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();