From 8cb0ce0de597542d1fec971d83a9a6725d7e195d Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:26:18 -0400 Subject: [PATCH] fix(ui): restore queued message interrupt action (#11374) 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 task thread lets an operator add guidance while an agent run is active > - A new message can wait behind that active run as a queued message > - The classic task view lets the operator interrupt the target run from that queued message > - The redesigned task view did not expose the same action > - This pull request restores the action and keeps it bound to the exact target run > - The benefit is that operators can apply urgent guidance without switching task views ## Linked Issues or Issue Description **What happened?** The redesigned task view showed `Queued` for a queued operator message, but it did not show the existing interrupt action. **Expected behavior** The queued message must show `Interrupt` next to `Queued`. The action must stop the exact run that the message is waiting behind. **Steps to reproduce** 1. Open a task in the redesigned task view while an agent run is active. 2. Send a new operator message so it enters the queued state. 3. Observe that the queued message has no interrupt action. **Paperclip version or commit** `bc0b5a1642` **Deployment mode** All deployment modes that use the redesigned task view. ## What Changed - Preserve persisted queued state and the target run ID in the redesigned thread model. - Render a token-compliant `Interrupt` action beside the queued state. - Reuse the existing exact-run interrupt callback and show a disabled `Interrupting…` state during the request. - Keep an assigned queue target immutable so an in-flight comment cannot rebind its interrupt action to a replacement run. - Add regression tests for persisted queued messages, replacement-run races, and the in-progress action state. - No documentation update was required because this restores existing behavior. ## Verification - `pnpm exec vitest run ui/src/components/TaskChatThread.test.tsx` - `pnpm exec vitest run ui/src/components/TaskChatThread.test.tsx ui/src/components/task-chat/task-chat-adapter.test.ts ui/src/pages/IssueDetail.test.tsx -t 'queued message actions|queues messages against a queued live run and interrupts that exact run|commentsToTaskChatItems'` - `pnpm exec vitest run ui/src/pages/IssueDetail.test.tsx -t 'queued message|queues messages'` - `pnpm exec vitest run ui/src/pages/IssueDetail.test.tsx` (52 passed) - `pnpm -r typecheck` - `pnpm test:run` (all server and UI groups passed; the CLI group passed after inherited static AWS credential variables were omitted) - `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY pnpm exec vitest run cli/src/__tests__/secrets.test.ts` - `pnpm build` - `pnpm check:token-gates` ## Risks Low risk. The change only adds an action to queued messages that have a target run and an interrupt callback. Messages without both values keep the current rendering. > 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 Codex with GPT-5. The runtime does not expose a more specific deployment ID or context-window size. The model used reasoning, repository tools, code execution, and test execution. ## 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/components/TaskChatThread.test.tsx | 70 ++++++++++++++ ui/src/components/TaskChatThread.tsx | 25 +++++ .../components/task-chat/TaskChatBubble.tsx | 9 +- .../task-chat/TaskChatThreadView.tsx | 14 ++- .../components/task-chat/task-chat-adapter.ts | 4 +- .../components/task-chat/task-chat-model.ts | 2 + ui/src/pages/IssueDetail.test.tsx | 96 +++++++++++++++++++ ui/src/pages/IssueDetail.tsx | 11 ++- 8 files changed, 224 insertions(+), 7 deletions(-) diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 34be3e9125..43c9142762 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -96,6 +96,76 @@ describe("TaskChatThread composer alignment (PAP-498)", () => { }); }); +describe("TaskChatThread queued message actions", () => { + it("interrupts the exact run that a persisted queued message is waiting behind", () => { + const onInterruptQueued = vi.fn(async () => {}); + const queuedComment = { + id: "comment-queued", + companyId: "company-1", + issueId: "issue-1", + authorType: "user" as const, + authorAgentId: null, + authorUserId: "user-1", + body: "Use the latest requirements instead.", + presentation: null, + metadata: null, + queueState: "queued" as const, + queueTargetRunId: "run-active", + createdAt: new Date("2026-08-14T12:00:00.000Z"), + updatedAt: new Date("2026-08-14T12:00:00.000Z"), + }; + + render( + {}} + onInterruptQueued={onInterruptQueued} + />, + ); + + const interrupt = [...container.querySelectorAll("button")].find( + (button) => button.textContent === "Interrupt", + ); + expect(container.textContent).toContain("Queued"); + expect(interrupt).not.toBeUndefined(); + + flushSync(() => interrupt!.click()); + expect(onInterruptQueued).toHaveBeenCalledOnce(); + expect(onInterruptQueued).toHaveBeenCalledWith("run-active"); + }); + + it("disables the action while the queued run is being interrupted", () => { + render( + {}} + onInterruptQueued={async () => {}} + interruptingQueuedRunId="run-active" + />, + ); + + const interrupting = [...container.querySelectorAll("button")].find( + (button) => button.textContent === "Interrupting…", + ); + expect(interrupting).not.toBeUndefined(); + expect(interrupting?.disabled).toBe(true); + }); +}); + describe("TaskChatThread mobile composer dock (PAP-495)", () => { it("pins the composer to the nav-aware bottom offset so its action row clears the auto-hiding bottom nav", () => { sidebarState.isMobile = true; diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 942d0ffbdd..86ce55de92 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -41,6 +41,7 @@ import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer"; import { useWindowAutoFollow } from "@/components/task-chat/useWindowAutoFollow"; import { useSidebar } from "@/context/SidebarContext"; import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument"; import { latestSameRunHandoffTimestamp } from "@/lib/issue-chat-messages"; import { isLiveIssueRun, isTerminalIssueStatus } from "@/lib/liveIssueIds"; @@ -123,6 +124,8 @@ export function TaskChatThread(props: TaskChatThreadProps) { feedbackTermsUrl = null, onVote, draftKey, + onInterruptQueued, + interruptingQueuedRunId, } = props; const linkedRunMetaById = useMemo(() => { @@ -522,6 +525,27 @@ export function TaskChatThread(props: TaskChatThreadProps) { [onVote, feedbackVoteByTargetId, feedbackDataSharingPreference, feedbackTermsUrl], ); + const renderQueuedAction = useCallback( + (item: TaskChatMessageItem) => { + const runId = item.queueTargetRunId; + if (item.optimistic !== "queued" || !runId || !onInterruptQueued) return null; + + const isInterrupting = interruptingQueuedRunId === runId; + return ( + + ); + }, + [interruptingQueuedRunId, onInterruptQueued], + ); + const renderInteraction = useCallback( (item: TaskChatInteractionItem) => ( : undefined} renderMessageActions={renderMessageActions} + renderQueuedAction={renderQueuedAction} tail={tailRunId ? (
(null); @@ -171,8 +173,9 @@ export function TaskChatBubble({ item, attachedTurn, actions }: TaskChatBubblePr ) : null} {item.optimistic ? ( - - {item.optimistic === "queued" ? "Queued" : "Sending…"} + + {item.optimistic === "queued" ? "Queued" : "Sending…"} + {item.optimistic === "queued" ? queuedAction : null} ) : attachedTurn ? ( // The settled turn takes over the footer line: timestamp + "✓ Worked" diff --git a/ui/src/components/task-chat/TaskChatThreadView.tsx b/ui/src/components/task-chat/TaskChatThreadView.tsx index 896715fba1..c503bcef84 100644 --- a/ui/src/components/task-chat/TaskChatThreadView.tsx +++ b/ui/src/components/task-chat/TaskChatThreadView.tsx @@ -41,6 +41,8 @@ interface TaskChatThreadViewProps { * fixtures omit it and the bubbles render actionless. */ renderMessageActions?: (item: TaskChatMessageItem) => ReactNode; + /** Renders an interrupt action beside a queued human message. */ + renderQueuedAction?: (item: TaskChatMessageItem) => ReactNode; /** Content appended inside the transcript scroller after the settled thread. */ tail?: ReactNode; /** Optional streaming-aware key when `tail` changes without changing `items`. */ @@ -56,6 +58,7 @@ function renderItem( renderInteraction?: (item: TaskChatInteractionItem) => ReactNode, renderBrief?: () => ReactNode, renderMessageActions?: (item: TaskChatMessageItem) => ReactNode, + renderQueuedAction?: (item: TaskChatMessageItem) => ReactNode, ) { switch (item.kind) { case "message": { @@ -69,6 +72,7 @@ function renderItem( (
- {renderItem(item, onApprovalDecision, renderInteraction, renderBrief, renderMessageActions)} + {renderItem( + item, + onApprovalDecision, + renderInteraction, + renderBrief, + renderMessageActions, + renderQueuedAction, + )}
))} {tail} diff --git a/ui/src/components/task-chat/task-chat-adapter.ts b/ui/src/components/task-chat/task-chat-adapter.ts index 0091444faf..62af3ca33e 100644 --- a/ui/src/components/task-chat/task-chat-adapter.ts +++ b/ui/src/components/task-chat/task-chat-adapter.ts @@ -70,8 +70,9 @@ export function commentsToTaskChatItems( authorName = (comment.authorUserId && ctx.userLabelMap?.get(comment.authorUserId)) || undefined; } + const queued = comment.queueState === "queued" || comment.clientStatus === "queued"; const optimistic = - comment.clientStatus === "queued" + queued ? "queued" : comment.clientStatus === "pending" ? "pending" @@ -90,6 +91,7 @@ export function commentsToTaskChatItems( text: comment.body, timestamp: formatTaskChatTimestamp(comment.createdAt), optimistic, + queueTargetRunId: queued ? comment.queueTargetRunId ?? null : null, agentIcon, onBehalfOfUserName, // System notices carry their structured hints through to the render diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index 71d771fd02..f1cacc1475 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -70,6 +70,8 @@ export interface TaskChatMessageItem { streaming?: boolean; /** Optimistic local echo state (matches IssueChatComment.clientStatus). */ optimistic?: "pending" | "queued"; + /** Live run this queued message is waiting behind. */ + queueTargetRunId?: string | null; /** Assigned agent icon name (AgentIconName) for the avatar header. */ agentIcon?: string | null; /** diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 4243256801..2e038a8392 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -1760,6 +1760,102 @@ describe("IssueDetail", () => { mockHeartbeatsApi.cancel.mockClear(); }); + it("does not rebind a queued message when another run becomes live before its request settles", async () => { + const postedComment = createDeferred(); + mockIssuesApi.get.mockResolvedValue(createIssue({ + status: "in_progress", + executionRunId: "run-original", + })); + mockIssuesApi.addComment.mockReturnValue(postedComment.promise); + mockHeartbeatsApi.cancel.mockResolvedValue({}); + mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([ + { + id: "run-original", + status: "running", + invocationSource: "issue", + triggerDetail: null, + contextCommentId: null, + contextWakeCommentId: null, + startedAt: "2026-04-21T00:00:01.000Z", + 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 initialProps = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as { + onAdd: (body: string) => Promise; + }; + await act(async () => { + void initialProps.onAdd("Keep this bound to the original run"); + await Promise.resolve(); + }); + await flushReact(); + + const replacementRun = { + id: "run-replacement", + status: "running" as const, + invocationSource: "issue" as const, + triggerDetail: null, + contextCommentId: null, + contextWakeCommentId: null, + startedAt: "2026-04-21T00:00:02.000Z", + finishedAt: null, + createdAt: "2026-04-21T00:00:02.000Z", + agentId: "agent-1", + agentName: "Coder", + adapterType: "codex_local", + issueId: "issue-1", + }; + await act(async () => { + queryClient.setQueryData(queryKeys.issues.liveRuns("issue-1"), [replacementRun]); + queryClient.setQueryData(queryKeys.issues.activeRun("issue-1"), replacementRun); + }); + await flushReact(); + + const replacementProps = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as { + comments?: Array<{ + body: string; + clientStatus?: string; + queueState?: string; + queueTargetRunId?: string | null; + }>; + onInterruptQueued: (runId: string) => Promise; + }; + const optimisticComment = replacementProps.comments?.find( + (comment) => comment.body === "Keep this bound to the original run", + ); + expect(optimisticComment).toMatchObject({ + clientStatus: "queued", + queueTargetRunId: "run-original", + }); + + await act(async () => { + await replacementProps.onInterruptQueued(optimisticComment!.queueTargetRunId!); + }); + expect(mockHeartbeatsApi.cancel).toHaveBeenCalledWith("run-original"); + expect(mockHeartbeatsApi.cancel).not.toHaveBeenCalledWith("run-replacement"); + + await act(async () => { + postedComment.resolve(createIssueComment({ body: "Keep this bound to the original run" })); + }); + await flushReact(); + 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 4a620be8fc..13b8ecfbbf 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1226,7 +1226,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ if (followUpCommentIds.has(comment.id)) { nextComment.followUpRequested = true; } - const queuedTargetRunId = locallyQueuedCommentRunIds.get(comment.id) ?? null; + const queuedTargetRunId = + locallyQueuedCommentRunIds.get(comment.id) ?? nextComment.queueTargetRunId ?? null; const locallyQueuedComment = applyLocalQueuedIssueCommentState(nextComment, { queuedTargetRunId, targetRunIsLive: queuedTargetRunId ? liveRunIds.has(queuedTargetRunId) : false, @@ -1235,6 +1236,12 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ if (locallyQueuedComment !== nextComment) { return locallyQueuedComment; } + // A queued target is fixed when the message is submitted. If that run + // settles while the request is still in flight, do not rebind the + // message's Interrupt action to an unrelated run that became live later. + if (queuedTargetRunId) { + return nextComment; + } if ( isQueuedIssueComment({ comment: nextComment, @@ -1249,7 +1256,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ return { ...nextComment, queueState: "queued" as const, - queueTargetRunId: interruptibleIssueRun?.id ?? nextComment.queueTargetRunId ?? null, + queueTargetRunId: interruptibleIssueRun?.id ?? null, queueReason: queuedCommentReason, }; }