From a015ac7a57b2f60b074f59daf75b1663bd6d52e7 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:55:51 -0500 Subject: [PATCH] fix(ui): allow interrupting queued issue runs (#9725) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The issue detail chat lets operators send messages while an issue run is live > - Messages sent during a live run are shown as queued and can expose an interrupt action > - The UI only treated running runs as interruptible, even though queued runs can also own the pending message > - That mismatch hid the interrupt button after sending a message while the agent run was still queued > - This pull request treats queued and running issue runs as interruptible and preserves the exact target run on optimistic and persisted comments > - The benefit is that operators can immediately interrupt the queued run their message is waiting behind ## Linked Issues or Issue Description - **What happened:** Sending a message while an issue-owned agent run was in `queued` state showed the message as pending but did not make the interrupt action available. - **Expected behavior:** A message queued behind either a queued or running issue run should retain that run as its interrupt target and expose the interrupt control. - **Steps to reproduce:** Open an in-progress issue with an issue-owned run still queued, send a chat message, and inspect the queued message actions. - **Version/commit:** Reproduced against the pre-change `master` UI behavior. - **Deployment mode:** Paperclip board UI with a queued issue execution run. ## What Changed - Generalized issue-run resolution from running-only to queued-or-running interruptible runs. - Used the interruptible run consistently for optimistic queue metadata, persisted comment decoration, cancel controls, and targeted interruption. - Added a regression test that sends a message behind a queued run and verifies the exact run is cancelled. ## Verification - `pnpm exec vitest run ui/src/pages/IssueDetail.test.tsx` — 44 tests passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm check:token-gates` — repository-wide gate currently reports nine pre-existing `#9627` comment literals; this patch adds no token literals or gate violations. ## Risks - Low risk: the behavior change is limited to selecting queued issue-owned runs as valid interrupt targets in the existing chat flow. - Cancellation remains targeted by run ID, and the regression test verifies the queued run ID is preserved through optimistic and persisted comment states. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI `gpt-5.4` via Codex CLI; context-window size is not exposed by this runtime; reasoning-enabled with repository, shell, GitHub CLI, and code-execution tools. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- ui/src/pages/IssueDetail.test.tsx | 97 +++++++++++++++++++++++++++++++ ui/src/pages/IssueDetail.tsx | 49 ++++++++++------ 2 files changed, 127 insertions(+), 19 deletions(-) diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 98f9efbe6e..4aa6b5c3b5 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -240,6 +240,14 @@ vi.mock("../components/IssueChatThread", () => ({ IssueChatThread: (props: { onWorkModeChange?: (workMode: string) => void; issueWorkMode?: string; + comments?: Array<{ + body: string; + clientStatus?: string; + queueState?: string; + queueTargetRunId?: string | null; + }>; + onAdd?: (body: string) => Promise; + onInterruptQueued?: (runId: string) => Promise; onStopRun?: (runId: string) => Promise; stopRunLabel?: string; stoppingRunLabel?: string; @@ -1267,6 +1275,95 @@ describe("IssueDetail", () => { expect(freshComment?.queueState).toBeUndefined(); }); + it("queues messages against a queued live run and interrupts that exact run", async () => { + const postedComment = createDeferred(); + mockIssuesApi.get.mockResolvedValue(createIssue({ + status: "in_progress", + executionRunId: "run-queued", + })); + mockIssuesApi.addComment.mockReturnValue(postedComment.promise); + mockHeartbeatsApi.cancel.mockResolvedValue({}); + mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([ + { + id: "run-queued", + status: "queued", + invocationSource: "issue", + triggerDetail: null, + contextCommentId: null, + contextWakeCommentId: null, + startedAt: null, + finishedAt: null, + createdAt: "2026-04-21T00:00:01.000Z", + agentId: "agent-1", + agentName: "Coder", + adapterType: "codex_local", + issueId: "issue-1", + }, + ]); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as { + onAdd: (body: string) => Promise; + }; + await act(async () => { + void props.onAdd("Queued run message"); + await Promise.resolve(); + }); + await flushReact(); + + const queuedProps = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as { + comments?: Array<{ + body: string; + clientStatus?: string; + queueState?: string; + queueTargetRunId?: string | null; + }>; + onInterruptQueued: (runId: string) => Promise; + }; + const optimisticComment = queuedProps.comments?.find((comment) => comment.body === "Queued run message"); + expect(optimisticComment).toMatchObject({ + clientStatus: "queued", + queueState: "queued", + queueTargetRunId: "run-queued", + }); + + await act(async () => { + postedComment.resolve(createIssueComment({ body: "Queued run message" })); + }); + await flushReact(); + + const persistedProps = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as { + comments?: Array<{ + body: string; + clientStatus?: string; + queueState?: string; + queueTargetRunId?: string | null; + }>; + onInterruptQueued: (runId: string) => Promise; + }; + const persistedComment = persistedProps.comments?.find((comment) => comment.body === "Queued run message"); + expect(persistedComment).toMatchObject({ + queueState: "queued", + queueTargetRunId: "run-queued", + }); + + await act(async () => { + await persistedProps.onInterruptQueued(persistedComment!.queueTargetRunId!); + }); + + expect(mockHeartbeatsApi.cancel).toHaveBeenCalledWith("run-queued"); + mockHeartbeatsApi.cancel.mockClear(); + }); + it("does not optimistically queue a fresh comment from an unlocked stale active-run cache", async () => { const postedComment = createDeferred(); mockIssuesApi.get.mockResolvedValue(createIssue({ diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index acfc5ea85a..897e6a0c59 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -316,12 +316,19 @@ export function shouldScrollIssueDetailToTopOnNavigation(input: { return input.previousIssueId !== input.nextIssueId; } -function resolveRunningIssueRun( +function resolveInterruptibleIssueRun( activeRun: ActiveRunForIssue | null | undefined, liveRuns: readonly LiveRunForIssue[] | undefined, ) { - const runningLiveRun = (liveRuns ?? []).find((run) => run.status === "running") ?? null; - return runningLiveRun ?? (activeRun?.status === "running" ? activeRun : null); + const issueLiveRun = + (liveRuns ?? []).find((run) => run.status === "running") ?? + (liveRuns ?? []).find((run) => run.status === "queued") ?? + null; + return issueLiveRun ?? ( + activeRun?.status === "running" || activeRun?.status === "queued" + ? activeRun + : null + ); } function dedupeLiveRunsById(liveRuns: readonly LiveRunForIssue[]) { @@ -354,7 +361,7 @@ function readIssueRunStateFromCache( return { liveRuns, activeRun: resolvedActiveRun, - runningIssueRun: resolveRunningIssueRun(resolvedActiveRun, liveRuns), + interruptibleIssueRun: resolveInterruptibleIssueRun(resolvedActiveRun, liveRuns), }; } @@ -1037,8 +1044,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ const resolvedActivity = activity ?? []; const resolvedLinkedRuns = linkedRuns ?? []; - const runningIssueRun = useMemo( - () => resolveRunningIssueRun(resolvedActiveRun, resolvedLiveRuns), + const interruptibleIssueRun = useMemo( + () => resolveInterruptibleIssueRun(resolvedActiveRun, resolvedLiveRuns), [resolvedActiveRun, resolvedLiveRuns], ); const liveRunIds = useMemo(() => { @@ -1058,7 +1065,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ })); }, [liveRunIds, resolvedLinkedRuns]); const commentsWithRunMeta = useMemo(() => { - const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null; + const activeRunStartedAt = interruptibleIssueRun?.startedAt ?? interruptibleIssueRun?.createdAt ?? null; const runMetaByCommentId = new Map(); const followUpCommentIds = new Set(); const agentIdByRunId = new Map(); @@ -1099,7 +1106,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ const locallyQueuedComment = applyLocalQueuedIssueCommentState(nextComment, { queuedTargetRunId, targetRunIsLive: queuedTargetRunId ? liveRunIds.has(queuedTargetRunId) : false, - runningRunId: runningIssueRun?.id ?? null, + runningRunId: interruptibleIssueRun?.id ?? null, }); if (locallyQueuedComment !== nextComment) { return locallyQueuedComment; @@ -1108,9 +1115,9 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ isQueuedIssueComment({ comment: nextComment, activeRunStartedAt, - activeRunAgentId: runningIssueRun?.agentId ?? null, - activeRunCommentId: runningIssueRun?.contextCommentId ?? null, - activeRunWakeCommentId: runningIssueRun?.contextWakeCommentId ?? null, + activeRunAgentId: interruptibleIssueRun?.agentId ?? null, + activeRunCommentId: interruptibleIssueRun?.contextCommentId ?? null, + activeRunWakeCommentId: interruptibleIssueRun?.contextWakeCommentId ?? null, runId: meta?.runId ?? nextComment.runId ?? null, interruptedRunId: meta?.interruptedRunId ?? nextComment.interruptedRunId ?? null, }) @@ -1118,7 +1125,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ return { ...nextComment, queueState: "queued" as const, - queueTargetRunId: runningIssueRun?.id ?? nextComment.queueTargetRunId ?? null, + queueTargetRunId: interruptibleIssueRun?.id ?? nextComment.queueTargetRunId ?? null, queueReason: queuedCommentReason, }; } @@ -1131,7 +1138,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ queuedCommentReason, resolvedActivity, resolvedLinkedRuns, - runningIssueRun, + interruptibleIssueRun, ]); const timelineEvents = useMemo( () => extractIssueTimelineEvents(resolvedActivity), @@ -1220,9 +1227,9 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ onSubmitInteractionVerdicts={onSubmitInteractionVerdicts} issueWorkMode={issueWorkMode} onWorkModeChange={onWorkModeChange} - onCancelRun={runningIssueRun && onPauseWorkRun + onCancelRun={interruptibleIssueRun && onPauseWorkRun ? async () => { - await onPauseWorkRun(runningIssueRun.id); + await onPauseWorkRun(interruptibleIssueRun.id); } : undefined} onImageClick={onImageClick} @@ -2415,7 +2422,9 @@ export function IssueDetail() { await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) }); const previousIssue = queryClient.getQueryData(queryKeys.issues.detail(issueId!)); - const queuedComment = !interrupt ? readIssueRunStateFromCache(queryClient, issueId!, issue).runningIssueRun : null; + const queuedComment = !interrupt + ? readIssueRunStateFromCache(queryClient, issueId!, issue).interruptibleIssueRun + : null; const optimisticComment = issue ? createOptimisticIssueComment({ companyId: issue.companyId, @@ -2673,7 +2682,9 @@ export function IssueDetail() { await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) }); const previousIssue = queryClient.getQueryData(queryKeys.issues.detail(issueId!)); - const queuedComment = !interrupt ? readIssueRunStateFromCache(queryClient, issueId!, issue).runningIssueRun : null; + const queuedComment = !interrupt + ? readIssueRunStateFromCache(queryClient, issueId!, issue).interruptibleIssueRun + : null; const optimisticComment = issue ? createOptimisticIssueComment({ companyId: issue.companyId, @@ -2793,11 +2804,11 @@ export function IssueDetail() { previousRunState.find((state) => state.activeRun)?.activeRun ?? null; const liveRunList = dedupeLiveRunsById(previousRunState.flatMap((state) => state.liveRuns ?? [])); - const runningIssueRun = resolveRunningIssueRun(cachedActiveRun, liveRunList); + const interruptibleIssueRun = resolveInterruptibleIssueRun(cachedActiveRun, liveRunList); const targetRun = cachedActiveRun?.id === runId ? cachedActiveRun - : liveRunList?.find((run) => run.id === runId) ?? runningIssueRun ?? null; + : liveRunList?.find((run) => run.id === runId) ?? interruptibleIssueRun ?? null; if (targetRun) { const interruptedAt = new Date().toISOString();