fix(site/src/pages/AgentsPage): treat interrupting chats as busy in the composer (#28209)

This commit is contained in:
Danielle Maywood
2026-08-18 12:51:29 +01:00
committed by GitHub
parent 522ef09517
commit 14e3ae33cf
8 changed files with 215 additions and 25 deletions
@@ -1644,16 +1644,31 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
/>
)}
{isStreaming && onInterrupt && (
<Button
size="icon"
variant="default"
className="size-7 rounded-full transition-colors [&>svg]:!size-3 [&>svg]:p-0"
onClick={onInterrupt}
disabled={isInterruptPending}
>
<SquareIcon className="fill-current" />
<span className="sr-only">Stop</span>
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="default"
className="size-7 rounded-full transition-colors [&>svg]:!size-3 [&>svg]:p-0"
onClick={onInterrupt}
disabled={isInterruptPending}
>
<SquareIcon className="fill-current" />
<span className="sr-only">Stop</span>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{isInterruptPending ? "Interrupting…" : "Stop"}
</TooltipContent>
</Tooltip>
)}
{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.
<span role="status" className="sr-only">
Interrupting. Waiting for the agent to stop.
</span>
)}
{!(isStreaming && editingQueuedMessageID === null) && (
<Tooltip>
@@ -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,
}) => (
<div
data-testid="live-activity-slot"
className="flex h-6 items-center gap-2 text-content-secondary"
>
<ToolIcon name="thinking" />
{interrupting ? (
<PauseIcon className="size-4 shrink-0 stroke-[1.5]" />
) : (
<ToolIcon name="thinking" />
)}
<Shimmer as="span" className="text-[13px] leading-6">
Thinking
{interrupting ? "Interrupting" : "Thinking"}
</Shimmer>
</div>
);
@@ -43,8 +50,11 @@ export const AssistantOutput: FC<AssistantOutputProps> = ({
<BlockList {...blockProps} />
{callout && <ChatStatusCallout status={callout} />}
{liveStatus &&
shouldShowGenericThinking({ liveStatus, blocks, tools }) && (
<LiveActivitySlot />
(liveStatus.phase === "interrupting" ||
shouldShowGenericThinking({ liveStatus, blocks, tools })) && (
<LiveActivitySlot
interrupting={liveStatus.phase === "interrupting"}
/>
)}
</div>
);
@@ -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<Parameters<typeof deriveLiveStatus>[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,
@@ -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 };
}
@@ -25,6 +25,7 @@ const DEFAULT_LIVE_STATUS_PARAMS: DeriveLiveStatusParams = {
streamError: null,
persistedError: null,
isAwaitingFirstStreamChunk: false,
chatStatus: null,
};
export const buildLiveStatus = (
@@ -38,6 +38,8 @@ const liveStatus = (phase: LiveStatusModel["phase"]): LiveStatusModel => {
title: "Failed",
message: "Failed",
};
case "interrupting":
return { phase: "interrupting", hasAccumulatedOutput: false };
}
};
@@ -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<typeof meta>;
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<typeof createChatStore>;
onInterrupt?: () => void;
}> = ({ store, onInterrupt }) => (
<div className="mx-auto w-full max-w-3xl p-4">
<ChatPageInput
organizationId={undefined}
store={store}
compressionThreshold={undefined}
onSend={fn()}
sendShortcut="enter"
onDeleteQueuedMessage={fn()}
onPromoteQueuedMessage={fn()}
onInterrupt={onInterrupt ?? fn()}
isInputDisabled={false}
isSendPending={false}
isInterruptPending={false}
hasModelOptions
selectedModel="model-config-1"
onModelChange={fn()}
modelOptions={[
{
id: "model-config-1",
provider: "openai",
model: "gpt-4o",
displayName: "GPT-4o",
},
]}
modelSelectorPlaceholder="Select model"
canConfigureAgentSetup={false}
isEditing={false}
editingQueuedMessageID={null}
onStartQueueEdit={fn()}
onCancelQueueEdit={fn()}
isEditingHistoryMessage={false}
onCancelHistoryEdit={fn()}
workspaceOptions={[]}
selectedWorkspaceId={null}
isWorkspaceLoading={false}
/>
</div>
);
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 (
<MessageScroller.Provider autoScroll defaultScrollPosition="end">
<div className="flex h-full flex-col">
<ChatPageTimeline
store={store}
persistedError={undefined}
hasMoreMessages={false}
isFetchingMoreMessages={false}
isHydratingMessages={false}
hasFetchMoreError={false}
onFetchMoreMessages={async () => {}}
/>
<StoryChatPageInput
store={store}
onInterrupt={interruptingOnInterrupt}
/>
</div>
</MessageScroller.Provider>
);
},
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 <StoryChatPageInput store={store} />;
},
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();
},
};
@@ -149,6 +149,7 @@ export const ChatPageTimeline: FC<ChatPageTimelineProps> = ({
streamError,
persistedError: persistedError ?? null,
isAwaitingFirstStreamChunk,
chatStatus,
});
const streamTools = buildStreamTools(
streamState?.toolCalls,
@@ -505,7 +506,8 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
wasEditingRef.current = isEditing;
}, [isEditing, resetEditAttachments]);
const isStreaming = hasStreamState || chatStatus === "running";
const isStreaming =
hasStreamState || chatStatus === "running" || chatStatus === "interrupting";
const inputElement = (
<AgentChatInput
@@ -581,7 +583,7 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
isLoading={isSendPending}
isStreaming={isStreaming}
onInterrupt={onInterrupt}
isInterruptPending={isInterruptPending}
isInterruptPending={isInterruptPending || chatStatus === "interrupting"}
contextUsage={latestContextUsage}
onRefreshContext={handleRefreshContext}
isRefreshingContext={refreshContextMutation.isPending}