mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
fix(site/src/pages/AgentsPage): treat interrupting chats as busy in the composer (#28209)
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user