From 2969ae989f3132a3cddfbe26c14f79c2e99063f1 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 17 Aug 2026 11:07:22 +0000 Subject: [PATCH 1/9] fix(site/src/pages/AgentsPage): treat interrupting chats as busy in the composer The composer derived isStreaming from hasStreamState or a running chat status, excluding interrupting. Once an interrupt is requested the stream state is gone, so a chat still finalizing an interruption rendered the idle composer: no Stop button, the normal Send button, and no busy indication. That made the interrupting state visually indistinguishable from waiting. Include interrupting in the composer's streaming check and add Storybook coverage for the I1 state (interrupting with a queued message) alongside a running-state control. --- .../components/ChatPageContent.stories.tsx | 107 +++++++++++++++++- .../AgentsPage/components/ChatPageContent.tsx | 3 +- 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx index 83a66823802..78b1112302e 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx @@ -1,12 +1,12 @@ import { MessageScroller } from "@shadcn/react/message-scroller"; import type { Meta, StoryObj } from "@storybook/react-vite"; import type { FC } from "react"; -import { expect, within } from "storybook/test"; +import { expect, fn, within } from "storybook/test"; import type * as TypesGen from "#/api/typesGenerated"; import { ChatWorkspaceContext } from "../context/ChatWorkspaceContext"; import { createChatStore } from "./ChatConversation/chatStore"; import { FIXTURE_NOW } from "./ChatConversation/storyFixtures"; -import { ChatPageTimeline } from "./ChatPageContent"; +import { ChatPageInput, ChatPageTimeline } from "./ChatPageContent"; // These stories cover transcript rendering, so history paging stays idle. const StoryChatPageTimeline: FC<{ @@ -34,6 +34,51 @@ type Story = StoryObj; const CHAT_ID = "chat-page-content-stories"; +// Renders only the composer half of the chat page. chatId and +// organizationId stay undefined so the prompt-history and draft +// attachment queries stay disabled. +const StoryChatPageInput: FC<{ + store: ReturnType; +}> = ({ store }) => ( +
+ +
+); + const buildMessage = ( id: number, role: TypesGen.ChatMessageRole, @@ -46,6 +91,26 @@ const buildMessage = ( content, }); +// Matches the backend I1 state: an interruption has been requested +// and the stream has already been torn down, so the store holds no +// stream state while the chat status is still "interrupting". +const buildInterruptingStore = () => { + const store = createChatStore(); + store.replaceMessages([ + buildMessage(1, "user", [{ type: "text", text: "Refactor the module" }]), + ]); + store.setQueuedMessages([ + { + id: 2, + chat_id: CHAT_ID, + content: [{ type: "text", text: "Also rename the helpers" }], + created_at: new Date(FIXTURE_NOW).toISOString(), + }, + ]); + store.setChatStatus("interrupting"); + return store; +}; + const buildThinkingSpacerStore = () => { const store = createChatStore(); @@ -165,3 +230,41 @@ export const MergedMessagesRenderInIDOrder: Story = { ); }, }; + +// A chat finalizing an interruption has no stream state but is still +// busy, so the composer must match the running case: Stop button +// shown, Send button hidden. Covers the I1 state (interrupting with +// a queued message), which previously rendered as idle because the +// composer treated only "running" as streaming. +export const InterruptingShowsBusyComposer: Story = { + render: () => { + const store = buildInterruptingStore(); + return ; + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // The queued message confirms the chat is mid-interruption + // (backend state I1), not idle. + expect(canvas.getByText("Also rename the helpers")).toBeInTheDocument(); + expect(canvas.getByRole("button", { name: "Stop" })).toBeInTheDocument(); + expect(canvas.queryByRole("button", { name: "Send" })).toBeNull(); + }, +}; + +// Control for InterruptingShowsBusyComposer: a running chat with an +// identical history and queue renders the same busy composer, so the +// rendering above follows the active-chat statuses rather than any +// interrupting-specific path. +export const RunningShowsBusyComposer: Story = { + render: () => { + const store = buildInterruptingStore(); + store.setChatStatus("running"); + return ; + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Also rename the helpers")).toBeInTheDocument(); + expect(canvas.getByRole("button", { name: "Stop" })).toBeInTheDocument(); + expect(canvas.queryByRole("button", { name: "Send" })).toBeNull(); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index 1a9172d0e61..7b5c15a0196 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -505,7 +505,8 @@ export const ChatPageInput: FC = ({ wasEditingRef.current = isEditing; }, [isEditing, resetEditAttachments]); - const isStreaming = hasStreamState || chatStatus === "running"; + const isStreaming = + hasStreamState || chatStatus === "running" || chatStatus === "interrupting"; const inputElement = ( Date: Mon, 17 Aug 2026 11:43:03 +0000 Subject: [PATCH 2/9] fix(site/src/pages/AgentsPage): keep stop action disabled while interrupting The interrupt POST resolves as soon as the request lands, but the worker may finalize the interruption for several seconds, and the backend rejects a second interrupt with 409. Gate the composer's stop action on the interrupting status in addition to the mutation's pending flag so neither the Stop button nor Escape re-fires an interrupt that the close state machine (I0/I1) would reject. Also trims the interrupting story comment to the non-obvious invariant only. --- .../components/ChatPageContent.stories.tsx | 18 ++++++------------ .../AgentsPage/components/ChatPageContent.tsx | 4 +++- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx index 78b1112302e..c20c96a27a5 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx @@ -231,11 +231,8 @@ export const MergedMessagesRenderInIDOrder: Story = { }, }; -// A chat finalizing an interruption has no stream state but is still -// busy, so the composer must match the running case: Stop button -// shown, Send button hidden. Covers the I1 state (interrupting with -// a queued message), which previously rendered as idle because the -// composer treated only "running" as streaming. +// Interrupting is busy without stream state; interrupt retries are +// rejected by the backend, so Stop stays present but disabled. export const InterruptingShowsBusyComposer: Story = { render: () => { const store = buildInterruptingStore(); @@ -243,18 +240,15 @@ export const InterruptingShowsBusyComposer: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // The queued message confirms the chat is mid-interruption - // (backend state I1), not idle. expect(canvas.getByText("Also rename the helpers")).toBeInTheDocument(); - expect(canvas.getByRole("button", { name: "Stop" })).toBeInTheDocument(); + expect(canvas.getByRole("button", { name: "Stop" })).toBeDisabled(); expect(canvas.queryByRole("button", { name: "Send" })).toBeNull(); }, }; // Control for InterruptingShowsBusyComposer: a running chat with an -// identical history and queue renders the same busy composer, so the -// rendering above follows the active-chat statuses rather than any -// interrupting-specific path. +// identical history and queue renders the same busy composer, but +// with Stop enabled since an interrupt is still legal. export const RunningShowsBusyComposer: Story = { render: () => { const store = buildInterruptingStore(); @@ -264,7 +258,7 @@ export const RunningShowsBusyComposer: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); expect(canvas.getByText("Also rename the helpers")).toBeInTheDocument(); - expect(canvas.getByRole("button", { name: "Stop" })).toBeInTheDocument(); + expect(canvas.getByRole("button", { name: "Stop" })).toBeEnabled(); expect(canvas.queryByRole("button", { name: "Send" })).toBeNull(); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index 7b5c15a0196..9543b264d73 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -582,7 +582,9 @@ export const ChatPageInput: FC = ({ isLoading={isSendPending} isStreaming={isStreaming} onInterrupt={onInterrupt} - isInterruptPending={isInterruptPending} + // Once an interrupt lands, the backend rejects another with 409; + // hold Stop disabled for the whole interrupting status. + isInterruptPending={isInterruptPending || chatStatus === "interrupting"} contextUsage={latestContextUsage} onRefreshContext={handleRefreshContext} isRefreshingContext={refreshContextMutation.isPending} From 3308c4f082327b956a68e63ae78a7b0a243bc3b1 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 17 Aug 2026 12:13:01 +0000 Subject: [PATCH 3/9] style(site/src/pages/AgentsPage): drop comment narrating the control story The RunningShowsBusyComposer header comment restated what the story code and assertions already say; FE4 prohibits that narration. --- .../pages/AgentsPage/components/ChatPageContent.stories.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx index c20c96a27a5..672cce43923 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx @@ -246,9 +246,6 @@ export const InterruptingShowsBusyComposer: Story = { }, }; -// Control for InterruptingShowsBusyComposer: a running chat with an -// identical history and queue renders the same busy composer, but -// with Stop enabled since an interrupt is still legal. export const RunningShowsBusyComposer: Story = { render: () => { const store = buildInterruptingStore(); From cde235bdc7fbe1e271c93379ec558f76ef556f9f Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 17 Aug 2026 12:43:08 +0000 Subject: [PATCH 4/9] test(site/src/pages/AgentsPage): exercise escape shortcut and reuse queued-message fixture The interrupting story now focuses the composer and sends Escape, asserting the interrupt callback is not invoked while finalization is still pending. The queued message is built from MockChatQueuedMessage instead of an inline literal so fixture drift type-checks. isActiveChatStatus is intentionally not reused for the composer's busy check: it means active for store synchronization, a broader contract than the composer needs, and conflating the two could show a Stop button for a future busy status where interrupting is not legal. --- .../components/ChatPageContent.stories.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx index 672cce43923..db26caba7e1 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx @@ -1,8 +1,9 @@ import { MessageScroller } from "@shadcn/react/message-scroller"; import type { Meta, StoryObj } from "@storybook/react-vite"; import type { FC } from "react"; -import { expect, fn, within } from "storybook/test"; +import { expect, fn, userEvent, within } from "storybook/test"; import type * as TypesGen from "#/api/typesGenerated"; +import { MockChatQueuedMessage } from "#/testHelpers/chatEntities"; import { ChatWorkspaceContext } from "../context/ChatWorkspaceContext"; import { createChatStore } from "./ChatConversation/chatStore"; import { FIXTURE_NOW } from "./ChatConversation/storyFixtures"; @@ -39,7 +40,8 @@ const CHAT_ID = "chat-page-content-stories"; // attachment queries stay disabled. const StoryChatPageInput: FC<{ store: ReturnType; -}> = ({ store }) => ( + onInterrupt?: () => void; +}> = ({ store, onInterrupt }) => (
{ ]); store.setQueuedMessages([ { + ...MockChatQueuedMessage, id: 2, chat_id: CHAT_ID, content: [{ type: "text", text: "Also rename the helpers" }], @@ -233,16 +236,23 @@ export const MergedMessagesRenderInIDOrder: Story = { // Interrupting is busy without stream state; interrupt retries are // rejected by the backend, so Stop stays present but disabled. +const interruptingOnInterrupt = fn(); export const InterruptingShowsBusyComposer: Story = { render: () => { const store = buildInterruptingStore(); - return ; + return ( + + ); }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); expect(canvas.getByText("Also rename the helpers")).toBeInTheDocument(); expect(canvas.getByRole("button", { name: "Stop" })).toBeDisabled(); expect(canvas.queryByRole("button", { name: "Send" })).toBeNull(); + + await userEvent.click(canvas.getByTestId("chat-message-input")); + await userEvent.keyboard("{Escape}"); + expect(interruptingOnInterrupt).not.toHaveBeenCalled(); }, }; From d6fbee2e6bd72544fa650550f0436c2a65ac0989 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 17 Aug 2026 13:10:08 +0000 Subject: [PATCH 5/9] test(site/src/pages/AgentsPage): locate composer editor by accessible role Prefer getByRole("textbox", { name: "Chat message" }) over the test ID when focusing the editor in the interrupting story, so a regression that drops the accessible name fails instead of passing on a stale test ID. --- .../pages/AgentsPage/components/ChatPageContent.stories.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx index db26caba7e1..7e6b80f5d44 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx @@ -250,7 +250,9 @@ export const InterruptingShowsBusyComposer: Story = { expect(canvas.getByRole("button", { name: "Stop" })).toBeDisabled(); expect(canvas.queryByRole("button", { name: "Send" })).toBeNull(); - await userEvent.click(canvas.getByTestId("chat-message-input")); + await userEvent.click( + canvas.getByRole("textbox", { name: "Chat message" }), + ); await userEvent.keyboard("{Escape}"); expect(interruptingOnInterrupt).not.toHaveBeenCalled(); }, From 93eda335d354632dd3655de8750fbd193646a796 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 17 Aug 2026 13:37:52 +0000 Subject: [PATCH 6/9] feat(site/src/pages/AgentsPage): announce interrupt finalization to keyboard users While an interrupt is finalizing, the composer's Stop button is disabled and skipped in tab order, leaving keyboard and screen-reader users with no indication that the interruption is still in progress. Wrap the button in a tooltip that reads "Interrupting..." while pending, and add an sr-only live region with role=status announcing the same. The interrupting story now asserts the live region text. --- .../AgentsPage/components/AgentChatInput.tsx | 35 +++++++++++++------ .../components/ChatPageContent.stories.tsx | 3 ++ 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 7fddd7c95ad..e8c7f2ce5c5 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -1644,16 +1644,31 @@ export const AgentChatInput: FC = ({ /> )} {isStreaming && onInterrupt && ( - + + + + + + {isInterruptPending ? "Interrupting…" : "Stop"} + + + )} + {isInterruptPending && isStreaming && ( + // The disabled Stop button is skipped by Tab order, so the + // pending interruption is also announced through a live + // region and a tooltip. + + Interrupting. Waiting for the agent to stop. + )} {!(isStreaming && editingQueuedMessageID === null) && ( diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx index 7e6b80f5d44..a86a20e910d 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx @@ -248,6 +248,9 @@ export const InterruptingShowsBusyComposer: Story = { const canvas = within(canvasElement); expect(canvas.getByText("Also rename the helpers")).toBeInTheDocument(); expect(canvas.getByRole("button", { name: "Stop" })).toBeDisabled(); + expect(canvas.getByRole("status")).toHaveTextContent( + "Interrupting. Waiting for the agent to stop.", + ); expect(canvas.queryByRole("button", { name: "Send" })).toBeNull(); await userEvent.click( From e72c31927fa604b8d5e82cd3b09ffcac91a75539 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 18 Aug 2026 09:57:15 +0000 Subject: [PATCH 7/9] feat(site/src/pages/AgentsPage): show Interrupting in the transcript during finalization While an interrupt finalizes, the interrupted turn's flushed stream state made the transcript render "Thinking" as if the agent were still producing output. deriveLiveStatus now takes chatStatus and the new interrupting phase outranks stream leftovers, so the activity slot reads "Interrupting" with a pause icon (matching the sidebar's interrupting status icon) instead. The interrupting story asserts the transcript label and the absence of Thinking. --- .../ChatConversation/AssistantOutput.tsx | 20 ++++++++++++----- .../ChatConversation/liveStatusModel.test.ts | 17 ++++++++++++++ .../ChatConversation/liveStatusModel.ts | 11 ++++++++++ .../ChatConversation/storyFixtures.ts | 1 + .../streamingActivity.test.ts | 2 ++ .../components/ChatPageContent.stories.tsx | 22 ++++++++++++++++++- .../AgentsPage/components/ChatPageContent.tsx | 1 + 7 files changed, 68 insertions(+), 6 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AssistantOutput.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AssistantOutput.tsx index c7eb2e8f515..506c13cac92 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/AssistantOutput.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AssistantOutput.tsx @@ -1,3 +1,4 @@ +import { PauseIcon } from "lucide-react"; import type { FC } from "react"; import { Shimmer } from "../ChatElements"; import { ToolIcon } from "../ChatElements/tools/ToolIcon"; @@ -6,14 +7,20 @@ import type { LiveStatusModel } from "./liveStatusModel"; import { BlockList, type BlockListProps } from "./MessageBlocks"; import { shouldShowGenericThinking } from "./streamingActivity"; -const LiveActivitySlot: FC = () => ( +const LiveActivitySlot: FC<{ interrupting?: boolean }> = ({ + interrupting = false, +}) => (
- + {interrupting ? ( + + ) : ( + + )} - Thinking + {interrupting ? "Interrupting" : "Thinking"}
); @@ -43,8 +50,11 @@ export const AssistantOutput: FC = ({ {callout && } {liveStatus && - shouldShowGenericThinking({ liveStatus, blocks, tools }) && ( - + (liveStatus.phase === "interrupting" || + shouldShowGenericThinking({ liveStatus, blocks, tools })) && ( + )}
); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.test.ts index f1a19d1f997..0ad9e3b654d 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.test.ts @@ -35,6 +35,7 @@ const derive = ( streamError: null, persistedError: null, isAwaitingFirstStreamChunk: false, + chatStatus: null, ...overrides, }); @@ -91,10 +92,26 @@ describe("deriveLiveStatus", () => { { streamState: buildStreamState() }, { phase: "streaming", hasAccumulatedOutput: false }, ], + [ + "interrupting", + { chatStatus: "interrupting" as const }, + { phase: "interrupting", hasAccumulatedOutput: false }, + ], ])("returns %s", (_phase, overrides, expected) => { expect(derive(overrides)).toEqual(expected); }); + it("treats interrupting as outranking stream leftovers", () => { + expect( + derive({ + chatStatus: "interrupting", + streamState: buildStreamState({ + blocks: [{ type: "response", text: "Partial response" }], + }), + }), + ).toEqual({ phase: "interrupting", hasAccumulatedOutput: true }); + }); + it("uses the persisted error as the idle fallback", () => { expect(derive({ persistedError: buildStreamError() })).toEqual( failedStatus, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.ts b/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.ts index 6ba37b294c8..d9acdd1af82 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.ts @@ -14,6 +14,7 @@ export type LiveStatusModel = | ({ phase: "idle" } & LiveStatusBase) | ({ phase: "starting" } & LiveStatusBase) | ({ phase: "streaming" } & LiveStatusBase) + | ({ phase: "interrupting" } & LiveStatusBase) | ({ phase: "retrying"; title: string; @@ -46,6 +47,7 @@ export const shouldRenderLiveAssistant = ( ): boolean => liveStatus.phase === "streaming" || liveStatus.phase === "starting" || + liveStatus.phase === "interrupting" || liveStatus.phase === "retrying" || liveStatus.phase === "reconnecting" || liveStatus.hasAccumulatedOutput; @@ -57,6 +59,7 @@ export type DeriveLiveStatusParams = { streamError: ChatDetailError | null; persistedError: ChatDetailError | null; isAwaitingFirstStreamChunk: boolean; + chatStatus: TypesGen.ChatStatus | null; }; const getHasAccumulatedOutput = (streamState: StreamState | null): boolean => @@ -108,6 +111,7 @@ export const deriveLiveStatus = ({ streamError, persistedError, isAwaitingFirstStreamChunk, + chatStatus, }: DeriveLiveStatusParams): LiveStatusModel => { const hasAccumulatedOutput = getHasAccumulatedOutput(streamState); @@ -123,6 +127,13 @@ export const deriveLiveStatus = ({ return toReconnectingLiveStatus(reconnectState, { hasAccumulatedOutput }); } + // The interrupt outranks stream leftovers: while the worker drains and + // finalizes an interruption, the transcript must not claim the agent is + // still producing output. + if (chatStatus === "interrupting") { + return { phase: "interrupting", hasAccumulatedOutput }; + } + if (isAwaitingFirstStreamChunk) { return { phase: "starting", hasAccumulatedOutput }; } diff --git a/site/src/pages/AgentsPage/components/ChatConversation/storyFixtures.ts b/site/src/pages/AgentsPage/components/ChatConversation/storyFixtures.ts index dd95be0bf55..a6048674392 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/storyFixtures.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/storyFixtures.ts @@ -25,6 +25,7 @@ const DEFAULT_LIVE_STATUS_PARAMS: DeriveLiveStatusParams = { streamError: null, persistedError: null, isAwaitingFirstStreamChunk: false, + chatStatus: null, }; export const buildLiveStatus = ( diff --git a/site/src/pages/AgentsPage/components/ChatConversation/streamingActivity.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/streamingActivity.test.ts index 5367e63d4da..610445b1630 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/streamingActivity.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/streamingActivity.test.ts @@ -38,6 +38,8 @@ const liveStatus = (phase: LiveStatusModel["phase"]): LiveStatusModel => { title: "Failed", message: "Failed", }; + case "interrupting": + return { phase: "interrupting", hasAccumulatedOutput: false }; } }; diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx index a86a20e910d..89535c19a7b 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx @@ -241,7 +241,23 @@ export const InterruptingShowsBusyComposer: Story = { render: () => { const store = buildInterruptingStore(); return ( - + +
+ {}} + /> + +
+
); }, play: async ({ canvasElement }) => { @@ -252,6 +268,10 @@ export const InterruptingShowsBusyComposer: Story = { "Interrupting. Waiting for the agent to stop.", ); expect(canvas.queryByRole("button", { name: "Send" })).toBeNull(); + // The transcript must not claim the agent is still thinking while + // the interruption finalizes. + expect(canvas.getByText("Interrupting")).toBeInTheDocument(); + expect(canvas.queryByText("Thinking")).toBeNull(); await userEvent.click( canvas.getByRole("textbox", { name: "Chat message" }), diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index 9543b264d73..c48953a386b 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -149,6 +149,7 @@ export const ChatPageTimeline: FC = ({ streamError, persistedError: persistedError ?? null, isAwaitingFirstStreamChunk, + chatStatus, }); const streamTools = buildStreamTools( streamState?.toolCalls, From fcde0ddeb29e078c7f987222c88ef0d3615b7406 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 18 Aug 2026 10:32:44 +0000 Subject: [PATCH 8/9] style(site/src/pages/AgentsPage): drop narrating comment and widen it.each typing Removes a comment that restated the story assertions, and replaces the as-const cast in the deriveLiveStatus case table with a typed cases array plus satisfies annotations on the expected status fixtures. --- .../ChatConversation/liveStatusModel.test.ts | 19 ++++++++++++------- .../components/ChatPageContent.stories.tsx | 2 -- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.test.ts index 0ad9e3b654d..535bb900844 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.test.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ChatDetailError } from "./chatError"; -import { deriveLiveStatus } from "./liveStatusModel"; +import { deriveLiveStatus, type LiveStatusModel } from "./liveStatusModel"; import { buildReconnectState, buildRetryState } from "./storyFixtures"; import type { StreamState } from "./types"; @@ -49,7 +49,7 @@ describe("deriveLiveStatus", () => { attempt: 2, provider: "anthropic", retryingAt: "2026-03-10T00:00:02.000Z", - }; + } satisfies LiveStatusModel; const reconnectingStatus = { phase: "reconnecting", hasAccumulatedOutput: false, @@ -58,7 +58,7 @@ describe("deriveLiveStatus", () => { attempt: 1, delayMs: 1000, retryingAt: "2026-03-10T00:00:01.000Z", - }; + } satisfies LiveStatusModel; const failedStatus = { phase: "failed", hasAccumulatedOutput: false, @@ -67,9 +67,13 @@ describe("deriveLiveStatus", () => { message: "Chat processing failed.", provider: "anthropic", statusCode: 500, - }; + } satisfies LiveStatusModel; - it.each([ + const cases: [ + string, + Partial[0]> | undefined, + LiveStatusModel, + ][] = [ ["idle", undefined, { phase: "idle", hasAccumulatedOutput: false }], [ "starting", @@ -94,10 +98,11 @@ describe("deriveLiveStatus", () => { ], [ "interrupting", - { chatStatus: "interrupting" as const }, + { chatStatus: "interrupting" }, { phase: "interrupting", hasAccumulatedOutput: false }, ], - ])("returns %s", (_phase, overrides, expected) => { + ]; + it.each(cases)("returns %s", (_phase, overrides, expected) => { expect(derive(overrides)).toEqual(expected); }); diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx index 89535c19a7b..46c0850d33e 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx @@ -268,8 +268,6 @@ export const InterruptingShowsBusyComposer: Story = { "Interrupting. Waiting for the agent to stop.", ); expect(canvas.queryByRole("button", { name: "Send" })).toBeNull(); - // The transcript must not claim the agent is still thinking while - // the interruption finalizes. expect(canvas.getByText("Interrupting")).toBeInTheDocument(); expect(canvas.queryByText("Thinking")).toBeNull(); From 600a76b50523c5c24e78f9f874b360c0759b74f7 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 18 Aug 2026 11:49:45 +0100 Subject: [PATCH 9/9] Apply suggestion from @DanielleMaywood --- site/src/pages/AgentsPage/components/ChatPageContent.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index c48953a386b..8038cec91b1 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -583,8 +583,6 @@ export const ChatPageInput: FC = ({ isLoading={isSendPending} isStreaming={isStreaming} onInterrupt={onInterrupt} - // Once an interrupt lands, the backend rejects another with 409; - // hold Stop disabled for the whole interrupting status. isInterruptPending={isInterruptPending || chatStatus === "interrupting"} contextUsage={latestContextUsage} onRefreshContext={handleRefreshContext}