Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 25 additions & 10 deletions site/src/pages/AgentsPage/components/AgentChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Comment thread
DanielleMaywood marked this conversation as resolved.
>
<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>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { PauseIcon } from "lucide-react";
import type { FC } from "react";
import { Shimmer } from "../ChatElements";
import { ToolIcon } from "../ChatElements/tools/ToolIcon";
Expand All @@ -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>
);
Expand Down Expand Up @@ -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>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -35,6 +35,7 @@ const derive = (
streamError: null,
persistedError: null,
isAwaitingFirstStreamChunk: false,
chatStatus: null,
...overrides,
});

Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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",
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type LiveStatusModel =
| ({ phase: "idle" } & LiveStatusBase)
| ({ phase: "starting" } & LiveStatusBase)
| ({ phase: "streaming" } & LiveStatusBase)
| ({ phase: "interrupting" } & LiveStatusBase)
| ({
phase: "retrying";
title: string;
Expand Down Expand Up @@ -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;
Expand All @@ -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 =>
Expand Down Expand Up @@ -108,6 +111,7 @@ export const deriveLiveStatus = ({
streamError,
persistedError,
isAwaitingFirstStreamChunk,
chatStatus,
}: DeriveLiveStatusParams): LiveStatusModel => {
const hasAccumulatedOutput = getHasAccumulatedOutput(streamState);

Expand All @@ -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 };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const DEFAULT_LIVE_STATUS_PARAMS: DeriveLiveStatusParams = {
streamError: null,
persistedError: null,
isAwaitingFirstStreamChunk: false,
chatStatus: null,
};

export const buildLiveStatus = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ const liveStatus = (phase: LiveStatusModel["phase"]): LiveStatusModel => {
title: "Failed",
message: "Failed",
};
case "interrupting":
return { phase: "interrupting", hasAccumulatedOutput: false };
}
};

Expand Down
131 changes: 129 additions & 2 deletions site/src/pages/AgentsPage/components/ChatPageContent.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<{
Expand Down Expand Up @@ -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,
Expand All @@ -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(),
Comment thread
DanielleMaywood marked this conversation as resolved.
},
]);
store.setChatStatus("interrupting");
return store;
};

const buildThinkingSpacerStore = () => {
const store = createChatStore();

Expand Down Expand Up @@ -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();
Comment thread
DanielleMaywood marked this conversation as resolved.
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();
},
};
Loading
Loading