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, }; }