diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 209a05f8d51..93e6d98bbbd 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -128,6 +128,8 @@ interface IssueChatMessageContext { options?: { allowSharing?: boolean; reason?: string }, ) => Promise; onStopRun?: (runId: string) => Promise; + stopRunLabel?: string; + stoppingRunLabel?: string; onInterruptQueued?: (runId: string) => Promise; onCancelQueued?: (commentId: string) => void; onImageClick?: (src: string) => void; @@ -276,6 +278,8 @@ interface IssueChatThreadProps { onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise; onCancelRun?: () => Promise; onStopRun?: (runId: string) => Promise; + stopRunLabel?: string; + stoppingRunLabel?: string; imageUploadHandler?: (file: File) => Promise; onAttachImage?: (file: File) => Promise; 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; const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined; @@ -1531,13 +1537,21 @@ function IssueChatAssistantMessage({ {canStopRun && onStopRun && runId ? ( { void onStopRun(runId); }} > - - {isStoppingRun ? "Stopping…" : "Stop run"} + {stopRunLabel.toLowerCase().includes("pause") ? ( + + ) : ( + + )} + {isStoppingRun ? stoppingRunLabel : stopRunLabel} ) : 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, diff --git a/ui/src/components/IssueRunLedger.test.tsx b/ui/src/components/IssueRunLedger.test.tsx index 7ab4bf1c84b..514c18ef970 100644 --- a/ui/src/components/IssueRunLedger.test.tsx +++ b/ui/src/components/IssueRunLedger.test.tsx @@ -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", () => { diff --git a/ui/src/components/IssueRunLedger.tsx b/ui/src/components/IssueRunLedger.tsx index 45f9bc2671f..e120b65bfa8 100644 --- a/ui/src/components/IssueRunLedger.tsx +++ b/ui/src/components/IssueRunLedger.tsx @@ -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"; diff --git a/ui/src/lib/issue-chat-messages.test.ts b/ui/src/lib/issue-chat-messages.test.ts index 3775b536f3b..a68c6c5a58a 100644 --- a/ui/src/lib/issue-chat-messages.test.ts +++ b/ui/src/lib/issue-chat-messages.test.ts @@ -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: [], diff --git a/ui/src/lib/issue-chat-messages.ts b/ui/src/lib/issue-chat-messages.ts index 6d59789a91b..9ccc2648ef2 100644 --- a/ui/src/lib/issue-chat-messages.ts +++ b/ui/src/lib/issue-chat-messages.ts @@ -45,6 +45,7 @@ export interface IssueChatLinkedRun { finishedAt?: Date | string | null; hasStoredOutput?: boolean; logBytes?: number | null; + resultJson?: Record | null; } export interface IssueChatTranscriptEntry { @@ -484,11 +485,13 @@ function runDurationLabel(run: { createdAt: Date | string; startedAt: Date | string | null; finishedAt?: Date | string | null; + resultJson?: Record | 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"; diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 17ee88c8d99..32da5890d50 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -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: () =>
Chat thread
, + IssueChatThread: (props: { + onStopRun?: (runId: string) => Promise; + stopRunLabel?: string; + stoppingRunLabel?: string; + }) => { + mockIssueChatThreadRender(props); + return ( +
+ Chat thread + {props.onStopRun ? ( + + ) : null} +
+ ); + }, })); 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( + + + , + ); + }); + 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( + + + , + ); + }); + 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", diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index ad5768450f0..b67e3141b4b 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -163,12 +163,32 @@ const TREE_CONTROL_MODE_LABEL: Record = { cancel: "Cancel subtree", restore: "Restore subtree", }; +const LEAF_WORK_CONTROL_MODE_LABEL: Partial> = { + pause: "Pause work", + resume: "Resume work", +}; const TREE_CONTROL_MODE_HELP_TEXT: Record = { 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> = { + 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; onAttachImage: (file: File) => Promise; onInterruptQueued: (runId: string) => Promise; + onPauseWorkRun?: (runId: string) => Promise; 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) => { 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 ? (
- Subtree pause is active. + + {childIssues.length === 0 ? "Paused by board." : "Subtree pause is active."} + - 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."}
- {heldDescendantCount} descendant{heldDescendantCount === 1 ? "" : "s"} held + {childIssues.length === 0 + ? "1 issue held" + : `${heldDescendantCount} descendant${heldDescendantCount === 1 ? "" : "s"} held`} {activeRootPauseHold?.createdAt ? ` · started ${relativeTime(activeRootPauseHold.createdAt)}` : ""}
- {canShowSubtreeControls ? ( + {canShowSubtreeControls || canResumeLeafWork ? (
- + {canShowSubtreeControls ? ( + + ) : null}
) : null}
@@ -3068,6 +3156,34 @@ export function IssueDetail() { + {canPauseLeafWork ? ( + + ) : null} + {canResumeLeafWork ? ( + + ) : null} {canShowSubtreeControls ? ( <>