Implement issue pause resume UI

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-28 17:38:21 -05:00
1 parent 2834e356d9
commit 7a57a54b77
7 files changed
+395 -41

No files matched your search

+23 -3
View File
@@ -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", () => {
+1 -1
View File
@@ -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";
+31
View File
@@ -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: [],
+6
View File
@@ -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";
+172 -1
View File
@@ -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
View File
@@ -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">