From 14e3ae33cf10f671c561c80e368d12c15dba291c Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 18 Aug 2026 12:51:29 +0100 Subject: [PATCH] fix(site/src/pages/AgentsPage): treat interrupting chats as busy in the composer (#28209) --- .../AgentsPage/components/AgentChatInput.tsx | 35 +++-- .../ChatConversation/AssistantOutput.tsx | 20 ++- .../ChatConversation/liveStatusModel.test.ts | 34 ++++- .../ChatConversation/liveStatusModel.ts | 11 ++ .../ChatConversation/storyFixtures.ts | 1 + .../streamingActivity.test.ts | 2 + .../components/ChatPageContent.stories.tsx | 131 +++++++++++++++++- .../AgentsPage/components/ChatPageContent.tsx | 6 +- 8 files changed, 215 insertions(+), 25 deletions(-) diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 7fddd7c95a..e8c7f2ce5c 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/ChatConversation/AssistantOutput.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AssistantOutput.tsx index c7eb2e8f51..506c13cac9 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 f1a19d1f99..535bb90084 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"; @@ -35,6 +35,7 @@ const derive = ( streamError: null, persistedError: null, isAwaitingFirstStreamChunk: false, + chatStatus: null, ...overrides, }); @@ -48,7 +49,7 @@ describe("deriveLiveStatus", () => { attempt: 2, provider: "anthropic", retryingAt: "2026-03-10T00:00:02.000Z", - }; + } satisfies LiveStatusModel; const reconnectingStatus = { phase: "reconnecting", hasAccumulatedOutput: false, @@ -57,7 +58,7 @@ describe("deriveLiveStatus", () => { attempt: 1, delayMs: 1000, retryingAt: "2026-03-10T00:00:01.000Z", - }; + } satisfies LiveStatusModel; const failedStatus = { phase: "failed", hasAccumulatedOutput: false, @@ -66,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", @@ -91,10 +96,27 @@ describe("deriveLiveStatus", () => { { streamState: buildStreamState() }, { phase: "streaming", hasAccumulatedOutput: false }, ], - ])("returns %s", (_phase, overrides, expected) => { + [ + "interrupting", + { chatStatus: "interrupting" }, + { phase: "interrupting", hasAccumulatedOutput: false }, + ], + ]; + it.each(cases)("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 6ba37b294c..d9acdd1af8 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 dd95be0bf5..a604867439 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 5367e63d4d..610445b163 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 83a6682380..46c0850d33 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx @@ -1,12 +1,13 @@ 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, 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"; -import { ChatPageTimeline } from "./ChatPageContent"; +import { ChatPageInput, ChatPageTimeline } from "./ChatPageContent"; // These stories cover transcript rendering, so history paging stays idle. const StoryChatPageTimeline: FC<{ @@ -34,6 +35,52 @@ 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; + onInterrupt?: () => void; +}> = ({ store, onInterrupt }) => ( +
+ +
+); + const buildMessage = ( id: number, role: TypesGen.ChatMessageRole, @@ -46,6 +93,27 @@ 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([ + { + ...MockChatQueuedMessage, + 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 +233,62 @@ 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 ( + +
+ {}} + /> + +
+
+ ); + }, + play: async ({ canvasElement }) => { + 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(); + expect(canvas.getByText("Interrupting")).toBeInTheDocument(); + expect(canvas.queryByText("Thinking")).toBeNull(); + + await userEvent.click( + canvas.getByRole("textbox", { name: "Chat message" }), + ); + await userEvent.keyboard("{Escape}"); + expect(interruptingOnInterrupt).not.toHaveBeenCalled(); + }, +}; + +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" })).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 1a9172d0e6..8038cec91b 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, @@ -505,7 +506,8 @@ export const ChatPageInput: FC = ({ wasEditingRef.current = isEditing; }, [isEditing, resetEditAttachments]); - const isStreaming = hasStreamState || chatStatus === "running"; + const isStreaming = + hasStreamState || chatStatus === "running" || chatStatus === "interrupting"; const inputElement = ( = ({ isLoading={isSendPending} isStreaming={isStreaming} onInterrupt={onInterrupt} - isInterruptPending={isInterruptPending} + isInterruptPending={isInterruptPending || chatStatus === "interrupting"} contextUsage={latestContextUsage} onRefreshContext={handleRefreshContext} isRefreshingContext={refreshContextMutation.isPending}