mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
Implement issue pause resume UI
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
2834e356d9
commit
7a57a54b77
7 files changed
+395
-41
No files matched your search
@@ -128,6 +128,8 @@ interface IssueChatMessageContext {
|
||||
options?: { allowSharing?: boolean; reason?: string },
|
||||
) => Promise<void>;
|
||||
onStopRun?: (runId: string) => Promise<void>;
|
||||
stopRunLabel?: string;
|
||||
stoppingRunLabel?: string;
|
||||
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||
onCancelQueued?: (commentId: string) => void;
|
||||
onImageClick?: (src: string) => void;
|
||||
@@ -276,6 +278,8 @@ interface IssueChatThreadProps {
|
||||
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
|
||||
onCancelRun?: () => Promise<void>;
|
||||
onStopRun?: (runId: string) => Promise<void>;
|
||||
stopRunLabel?: string;
|
||||
stoppingRunLabel?: string;
|
||||
imageUploadHandler?: (file: File) => Promise<string>;
|
||||
onAttachImage?: (file: File) => Promise<IssueAttachment | void>;
|
||||
draftKey?: string;
|
||||
@@ -1338,6 +1342,8 @@ function IssueChatAssistantMessage({
|
||||
onVote,
|
||||
agentMap,
|
||||
onStopRun,
|
||||
stopRunLabel = "Stop run",
|
||||
stoppingRunLabel = "Stopping...",
|
||||
} = useContext(IssueChatCtx);
|
||||
const custom = message.metadata.custom as Record<string, unknown>;
|
||||
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
|
||||
@@ -1531,13 +1537,21 @@ function IssueChatAssistantMessage({
|
||||
{canStopRun && onStopRun && runId ? (
|
||||
<DropdownMenuItem
|
||||
disabled={isStoppingRun}
|
||||
className="text-red-700 focus:text-red-800 dark:text-red-300 dark:focus:text-red-200"
|
||||
className={cn(
|
||||
stopRunLabel.toLowerCase().includes("pause")
|
||||
? "text-amber-700 focus:text-amber-800 dark:text-amber-300 dark:focus:text-amber-200"
|
||||
: "text-red-700 focus:text-red-800 dark:text-red-300 dark:focus:text-red-200",
|
||||
)}
|
||||
onSelect={() => {
|
||||
void onStopRun(runId);
|
||||
}}
|
||||
>
|
||||
<Square className="mr-2 h-3.5 w-3.5 fill-current" />
|
||||
{isStoppingRun ? "Stopping…" : "Stop run"}
|
||||
{stopRunLabel.toLowerCase().includes("pause") ? (
|
||||
<PauseCircle className="mr-2 h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Square className="mr-2 h-3.5 w-3.5 fill-current" />
|
||||
)}
|
||||
{isStoppingRun ? stoppingRunLabel : stopRunLabel}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{runHref ? (
|
||||
@@ -2954,6 +2968,8 @@ export function IssueChatThread({
|
||||
onAdd,
|
||||
onCancelRun,
|
||||
onStopRun,
|
||||
stopRunLabel,
|
||||
stoppingRunLabel,
|
||||
imageUploadHandler,
|
||||
onAttachImage,
|
||||
draftKey,
|
||||
@@ -3391,6 +3407,8 @@ export function IssueChatThread({
|
||||
userProfileMap,
|
||||
onVote: stableOnVote,
|
||||
onStopRun: stableOnStopRun,
|
||||
stopRunLabel,
|
||||
stoppingRunLabel,
|
||||
onInterruptQueued: stableOnInterruptQueued,
|
||||
onCancelQueued: stableOnCancelQueued,
|
||||
onImageClick: stableOnImageClick,
|
||||
@@ -3407,6 +3425,8 @@ export function IssueChatThread({
|
||||
userProfileMap,
|
||||
stableOnVote,
|
||||
stableOnStopRun,
|
||||
stopRunLabel,
|
||||
stoppingRunLabel,
|
||||
stableOnInterruptQueued,
|
||||
stableOnCancelQueued,
|
||||
stableOnImageClick,
|
||||
|
||||
@@ -279,12 +279,18 @@ describe("IssueRunLedger", () => {
|
||||
resultJson: { stopReason: "budget_paused" },
|
||||
createdAt: "2026-04-18T19:56:00.000Z",
|
||||
}),
|
||||
createRun({
|
||||
runId: "run-paused",
|
||||
resultJson: { stopReason: "paused" },
|
||||
createdAt: "2026-04-18T19:55:00.000Z",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("timeout (30s timeout)");
|
||||
expect(container.textContent).toContain("cancelled");
|
||||
expect(container.textContent).toContain("budget paused");
|
||||
expect(container.textContent).toContain("paused by board");
|
||||
});
|
||||
|
||||
it("surfaces active and completed child issue summaries", () => {
|
||||
|
||||
@@ -256,7 +256,7 @@ function stopReasonLabel(run: RunForIssue) {
|
||||
}
|
||||
if (stopReason === "budget_paused") return "budget paused";
|
||||
if (stopReason === "cancelled") return "cancelled";
|
||||
if (stopReason === "paused") return "paused";
|
||||
if (stopReason === "paused") return "paused by board";
|
||||
if (stopReason === "process_lost") return "process lost";
|
||||
if (stopReason === "adapter_failed") return "adapter failed";
|
||||
if (stopReason === "completed") return timeoutText ? `completed (${timeoutText})` : "completed";
|
||||
|
||||
@@ -605,6 +605,37 @@ describe("buildIssueChatMessages", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("labels pause-caused cancelled runs as paused by board", () => {
|
||||
const messages = buildIssueChatMessages({
|
||||
comments: [],
|
||||
timelineEvents: [],
|
||||
linkedRuns: [
|
||||
{
|
||||
runId: "run-paused",
|
||||
status: "cancelled",
|
||||
agentId: "agent-1",
|
||||
agentName: "CodexCoder",
|
||||
createdAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
startedAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
finishedAt: new Date("2026-04-06T12:02:00.000Z"),
|
||||
resultJson: { stopReason: "paused" },
|
||||
},
|
||||
],
|
||||
liveRuns: [],
|
||||
transcriptsByRunId: new Map([
|
||||
["run-paused", [{ kind: "assistant", ts: "2026-04-06T12:01:05.000Z", text: "Working on it." }]],
|
||||
]),
|
||||
hasOutputForRun: (runId) => runId === "run-paused",
|
||||
currentUserId: "user-1",
|
||||
});
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]?.metadata.custom).toMatchObject({
|
||||
chainOfThoughtLabel: "Paused by board after 1 minute",
|
||||
runStatus: "cancelled",
|
||||
});
|
||||
});
|
||||
|
||||
it("can keep succeeded runs without transcript output for embedded run feeds", () => {
|
||||
const messages = buildIssueChatMessages({
|
||||
comments: [],
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface IssueChatLinkedRun {
|
||||
finishedAt?: Date | string | null;
|
||||
hasStoredOutput?: boolean;
|
||||
logBytes?: number | null;
|
||||
resultJson?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface IssueChatTranscriptEntry {
|
||||
@@ -484,11 +485,13 @@ function runDurationLabel(run: {
|
||||
createdAt: Date | string;
|
||||
startedAt: Date | string | null;
|
||||
finishedAt?: Date | string | null;
|
||||
resultJson?: Record<string, unknown> | null;
|
||||
}) {
|
||||
const start = run.startedAt ?? run.createdAt;
|
||||
const end = run.finishedAt ?? null;
|
||||
const durationMs = end ? Math.max(0, toTimestamp(end) - toTimestamp(start)) : null;
|
||||
const durationText = formatDurationWords(durationMs);
|
||||
const stopReason = typeof run.resultJson?.stopReason === "string" ? run.resultJson.stopReason : null;
|
||||
switch (run.status) {
|
||||
case "succeeded":
|
||||
return durationText ? `Worked for ${durationText}` : "Finished work";
|
||||
@@ -498,6 +501,9 @@ function runDurationLabel(run: {
|
||||
case "timed_out":
|
||||
return durationText ? `Timed out after ${durationText}` : "Run timed out";
|
||||
case "cancelled":
|
||||
if (stopReason === "paused") {
|
||||
return durationText ? `Paused by board after ${durationText}` : "Paused by board";
|
||||
}
|
||||
return durationText ? `Cancelled after ${durationText}` : "Run cancelled";
|
||||
case "queued":
|
||||
return "Queued";
|
||||
|
||||
@@ -68,6 +68,7 @@ const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||
const mockSetMobileToolbar = vi.hoisted(() => vi.fn());
|
||||
const mockPushToast = vi.hoisted(() => vi.fn());
|
||||
const mockIssuesListRender = vi.hoisted(() => vi.fn());
|
||||
const mockIssueChatThreadRender = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: mockIssuesApi,
|
||||
@@ -190,7 +191,23 @@ vi.mock("../components/InlineEditor", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../components/IssueChatThread", () => ({
|
||||
IssueChatThread: () => <div data-testid="issue-chat-thread">Chat thread</div>,
|
||||
IssueChatThread: (props: {
|
||||
onStopRun?: (runId: string) => Promise<void>;
|
||||
stopRunLabel?: string;
|
||||
stoppingRunLabel?: string;
|
||||
}) => {
|
||||
mockIssueChatThreadRender(props);
|
||||
return (
|
||||
<div data-testid="issue-chat-thread">
|
||||
Chat thread
|
||||
{props.onStopRun ? (
|
||||
<button type="button" onClick={() => void props.onStopRun?.("run-active-1")}>
|
||||
{props.stopRunLabel ?? "Stop run"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../components/IssueDocumentsSection", () => ({
|
||||
@@ -786,6 +803,7 @@ describe("IssueDetail", () => {
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
});
|
||||
mockIssuesListRender.mockClear();
|
||||
mockIssueChatThreadRender.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -1038,6 +1056,159 @@ describe("IssueDetail", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes leaf pause controls and routes issue active-run stop through Pause work", async () => {
|
||||
const pausePreview = createPausePreview();
|
||||
pausePreview.totals = {
|
||||
...pausePreview.totals,
|
||||
totalIssues: 1,
|
||||
affectedIssues: 1,
|
||||
skippedIssues: 0,
|
||||
activeRuns: 1,
|
||||
};
|
||||
pausePreview.issues = [pausePreview.issues[0]!];
|
||||
pausePreview.skippedIssues = [];
|
||||
const pauseHold = createPauseHold({
|
||||
id: "leaf-pause-hold-1",
|
||||
mode: "pause",
|
||||
reason: "Paused from active run controls.",
|
||||
releasePolicy: { strategy: "manual", note: "leaf_pause" },
|
||||
members: [],
|
||||
});
|
||||
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue({
|
||||
status: "in_progress",
|
||||
assigneeAgentId: "agent-1",
|
||||
executionRunId: "run-active-1",
|
||||
}));
|
||||
mockIssuesApi.previewTreeControl.mockResolvedValue(pausePreview);
|
||||
mockIssuesApi.createTreeHold.mockResolvedValue({ hold: pauseHold, preview: pausePreview });
|
||||
mockAgentsApi.list.mockResolvedValue([createAgent()]);
|
||||
mockAuthApi.getSession.mockResolvedValue({
|
||||
session: { userId: "user-1" },
|
||||
user: { id: "user-1" },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
stopRunLabel: "Pause work",
|
||||
stoppingRunLabel: "Pausing...",
|
||||
});
|
||||
|
||||
const chatPauseButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Pause work");
|
||||
expect(chatPauseButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
chatPauseButton!.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockIssuesApi.createTreeHold).toHaveBeenCalledWith("PAP-1", {
|
||||
mode: "pause",
|
||||
reason: "Paused from active run controls.",
|
||||
releasePolicy: { strategy: "manual", note: "leaf_pause" },
|
||||
metadata: { source: "issue_active_run_control", runId: "run-active-1" },
|
||||
});
|
||||
|
||||
const moreButton = container.querySelector('button[aria-label="More issue actions"]') as HTMLButtonElement | null;
|
||||
expect(moreButton).toBeTruthy();
|
||||
await act(async () => {
|
||||
moreButton!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const pauseMenuButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Pause work...");
|
||||
expect(pauseMenuButton).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Paused by board distinctly and defaults leaf resume to wake the assignee", async () => {
|
||||
const activeHold = createPauseHold();
|
||||
const releasedHold = createPauseHold({
|
||||
status: "released",
|
||||
releasedAt: new Date("2026-04-21T00:01:00.000Z"),
|
||||
releasedByActorType: "user",
|
||||
releasedByUserId: "user-1",
|
||||
releaseReason: "Ready to continue",
|
||||
updatedAt: new Date("2026-04-21T00:01:00.000Z"),
|
||||
});
|
||||
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue({
|
||||
status: "in_review",
|
||||
assigneeAgentId: "agent-1",
|
||||
}));
|
||||
mockIssuesApi.getTreeControlState.mockResolvedValue({
|
||||
activePauseHold: {
|
||||
holdId: "hold-1",
|
||||
rootIssueId: "issue-1",
|
||||
issueId: "issue-1",
|
||||
isRoot: true,
|
||||
mode: "pause",
|
||||
reason: null,
|
||||
releasePolicy: { strategy: "manual", note: "leaf_pause" },
|
||||
},
|
||||
});
|
||||
mockIssuesApi.listTreeHolds.mockResolvedValue([activeHold]);
|
||||
mockIssuesApi.previewTreeControl.mockResolvedValue(createResumePreview());
|
||||
mockIssuesApi.releaseTreeHold.mockResolvedValue(releasedHold);
|
||||
mockAgentsApi.list.mockResolvedValue([createAgent()]);
|
||||
mockAuthApi.getSession.mockResolvedValue({
|
||||
session: { userId: "user-1" },
|
||||
user: { id: "user-1" },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Paused by board.");
|
||||
expect(container.textContent).toContain("in_review");
|
||||
expect(container.textContent).not.toContain("Subtree pause is active.");
|
||||
|
||||
const resumeButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Resume work");
|
||||
expect(resumeButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
resumeButton!.click();
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const wakeCheckbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement | null;
|
||||
expect(wakeCheckbox?.checked).toBe(true);
|
||||
|
||||
const applyResumeButton = Array.from(container.querySelectorAll("button"))
|
||||
.filter((button) => button.textContent?.trim() === "Resume work")
|
||||
.at(-1);
|
||||
expect(applyResumeButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
applyResumeButton!.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockIssuesApi.releaseTreeHold).toHaveBeenCalledWith("PAP-1", "hold-1", {
|
||||
reason: null,
|
||||
metadata: { wakeAgents: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes restore subtree from the issue actions menu", async () => {
|
||||
const childIssue = createIssue({
|
||||
id: "child-1",
|
||||
|
||||
+156
-36
@@ -163,12 +163,32 @@ const TREE_CONTROL_MODE_LABEL: Record<IssueTreeControlMode, string> = {
|
||||
cancel: "Cancel subtree",
|
||||
restore: "Restore subtree",
|
||||
};
|
||||
const LEAF_WORK_CONTROL_MODE_LABEL: Partial<Record<IssueTreeControlMode, string>> = {
|
||||
pause: "Pause work",
|
||||
resume: "Resume work",
|
||||
};
|
||||
const TREE_CONTROL_MODE_HELP_TEXT: Record<IssueTreeControlMode, string> = {
|
||||
pause: "Pause active execution in this issue subtree until an explicit resume.",
|
||||
resume: "Release the active subtree pause hold so held work can continue.",
|
||||
cancel: "Cancel non-terminal issues in this subtree and stop queued/running work where possible.",
|
||||
restore: "Restore issues cancelled by this subtree operation so work can resume.",
|
||||
};
|
||||
const LEAF_WORK_CONTROL_MODE_HELP_TEXT: Partial<Record<IssueTreeControlMode, string>> = {
|
||||
pause: "Pause active execution on this issue until an explicit resume.",
|
||||
resume: "Release the active pause hold so this issue can continue.",
|
||||
};
|
||||
|
||||
function issueTreeControlLabel(mode: IssueTreeControlMode, scope: "leaf" | "subtree") {
|
||||
return scope === "leaf"
|
||||
? LEAF_WORK_CONTROL_MODE_LABEL[mode] ?? TREE_CONTROL_MODE_LABEL[mode]
|
||||
: TREE_CONTROL_MODE_LABEL[mode];
|
||||
}
|
||||
|
||||
function issueTreeControlHelpText(mode: IssueTreeControlMode, scope: "leaf" | "subtree") {
|
||||
return scope === "leaf"
|
||||
? LEAF_WORK_CONTROL_MODE_HELP_TEXT[mode] ?? TREE_CONTROL_MODE_HELP_TEXT[mode]
|
||||
: TREE_CONTROL_MODE_HELP_TEXT[mode];
|
||||
}
|
||||
|
||||
function treeControlPreviewErrorCopy(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
@@ -587,8 +607,10 @@ type IssueDetailChatTabProps = {
|
||||
onImageUpload: (file: File) => Promise<string>;
|
||||
onAttachImage: (file: File) => Promise<IssueAttachment | void>;
|
||||
onInterruptQueued: (runId: string) => Promise<void>;
|
||||
onPauseWorkRun?: (runId: string) => Promise<void>;
|
||||
onCancelQueued: (commentId: string) => void;
|
||||
interruptingQueuedRunId: string | null;
|
||||
pausingWorkRunId: string | null;
|
||||
onImageClick: (src: string) => void;
|
||||
onAcceptInteraction: (
|
||||
interaction: ActionableIssueThreadInteraction,
|
||||
@@ -637,8 +659,10 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
onImageUpload,
|
||||
onAttachImage,
|
||||
onInterruptQueued,
|
||||
onPauseWorkRun,
|
||||
onCancelQueued,
|
||||
interruptingQueuedRunId,
|
||||
pausingWorkRunId,
|
||||
onImageClick,
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
@@ -827,16 +851,18 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
onInterruptQueued={onInterruptQueued}
|
||||
onCancelQueued={onCancelQueued}
|
||||
interruptingQueuedRunId={interruptingQueuedRunId}
|
||||
stoppingRunId={interruptingQueuedRunId}
|
||||
onStopRun={onInterruptQueued}
|
||||
stoppingRunId={pausingWorkRunId}
|
||||
onStopRun={onPauseWorkRun}
|
||||
stopRunLabel="Pause work"
|
||||
stoppingRunLabel="Pausing..."
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
onSubmitInteractionAnswers={(interaction, answers) =>
|
||||
onSubmitInteractionAnswers(interaction, answers)
|
||||
}
|
||||
onCancelRun={runningIssueRun
|
||||
onCancelRun={runningIssueRun && onPauseWorkRun
|
||||
? async () => {
|
||||
await onInterruptQueued(runningIssueRun.id);
|
||||
await onPauseWorkRun(runningIssueRun.id);
|
||||
}
|
||||
: undefined}
|
||||
onImageClick={onImageClick}
|
||||
@@ -1561,7 +1587,7 @@ export function IssueDetail() {
|
||||
reason: treeControlReason.trim() || null,
|
||||
releasePolicy: {
|
||||
strategy: "manual",
|
||||
...(treeControlMode === "pause" ? { note: "full_pause" } : {}),
|
||||
...(treeControlMode === "pause" ? { note: treeControlScope === "leaf" ? "leaf_pause" : "full_pause" } : {}),
|
||||
},
|
||||
...(treeControlMode === "restore"
|
||||
? { metadata: { wakeAgents: treeControlWakeAgentsOnResume } }
|
||||
@@ -1570,18 +1596,20 @@ export function IssueDetail() {
|
||||
return { kind: "create" as const, hold: created.hold, preview: created.preview };
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
const modeLabel = TREE_CONTROL_MODE_LABEL[result.hold.mode];
|
||||
const modeLabel = issueTreeControlLabel(result.hold.mode, treeControlScope);
|
||||
const cancelCount = result.preview?.totals.activeRuns ?? 0;
|
||||
pushToast({
|
||||
title: result.kind === "release"
|
||||
? "Subtree resumed"
|
||||
? treeControlScope === "leaf" ? "Work resumed" : "Subtree resumed"
|
||||
: result.hold.mode === "pause"
|
||||
? "Subtree paused"
|
||||
? treeControlScope === "leaf" ? "Work paused" : "Subtree paused"
|
||||
: `${modeLabel} applied`,
|
||||
body: result.kind === "release"
|
||||
? (result.hold.releaseReason?.trim() || "Active subtree pause released.")
|
||||
? (result.hold.releaseReason?.trim() || (treeControlScope === "leaf" ? "Active issue pause released." : "Active subtree pause released."))
|
||||
: result.hold.mode === "pause"
|
||||
? `Subtree paused. ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.`
|
||||
? treeControlScope === "leaf"
|
||||
? `Work paused. ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.`
|
||||
: `Subtree paused. ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.`
|
||||
: result.hold.reason?.trim()
|
||||
? result.hold.reason
|
||||
: "Subtree control applied.",
|
||||
@@ -1592,6 +1620,7 @@ export function IssueDetail() {
|
||||
setTreeControlCancelConfirmed(false);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) }),
|
||||
@@ -1603,7 +1632,10 @@ export function IssueDetail() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }),
|
||||
...(issue?.id
|
||||
? [queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByParent(selectedCompanyId, issue.id) })]
|
||||
? [
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByParent(selectedCompanyId, issue.id) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByDescendantRoot(selectedCompanyId, issue.id) }),
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
}
|
||||
@@ -1616,6 +1648,45 @@ export function IssueDetail() {
|
||||
});
|
||||
},
|
||||
});
|
||||
const pauseIssueWorkRun = useMutation({
|
||||
mutationFn: async (runId: string) => {
|
||||
const created = await issuesApi.createTreeHold(issueId!, {
|
||||
mode: "pause",
|
||||
reason: "Paused from active run controls.",
|
||||
releasePolicy: { strategy: "manual", note: treeControlScope === "leaf" ? "leaf_pause" : "full_pause" },
|
||||
metadata: { source: "issue_active_run_control", runId },
|
||||
});
|
||||
return created;
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
const cancelCount = result.preview?.totals.activeRuns ?? 0;
|
||||
pushToast({
|
||||
title: "Work paused",
|
||||
body: cancelCount > 0
|
||||
? `Work paused. ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.`
|
||||
: "Work paused. This issue is held until resume.",
|
||||
tone: "success",
|
||||
});
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-state", issueId ?? "pending"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["issues", "tree-holds", issueId ?? "pending"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-preview", issueId ?? "pending"] }),
|
||||
]);
|
||||
invalidateIssueCollections();
|
||||
},
|
||||
onError: (err) => {
|
||||
pushToast({
|
||||
title: "Unable to pause work",
|
||||
body: err instanceof Error ? err.message : "Please try again.",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
const handleIssuePropertiesUpdate = useCallback((data: Record<string, unknown>) => {
|
||||
updateIssue.mutate(data);
|
||||
}, [updateIssue.mutate]);
|
||||
@@ -2733,16 +2804,25 @@ export function IssueDetail() {
|
||||
const canShowSubtreeControls = canManageTreeControl && childIssues.length > 0;
|
||||
const canResumeSubtree = canShowSubtreeControls && activePauseHold?.isRoot === true;
|
||||
const canRestoreSubtree = canShowSubtreeControls && activeCancelHolds.length > 0;
|
||||
const isTerminalIssue = issue.status === "done" || issue.status === "cancelled";
|
||||
const isAgentOwnedNonTerminalIssue = Boolean(issue.assigneeAgentId) && !isTerminalIssue;
|
||||
const canPauseLeafWork = canManageTreeControl && childIssues.length === 0 && !activePauseHold && !isTerminalIssue;
|
||||
const canResumeLeafWork = canManageTreeControl && childIssues.length === 0 && activePauseHold?.isRoot === true;
|
||||
const treeControlScope: "leaf" | "subtree" = childIssues.length === 0 ? "leaf" : "subtree";
|
||||
const previewAffectedIssueCount = treePreviewAffectedIssues.length;
|
||||
const previewAffectedAgentCount = treeControlPreview?.totals.affectedAgents ?? 0;
|
||||
const treeControlPrimaryButtonLabel =
|
||||
treeControlMode === "pause"
|
||||
? "Pause and stop work"
|
||||
? treeControlScope === "leaf"
|
||||
? "Pause work"
|
||||
: "Pause and stop work"
|
||||
: treeControlMode === "cancel"
|
||||
? `Cancel ${previewAffectedIssueCount} issues`
|
||||
: treeControlMode === "restore"
|
||||
? `Restore ${previewAffectedIssueCount} issues`
|
||||
: "Resume subtree";
|
||||
: treeControlScope === "leaf"
|
||||
? "Resume work"
|
||||
: "Resume subtree";
|
||||
const treePreviewAffectedIssueRows = treePreviewDisplayIssues.map((candidate) => ({
|
||||
candidate,
|
||||
issue: {
|
||||
@@ -2771,7 +2851,7 @@ export function IssueDetail() {
|
||||
)
|
||||
: null;
|
||||
const composerHint = pausedComposerHint;
|
||||
const queuedCommentReason: "hold" | "active_run" | "other" = "active_run";
|
||||
const queuedCommentReason: "hold" | "active_run" | "other" = activePauseHold ? "hold" : "active_run";
|
||||
const canApplyTreeControl =
|
||||
Boolean(treeControlPreview)
|
||||
&& !treeControlPreviewLoading
|
||||
@@ -2846,50 +2926,58 @@ export function IssueDetail() {
|
||||
{activePauseHold.isRoot ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">Subtree pause is active.</span>
|
||||
<span className="font-medium">
|
||||
{childIssues.length === 0 ? "Paused by board." : "Subtree pause is active."}
|
||||
</span>
|
||||
<span className="text-xs text-amber-900/80 dark:text-amber-100/80">
|
||||
Root and descendant execution is held until resume. Human comments can still wake assignees for triage.
|
||||
{childIssues.length === 0
|
||||
? "Issue execution is held until resume. Human comments can still wake the assignee for triage."
|
||||
: "Root and descendant execution is held until resume. Human comments can still wake assignees for triage."}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-amber-900/80 dark:text-amber-100/80">
|
||||
{heldDescendantCount} descendant{heldDescendantCount === 1 ? "" : "s"} held
|
||||
{childIssues.length === 0
|
||||
? "1 issue held"
|
||||
: `${heldDescendantCount} descendant${heldDescendantCount === 1 ? "" : "s"} held`}
|
||||
{activeRootPauseHold?.createdAt ? ` · started ${relativeTime(activeRootPauseHold.createdAt)}` : ""}
|
||||
</div>
|
||||
{canShowSubtreeControls ? (
|
||||
{canShowSubtreeControls || canResumeLeafWork ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTreeControlMode("resume");
|
||||
setTreeControlWakeAgentsOnResume(true);
|
||||
setTreeControlWakeAgentsOnResume(isAgentOwnedNonTerminalIssue || canShowSubtreeControls);
|
||||
setTreeControlOpen(true);
|
||||
}}
|
||||
>
|
||||
Resume subtree
|
||||
{childIssues.length === 0 ? "Resume work" : "Resume subtree"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTreeControlMode("resume");
|
||||
setTreeControlWakeAgentsOnResume(true);
|
||||
setTreeControlWakeAgentsOnResume(isAgentOwnedNonTerminalIssue || canShowSubtreeControls);
|
||||
setTreeControlOpen(true);
|
||||
}}
|
||||
>
|
||||
View affected ({heldDescendantCount})
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
setTreeControlMode("cancel");
|
||||
setTreeControlCancelConfirmed(false);
|
||||
setTreeControlOpen(true);
|
||||
}}
|
||||
>
|
||||
Cancel subtree...
|
||||
View affected ({childIssues.length === 0 ? 1 : heldDescendantCount})
|
||||
</Button>
|
||||
{canShowSubtreeControls ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
setTreeControlMode("cancel");
|
||||
setTreeControlCancelConfirmed(false);
|
||||
setTreeControlOpen(true);
|
||||
}}
|
||||
>
|
||||
Cancel subtree...
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -3068,6 +3156,34 @@ export function IssueDetail() {
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 p-1" align="end">
|
||||
{canPauseLeafWork ? (
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
setTreeControlMode("pause");
|
||||
setTreeControlCancelConfirmed(false);
|
||||
setTreeControlOpen(true);
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
>
|
||||
<PauseCircle className="h-3 w-3" />
|
||||
Pause work...
|
||||
</button>
|
||||
) : null}
|
||||
{canResumeLeafWork ? (
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
setTreeControlMode("resume");
|
||||
setTreeControlWakeAgentsOnResume(isAgentOwnedNonTerminalIssue);
|
||||
setTreeControlOpen(true);
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
>
|
||||
<PlayCircle className="h-3 w-3" />
|
||||
Resume work
|
||||
</button>
|
||||
) : null}
|
||||
{canShowSubtreeControls ? (
|
||||
<>
|
||||
<button
|
||||
@@ -3474,8 +3590,12 @@ export function IssueDetail() {
|
||||
onImageUpload={handleCommentImageUpload}
|
||||
onAttachImage={handleCommentAttachImage}
|
||||
onInterruptQueued={handleInterruptQueuedRun}
|
||||
onPauseWorkRun={canManageTreeControl
|
||||
? (runId) => pauseIssueWorkRun.mutateAsync(runId).then(() => undefined)
|
||||
: undefined}
|
||||
onCancelQueued={handleCancelQueuedComment}
|
||||
interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null}
|
||||
pausingWorkRunId={pauseIssueWorkRun.isPending ? pauseIssueWorkRun.variables ?? null : null}
|
||||
onImageClick={handleChatImageClick}
|
||||
onAcceptInteraction={handleAcceptInteraction}
|
||||
onRejectInteraction={handleRejectInteraction}
|
||||
@@ -3527,9 +3647,9 @@ export function IssueDetail() {
|
||||
<Dialog open={treeControlOpen} onOpenChange={setTreeControlOpen}>
|
||||
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[560px]">
|
||||
<DialogHeader className="border-b border-border/60 px-6 pb-4 pr-12 pt-6">
|
||||
<DialogTitle>{TREE_CONTROL_MODE_LABEL[treeControlMode]}</DialogTitle>
|
||||
<DialogTitle>{issueTreeControlLabel(treeControlMode, treeControlScope)}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{TREE_CONTROL_MODE_HELP_TEXT[treeControlMode]}
|
||||
{issueTreeControlHelpText(treeControlMode, treeControlScope)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain px-6 py-4">
|
||||
|
||||
Reference in new issue
Block a user