;
onEditUserMessage?: (
messageId: number,
text: string,
@@ -584,8 +147,13 @@ const ChatMessageItem = memo<{
onJumpToUserMessage?: (messageId: number) => void;
}>(
({
+ renderKey,
message,
parsed,
+ liveStatus,
+ liveBlocks = [],
+ liveTools = [],
+ subagentStatusOverrides,
onEditUserMessage,
editingMessageId,
isAfterEditingMessage = false,
@@ -610,21 +178,25 @@ const ChatMessageItem = memo<{
subagentVariants,
showDesktopPreviews,
}) => {
- const isUser = message.role === "user";
+ const isUser = message?.role === "user";
+ const messageId = message?.id;
const [previewImage, setPreviewImage] = useState
(null);
const [previewText, setPreviewText] =
useState(null);
- const displayState = deriveMessageDisplayState({
- message,
- parsed,
- hideActions,
- hasActiveStream,
- isAwaitingFirstStreamChunk,
- });
- if (displayState.shouldHide) {
+ const displayState =
+ message && parsed
+ ? deriveMessageDisplayState({
+ message,
+ parsed,
+ hideActions,
+ hasActiveStream,
+ isAwaitingFirstStreamChunk,
+ })
+ : undefined;
+ if (displayState?.shouldHide) {
return null;
}
- if (message.role === "system") {
+ if (message?.role === "system" && parsed) {
return (
0 ? (
parsed.hookNotices.map((notice, index) => (
{notice}
@@ -658,6 +230,9 @@ const ChatMessageItem = memo<{
return (
- {isUser ? (
+ {isUser && displayState && parsed ? (
- {/* Keep assistant content spacing consistent by letting the parent stack own every top-level gap. */}
-
-
-
+
)}
- {parsed.hookNotices.map((notice, index) => (
+ {parsed?.hookNotices.map((notice, index) => (
{notice}
))}
- {!hideActions &&
+ {displayState &&
+ !hideActions &&
(displayState.hasCopyableContent ||
(isUser && onEditUserMessage)) && (
- {displayState.hasCopyableContent && (
+ {displayState.hasCopyableContent && parsed && (
)}
- {isUser && onEditUserMessage && (
+ {isUser && messageId !== undefined && onEditUserMessage && (
)}
- {displayState.needsAssistantBottomSpacer && !isLastMessage && (
+ {displayState?.needsAssistantBottomSpacer && !isLastMessage && (
)}
{previewImage && (
@@ -866,6 +442,7 @@ const StickyUserMessage = memo<{
const [isReady, setIsReady] = useState(false);
const [isTooTall, setIsTooTall] = useState(false);
const sentinelRef = useRef
(null);
+ const messageKey = `message:${message.id}`;
const messageId = message.id;
const setSentinelRef = (el: HTMLDivElement | null) => {
sentinelRef.current = el;
@@ -1070,6 +647,7 @@ const StickyUserMessage = memo<{
(displayMessages.length).fill(false);
- let nextVisibleIsUser = true;
- for (let i = displayMessages.length - 1; i >= 0; i--) {
- const entry = displayMessages[i];
- if (entry.message.role === "system") {
- nextVisibleIsUser = true;
- continue;
- }
- if (entry.message.role !== "user") {
- flags[i] = nextVisibleIsUser;
- }
- nextVisibleIsUser = entry.message.role === "user";
- }
- return flags;
-}
-
interface ConversationTimelineProps {
parsedMessages: readonly ParsedMessageEntry[];
+ streamState?: StreamState | null;
+ streamTools?: readonly MergedTool[];
+ liveStatus?: LiveStatusModel;
+ subagentStatusOverrides?: Map;
subagentTitles: Map;
subagentVariants?: Map;
onEditUserMessage?: (
@@ -1204,6 +769,10 @@ interface ConversationTimelineProps {
export const ConversationTimeline = memo(
({
parsedMessages,
+ streamState,
+ streamTools = [],
+ liveStatus,
+ subagentStatusOverrides,
subagentTitles,
subagentVariants,
onEditUserMessage,
@@ -1233,9 +802,20 @@ export const ConversationTimeline = memo(
};
const displayMessages = buildDisplayMessages(parsedMessages);
- const lastInChainFlags = computeLastInChainFlags(displayMessages);
+ const renderRows = assignTimelineRows(
+ displayMessages,
+ Boolean(liveStatus && shouldRenderLiveAssistant(liveStatus)),
+ );
+
+ // A live turn only reveals its stream blocks once output has accumulated.
+ // Before that the callout and thinking indicator stand in for the turn.
+ const showsStreamOutput =
+ liveStatus !== undefined &&
+ (liveStatus.phase === "streaming" || liveStatus.hasAccumulatedOutput);
+ const liveBlocks = showsStreamOutput ? (streamState?.blocks ?? []) : [];
+ const liveTools = showsStreamOutput ? streamTools : [];
- if (parsedMessages.length === 0) {
+ if (renderRows.length === 0) {
return null;
}
@@ -1259,16 +839,10 @@ export const ConversationTimeline = memo(
// per-bubble prev/next arrow buttons that jump the transcript
// to the neighbouring user prompt.
const visibleUserMessageIds: number[] = [];
- for (const { message, parsed } of parsedMessages) {
- if (message.role !== "user") continue;
- const { shouldHide } = deriveMessageDisplayState({
- message,
- parsed,
- hideActions: false,
- hasActiveStream: false,
- isAwaitingFirstStreamChunk: false,
- });
- if (!shouldHide) visibleUserMessageIds.push(message.id);
+ for (const { message } of displayMessages) {
+ if (message.role === "user") {
+ visibleUserMessageIds.push(message.id);
+ }
}
const userNeighborsById = new Map<
number,
@@ -1325,41 +899,52 @@ export const ConversationTimeline = memo(
data-testid="conversation-timeline"
className="flex flex-col gap-2"
>
- {displayMessages.map(({ message, parsed }, msgIdx) => {
+ {renderRows.map((row) => {
+ if (row.type === "live") {
+ // This row only exists when liveStatus is set.
+ return (
+
+ );
+ }
+ const { message, parsed } = row.entry;
+ const neighbors = userNeighborsById.get(message.id);
+ const isAfterEditingMessage = afterEditingMessageIds.has(
+ message.id,
+ );
if (message.role === "user") {
- const { shouldHide } = deriveMessageDisplayState({
- message,
- parsed,
- hideActions: false,
- hasActiveStream: false,
- isAwaitingFirstStreamChunk: false,
- });
- if (shouldHide) {
- return null;
- }
return (
);
}
- // Hide actions on assistant messages that are not the
- // last in a consecutive assistant chain. Flags are
- // precomputed in a single reverse pass above.
- const isLastInChain = lastInChainFlags[msgIdx];
return (
(
hasUserResponseAfterAskQuestion
}
urlTransform={urlTransform}
- isAfterEditingMessage={afterEditingMessageIds.has(message.id)}
- hideActions={!isLastInChain}
+ isAfterEditingMessage={isAfterEditingMessage}
+ hideActions={!row.isLastInAssistantChain}
hasActiveStream={Boolean(hasActiveStream)}
isAwaitingFirstStreamChunk={Boolean(isAwaitingFirstStreamChunk)}
- isLastMessage={msgIdx === displayMessages.length - 1}
+ isLastMessage={row.isLastMessage}
mcpServers={mcpServers}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx
index 269b5d0e5e095..20e1adf1f9180 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx
+++ b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx
@@ -1,24 +1,11 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
-import { expect, waitFor, within } from "storybook/test";
+import { expect, within } from "storybook/test";
import { LiveStreamTailContent } from "./LiveStreamTail";
-import {
- buildLiveStatus,
- buildReconnectState,
- buildRetryState,
- buildStreamRenderState,
- pinFixtureClock,
- textResponseStreamParts,
-} from "./storyFixtures";
-
-const retryThenResumedStream = buildStreamRenderState(textResponseStreamParts);
+import { buildLiveStatus, pinFixtureClock } from "./storyFixtures";
const defaultArgs: React.ComponentProps = {
isTranscriptEmpty: true,
- streamState: null,
- streamTools: [],
liveStatus: buildLiveStatus(),
- subagentTitles: new Map(),
- subagentStatusOverrides: new Map(),
};
const meta: Meta = {
@@ -245,36 +232,6 @@ export const TerminalMissingKeyError: Story = {
},
};
-/** Retrying a transport timeout shows attempt + countdown. */
-export const RetryingTimeoutAnthropic: Story = {
- args: {
- ...defaultArgs,
- liveStatus: buildLiveStatus({
- retryState: buildRetryState({
- attempt: 2,
- kind: "timeout",
- error: "Anthropic is temporarily unavailable.",
- provider: "anthropic",
- }),
- }),
- },
- play: async ({ canvasElement }) => {
- const canvas = within(canvasElement);
- expect(
- canvas.getByRole("heading", { name: /request timed out/i }),
- ).toBeVisible();
- expect(
- canvas.getByText(/anthropic is temporarily unavailable/i),
- ).toBeVisible();
- expect(canvas.getByText(/attempt 2/i)).toBeVisible();
- // StatusCountdown renders label and seconds as separate text
- // nodes, so match against the element's combined textContent.
- await waitFor(() => {
- expect(canvasElement).toHaveTextContent(/retrying in \d+s/i);
- });
- },
-};
-
/** Terminal stream-silence timeouts get a specific heading without provider metadata. */
export const TerminalStreamSilenceTimeoutError: Story = {
args: {
@@ -396,82 +353,3 @@ export const GenericErrorShowsProviderDetail: Story = {
expect(canvas.getByText(/image exceeds 5 mb maximum/i)).toBeVisible();
},
};
-
-/** Reconnecting keeps already-streamed content visible without a terminal footer. */
-export const ReconnectingKeepsPartialOutputVisible: Story = {
- args: {
- ...defaultArgs,
- isTranscriptEmpty: false,
- streamState: retryThenResumedStream.streamState,
- streamTools: retryThenResumedStream.streamTools,
- liveStatus: buildLiveStatus({
- streamState: retryThenResumedStream.streamState,
- reconnectState: buildReconnectState({
- attempt: 2,
- delayMs: 2000,
- }),
- }),
- },
- play: async ({ canvasElement }) => {
- const canvas = within(canvasElement);
- expect(canvas.getByText(/storybook streamed answer/i)).toBeVisible();
- expect(
- canvas.getByRole("heading", { name: /reconnecting/i }),
- ).toBeVisible();
- expect(canvas.getByText(/chat stream disconnected/i)).toBeVisible();
- expect(
- canvas.queryByRole("heading", { name: /request failed/i }),
- ).not.toBeInTheDocument();
- },
-};
-
-/** Persisted errors yield to live streaming while the live tail is active. */
-export const PersistedGenericErrorDoesNotOverrideStreaming: Story = {
- args: {
- ...defaultArgs,
- isTranscriptEmpty: false,
- streamState: retryThenResumedStream.streamState,
- streamTools: retryThenResumedStream.streamTools,
- liveStatus: buildLiveStatus({
- streamState: retryThenResumedStream.streamState,
- persistedError: {
- kind: "generic",
- message: "Stale persisted error.",
- },
- }),
- },
- play: async ({ canvasElement }) => {
- const canvas = within(canvasElement);
- await waitFor(() => {
- expect(canvas.getByText(/storybook streamed answer/i)).toBeVisible();
- });
- expect(
- canvas.queryByRole("heading", { name: /request failed/i }),
- ).not.toBeInTheDocument();
- },
-};
-
-/** Terminal failures keep partial output visible above the footer callout. */
-export const FailedStreamKeepsPartialOutputVisible: Story = {
- args: {
- ...defaultArgs,
- isTranscriptEmpty: false,
- streamState: retryThenResumedStream.streamState,
- streamTools: retryThenResumedStream.streamTools,
- liveStatus: buildLiveStatus({
- streamState: retryThenResumedStream.streamState,
- streamError: {
- kind: "generic",
- message: "Provider request failed.",
- },
- }),
- },
- play: async ({ canvasElement }) => {
- const canvas = within(canvasElement);
- expect(canvas.getByText(/storybook streamed answer/i)).toBeVisible();
- expect(
- canvas.getByRole("heading", { name: /request failed/i }),
- ).toBeVisible();
- expect(canvas.getByText(/provider request failed/i)).toBeVisible();
- },
-};
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx
index ac396eefdaa48..1bcc0ab732597 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx
+++ b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx
@@ -1,65 +1,22 @@
-import type { UrlTransform } from "streamdown";
-import type * as TypesGen from "#/api/typesGenerated";
-import type { SubagentVariant } from "../ChatElements/tools/subagentDescriptor";
import { ChatStatusCallout } from "./ChatStatusCallout";
-import type { ChatDetailError } from "./chatError";
-import {
- selectIsAwaitingFirstStreamChunk,
- selectReconnectState,
- selectRetryState,
- selectStreamError,
- selectStreamState,
- selectSubagentStatusOverrides,
- useChatSelector,
- type useChatStore,
-} from "./chatStore";
-import { deriveLiveStatus, type LiveStatusModel } from "./liveStatusModel";
-import { StreamingOutput } from "./StreamingOutput";
-import { buildStreamTools } from "./streamState";
-import type { MergedTool, StreamState } from "./types";
-
-const shouldRenderStreamingSection = (liveStatus: LiveStatusModel): boolean =>
- liveStatus.phase === "streaming" ||
- liveStatus.phase === "starting" ||
- liveStatus.phase === "retrying" ||
- liveStatus.phase === "reconnecting" ||
- liveStatus.hasAccumulatedOutput;
-
-type ChatStoreHandle = ReturnType["store"];
+import type { LiveStatusModel } from "./liveStatusModel";
interface LiveStreamTailContentProps {
isTranscriptEmpty: boolean;
- streamState: StreamState | null;
- streamTools: readonly MergedTool[];
liveStatus: LiveStatusModel;
- subagentTitles: Map;
- subagentVariants?: Map;
- subagentStatusOverrides: Map;
- urlTransform?: UrlTransform;
- mcpServers?: readonly TypesGen.MCPServerConfig[];
}
+// The live assistant turn renders as a timeline row, so the tail below the
+// transcript only carries the empty state and the terminal failure callout.
export const LiveStreamTailContent = ({
isTranscriptEmpty,
- streamState,
- streamTools,
liveStatus,
- subagentTitles,
- subagentVariants,
- subagentStatusOverrides,
- urlTransform,
- mcpServers,
}: LiveStreamTailContentProps) => {
- const shouldRenderStreamSection = shouldRenderStreamingSection(liveStatus);
const terminalStatus = liveStatus.phase === "failed" ? liveStatus : null;
const shouldRenderEmptyState =
isTranscriptEmpty && liveStatus.phase === "idle";
- if (
- !shouldRenderEmptyState &&
- !shouldRenderStreamSection &&
- !terminalStatus
- ) {
+ if (!shouldRenderEmptyState && !terminalStatus) {
return null;
}
@@ -76,78 +33,7 @@ export const LiveStreamTailContent = ({
Start a conversation with your agent.
)}
- {shouldRenderStreamSection && (
-
- )}
{terminalStatus && }
);
};
-
-interface LiveStreamTailProps {
- store: ChatStoreHandle;
- persistedError: ChatDetailError | undefined;
- isTranscriptEmpty: boolean;
- subagentTitles: Map;
- subagentVariants?: Map;
- urlTransform?: UrlTransform;
- mcpServers?: readonly TypesGen.MCPServerConfig[];
-}
-
-export const LiveStreamTail = ({
- store,
- persistedError,
- isTranscriptEmpty,
- subagentTitles,
- subagentVariants,
- urlTransform,
- mcpServers,
-}: LiveStreamTailProps) => {
- const streamState = useChatSelector(store, selectStreamState);
- const streamError = useChatSelector(store, selectStreamError);
- const retryState = useChatSelector(store, selectRetryState);
- const reconnectState = useChatSelector(store, selectReconnectState);
- const isAwaitingFirstStreamChunk = useChatSelector(
- store,
- selectIsAwaitingFirstStreamChunk,
- );
- const subagentStatusOverrides = useChatSelector(
- store,
- selectSubagentStatusOverrides,
- );
- const streamTools = buildStreamTools(
- streamState?.toolCalls,
- streamState?.toolResults,
- );
- const liveStatus = deriveLiveStatus({
- streamState,
- retryState,
- reconnectState,
- streamError,
- persistedError: persistedError ?? null,
- isAwaitingFirstStreamChunk,
- });
-
- return (
-
- );
-};
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/MessageBlocks.tsx b/site/src/pages/AgentsPage/components/ChatConversation/MessageBlocks.tsx
new file mode 100644
index 0000000000000..85ef00f2a967c
--- /dev/null
+++ b/site/src/pages/AgentsPage/components/ChatConversation/MessageBlocks.tsx
@@ -0,0 +1,449 @@
+import { type FC, memo, useLayoutEffect, useRef, useState } from "react";
+import { useQuery } from "react-query";
+import type { UrlTransform } from "streamdown";
+import { preferenceSettings } from "#/api/queries/users";
+import type * as TypesGen from "#/api/typesGenerated";
+import type { ThinkingDisplayMode } from "#/api/typesGenerated";
+import { cn } from "#/utils/cn";
+import { Response, Tool } from "../ChatElements";
+import { WebSearchSources } from "../ChatElements/tools";
+import { ReadFilesTool } from "../ChatElements/tools/ReadFilesTool";
+import {
+ getReadFileToolData,
+ ReadFileTool,
+} from "../ChatElements/tools/ReadFileTool";
+import type { SubagentVariant } from "../ChatElements/tools/subagentDescriptor";
+import { ToolCall } from "../ChatElements/tools/ToolCall";
+import {
+ AttachmentBlock,
+ type PreviewTextAttachment,
+} from "./AttachmentBlocks";
+import { groupSequentialReadFileBlocks } from "./blockUtils";
+import { useSmoothStreamingText } from "./SmoothText";
+import { getThinkingDisclosureDisplay } from "./thinkingTitle";
+import type { MergedTool, RenderBlock } from "./types";
+
+const ReasoningDisclosure = memo<{
+ id: string;
+ text: string;
+ isStreaming?: boolean;
+ urlTransform?: UrlTransform;
+ thinkingDisplayMode?: ThinkingDisplayMode;
+}>(
+ ({
+ id,
+ text,
+ isStreaming = false,
+ urlTransform,
+ thinkingDisplayMode: mode = "auto",
+ }) => {
+ const [manualToggle, setManualToggle] = useState(null);
+
+ // Reset manual override on streaming transitions so
+ // auto/preview modes collapse when streaming stops.
+ const [prevStreaming, setPrevStreaming] = useState(isStreaming);
+ if (prevStreaming !== isStreaming) {
+ setPrevStreaming(isStreaming);
+ if (mode === "auto" || mode === "preview") {
+ setManualToggle(null);
+ }
+ }
+
+ const autoExpanded = (() => {
+ switch (mode) {
+ case "always_expanded":
+ return true;
+ case "always_collapsed":
+ return false;
+ case "auto":
+ case "preview":
+ return isStreaming;
+ default: {
+ const _exhaustive: never = mode;
+ return _exhaustive;
+ }
+ }
+ })();
+
+ const expanded = manualToggle ?? autoExpanded;
+
+ const isPreviewConstrained =
+ mode === "preview" && isStreaming && manualToggle === null;
+
+ const previewScrollRef = useRef(null);
+
+ const { visibleText } = useSmoothStreamingText({
+ fullText: text,
+ isStreaming,
+ bypassSmoothing: !isStreaming,
+ streamKey: id,
+ });
+ const displayText = isStreaming ? visibleText : text;
+ const { title, body } = getThinkingDisclosureDisplay(displayText);
+ const hasText = body.trim().length > 0;
+
+ // Auto-scroll the preview container to the bottom as new
+ // thinking content streams in. useLayoutEffect avoids a
+ // visible frame where content has grown but not scrolled.
+ const displayTextLength = body.length;
+ useLayoutEffect(() => {
+ if (
+ displayTextLength &&
+ isPreviewConstrained &&
+ previewScrollRef.current
+ ) {
+ previewScrollRef.current.scrollTop =
+ previewScrollRef.current.scrollHeight;
+ }
+ }, [displayTextLength, isPreviewConstrained]);
+
+ return (
+
+
setManualToggle(open)}
+ >
+
+
+
+
+ {body}
+
+
+
+
+
+ );
+ },
+);
+
+// Runs the smooth-streaming jitter buffer while the turn is live and renders
+// the raw text once it is durable, so both shapes render through the same
+// code path.
+const ResponseBlock = memo<{
+ text: string;
+ isStreaming: boolean;
+ streamKey: string;
+ urlTransform?: UrlTransform;
+}>(({ text, isStreaming, streamKey, urlTransform }) => {
+ const { visibleText } = useSmoothStreamingText({
+ fullText: text,
+ isStreaming,
+ bypassSmoothing: !isStreaming,
+ streamKey,
+ });
+ return (
+
+ {isStreaming ? visibleText : text}
+
+ );
+});
+
+const ReadFileTimelineBlock = memo<{
+ tools: readonly [MergedTool, ...MergedTool[]];
+}>(({ tools }) => {
+ const [expanded, setExpanded] = useState(false);
+ const [firstTool] = tools;
+ if (tools.length === 1) {
+ const readFile = getReadFileToolData(firstTool);
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ );
+});
+
+export type BlockListProps = {
+ blocks: readonly RenderBlock[];
+ tools: readonly MergedTool[];
+ keyPrefix: string;
+ isStreaming?: boolean;
+ subagentTitles?: Map;
+ subagentVariants?: Map;
+ showDesktopPreviews?: boolean;
+ subagentStatusOverrides?: Map;
+ mcpServers?: readonly TypesGen.MCPServerConfig[];
+ onImageClick?: (src: string) => void;
+ onTextFileClick?: (attachment: PreviewTextAttachment) => void;
+ onImplementPlan?: () => Promise | void;
+ onSendAskUserQuestionResponse?: (message: string) => Promise | void;
+ isChatCompleted?: boolean;
+ latestAskUserQuestionToolId?: string;
+ askUserQuestionResponseTextByToolId?: ReadonlyMap;
+ hasUserResponseAfterAskQuestion?: boolean;
+ urlTransform?: UrlTransform;
+};
+
+// Shared block renderer for durable messages and the live assistant turn.
+// Encapsulates the response / thinking / tool / file / sources switch so both
+// consumers stay in sync. PascalCase so the React Compiler auto-memoizes every
+// element inside.
+export const BlockList: FC = ({
+ blocks,
+ tools,
+ keyPrefix,
+ isStreaming = false,
+ subagentTitles,
+ subagentVariants,
+ showDesktopPreviews,
+ subagentStatusOverrides,
+ mcpServers,
+ onImageClick,
+ onTextFileClick,
+ onImplementPlan,
+ onSendAskUserQuestionResponse,
+ isChatCompleted,
+ latestAskUserQuestionToolId,
+ askUserQuestionResponseTextByToolId,
+ hasUserResponseAfterAskQuestion = false,
+ urlTransform,
+}) => {
+ const prefQuery = useQuery(preferenceSettings());
+ const thinkingDisplayMode: ThinkingDisplayMode =
+ prefQuery.data?.thinking_display_mode || "auto";
+ const shellToolDisplayMode: TypesGen.AgentDisplayMode =
+ prefQuery.data?.shell_tool_display_mode || "always_collapsed";
+ const codeDiffDisplayMode: TypesGen.AgentDisplayMode =
+ prefQuery.data?.code_diff_display_mode || "auto";
+
+ const toolByID = new Map(tools.map((tool) => [tool.id, tool]));
+ const displayBlocks = groupSequentialReadFileBlocks(blocks, tools);
+
+ // Pre-compute which tool IDs have a corresponding block so
+ // we can render "remaining" (block-less) tools afterwards.
+ const blockToolIDs = new Set(
+ displayBlocks.flatMap((block) => {
+ if (block.type === "tool") {
+ return toolByID.has(block.id) || isStreaming ? [block.id] : [];
+ }
+ if (block.type === "tool-group") {
+ return block.ids;
+ }
+ return [];
+ }),
+ );
+
+ const remainingTools = tools.filter((tool) => !blockToolIDs.has(tool.id));
+
+ // A thinking block is actively streaming only when it is the
+ // very last block in the list. Once newer content arrives
+ // (response, tool call, etc.) the thinking phase is over.
+ const lastDisplayBlockIsThinking =
+ displayBlocks.length > 0 &&
+ displayBlocks[displayBlocks.length - 1].type === "thinking";
+
+ return (
+ <>
+ {displayBlocks.map((block, index) => {
+ switch (block.type) {
+ case "response":
+ return (
+
+ );
+ case "thinking":
+ return (
+
+ );
+ case "file-reference":
+ return (
+
+
+ {block.file_name}:
+ {block.start_line === block.end_line
+ ? block.start_line
+ : `${block.start_line}\u2013${block.end_line}`}
+
+
+ );
+ case "tool-group": {
+ const [firstGroupTool, ...restGroupTools] = block.ids
+ .map((id) => toolByID.get(id))
+ .filter((tool) => tool !== undefined);
+ if (!firstGroupTool) {
+ return null;
+ }
+ return (
+
+ );
+ }
+ case "tool": {
+ const tool = toolByID.get(block.id);
+ if (!tool) {
+ if (!isStreaming) {
+ return null;
+ }
+ // Streaming placeholder for not-yet-resolved tool.
+ return (
+
+ );
+ }
+ if (tool.name === "read_file") {
+ return ;
+ }
+ return (
+
+ );
+ }
+ case "file":
+ return (
+
+ );
+ case "sources":
+ return (
+
+ );
+ default: {
+ const _exhaustive: never = block;
+ return _exhaustive;
+ }
+ }
+ })}
+ {remainingTools.map((tool) => (
+
+ ))}
+ >
+ );
+};
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx b/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx
deleted file mode 100644
index ba894a3eb0d70..0000000000000
--- a/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import type { FC } from "react";
-import type { UrlTransform } from "streamdown";
-import type * as TypesGen from "#/api/typesGenerated";
-import {
- ConversationItem,
- Message,
- MessageContent,
- Shimmer,
-} from "../ChatElements";
-import type { SubagentVariant } from "../ChatElements/tools/subagentDescriptor";
-import { ToolIcon } from "../ChatElements/tools/ToolIcon";
-import { ChatStatusCallout } from "./ChatStatusCallout";
-import { BlockList } from "./ConversationTimeline";
-import type { LiveStatusModel } from "./liveStatusModel";
-import { shouldShowGenericThinking } from "./streamingActivity";
-import type { MergedTool, StreamState } from "./types";
-
-const hasCalloutLiveStatus = (liveStatus: LiveStatusModel): boolean =>
- liveStatus.phase === "retrying" || liveStatus.phase === "reconnecting";
-
-const LiveActivitySlot: FC = () => (
-
-
-
- Thinking
-
-
-);
-
-export const StreamingOutput: FC<{
- streamState: StreamState | null;
- streamTools: readonly MergedTool[];
- subagentTitles?: Map;
- subagentVariants?: Map;
- subagentStatusOverrides?: Map;
- liveStatus: LiveStatusModel;
- urlTransform?: UrlTransform;
- mcpServers?: readonly TypesGen.MCPServerConfig[];
-}> = ({
- streamState,
- streamTools,
- subagentTitles,
- subagentVariants,
- subagentStatusOverrides,
- liveStatus,
- urlTransform,
- mcpServers,
-}) => {
- if (liveStatus.phase === "idle") {
- return null;
- }
-
- const isStreaming = liveStatus.phase === "streaming";
- const shouldShowBlocks =
- liveStatus.phase === "streaming" || liveStatus.hasAccumulatedOutput;
- const blocks = shouldShowBlocks ? (streamState?.blocks ?? []) : [];
-
- const showActivity = shouldShowGenericThinking({
- liveStatus,
- streamState,
- streamTools,
- });
-
- const conversationItemProps = { role: "assistant" as const };
-
- return (
-
-
-
-
- {shouldShowBlocks && (
-
- )}
- {hasCalloutLiveStatus(liveStatus) && (
-
- )}
- {showActivity && }
-
-
-
-
- );
-};
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.ts b/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.ts
index 77f3ed6cab64d..6ba37b294c890 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.ts
+++ b/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.ts
@@ -41,6 +41,15 @@ export type LiveStatusModel =
statusCode?: number;
} & LiveStatusBase);
+export const shouldRenderLiveAssistant = (
+ liveStatus: LiveStatusModel,
+): boolean =>
+ liveStatus.phase === "streaming" ||
+ liveStatus.phase === "starting" ||
+ liveStatus.phase === "retrying" ||
+ liveStatus.phase === "reconnecting" ||
+ liveStatus.hasAccumulatedOutput;
+
export type DeriveLiveStatusParams = {
streamState: StreamState | null;
retryState: RetryState | null;
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/storyFixtures.ts b/site/src/pages/AgentsPage/components/ChatConversation/storyFixtures.ts
index e0695c8ffe07b..dd95be0bf55d0 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/storyFixtures.ts
+++ b/site/src/pages/AgentsPage/components/ChatConversation/storyFixtures.ts
@@ -12,7 +12,7 @@ import type {
StreamState,
} from "./types";
-type StoryStreamRenderState = {
+export type StoryStreamRenderState = {
streamState: StreamState | null;
streamTools: readonly MergedTool[];
liveStatus: LiveStatusModel;
@@ -83,13 +83,6 @@ export const buildRetryState = (
...overrides,
});
-export const textResponseStreamParts = [
- {
- type: "text",
- text: "Storybook streamed answer.",
- },
-] satisfies readonly TypesGen.ChatMessagePart[];
-
export const pinFixtureClock = () => {
const real = Date.now;
Date.now = () => FIXTURE_NOW;
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/streamingActivity.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/streamingActivity.test.ts
index 8d5a04bcca2ff..5367e63d4da79 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/streamingActivity.test.ts
+++ b/site/src/pages/AgentsPage/components/ChatConversation/streamingActivity.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { LiveStatusModel } from "./liveStatusModel";
import { shouldShowGenericThinking } from "./streamingActivity";
-import type { MergedTool, StreamState } from "./types";
+import type { MergedTool } from "./types";
const liveStatus = (phase: LiveStatusModel["phase"]): LiveStatusModel => {
switch (phase) {
@@ -41,13 +41,6 @@ const liveStatus = (phase: LiveStatusModel["phase"]): LiveStatusModel => {
}
};
-const streamState = (blocks: StreamState["blocks"]): StreamState => ({
- blocks,
- toolCalls: {},
- toolResults: {},
- sources: [],
-});
-
const tool = (status: MergedTool["status"]): MergedTool => ({
id: status,
name: "read_file",
@@ -60,8 +53,8 @@ describe("shouldShowGenericThinking", () => {
expect(
shouldShowGenericThinking({
liveStatus: liveStatus("starting"),
- streamState: null,
- streamTools: [],
+ blocks: [],
+ tools: [],
}),
).toBe(true);
});
@@ -70,8 +63,8 @@ describe("shouldShowGenericThinking", () => {
expect(
shouldShowGenericThinking({
liveStatus: liveStatus("streaming"),
- streamState: null,
- streamTools: [],
+ blocks: [],
+ tools: [],
}),
).toBe(true);
});
@@ -80,8 +73,8 @@ describe("shouldShowGenericThinking", () => {
expect(
shouldShowGenericThinking({
liveStatus: liveStatus("streaming"),
- streamState: streamState([{ type: "tool", id: "read-1" }]),
- streamTools: [tool("running")],
+ blocks: [{ type: "tool", id: "read-1" }],
+ tools: [tool("running")],
}),
).toBe(false);
});
@@ -90,8 +83,8 @@ describe("shouldShowGenericThinking", () => {
expect(
shouldShowGenericThinking({
liveStatus: liveStatus("streaming"),
- streamState: streamState([{ type: "tool", id: "read-1" }]),
- streamTools: [tool("completed")],
+ blocks: [{ type: "tool", id: "read-1" }],
+ tools: [tool("completed")],
}),
).toBe(true);
});
@@ -100,8 +93,8 @@ describe("shouldShowGenericThinking", () => {
expect(
shouldShowGenericThinking({
liveStatus: liveStatus("streaming"),
- streamState: streamState([{ type: "response", text: "hello" }]),
- streamTools: [],
+ blocks: [{ type: "response", text: "hello" }],
+ tools: [],
}),
).toBe(false);
});
@@ -110,8 +103,8 @@ describe("shouldShowGenericThinking", () => {
expect(
shouldShowGenericThinking({
liveStatus: liveStatus("streaming"),
- streamState: streamState([{ type: "thinking", text: "thinking" }]),
- streamTools: [],
+ blocks: [{ type: "thinking", text: "thinking" }],
+ tools: [],
}),
).toBe(false);
});
@@ -125,8 +118,8 @@ describe("shouldShowGenericThinking", () => {
expect(
shouldShowGenericThinking({
liveStatus: liveStatus(phase),
- streamState: null,
- streamTools: [],
+ blocks: [],
+ tools: [],
}),
).toBe(false);
});
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/streamingActivity.ts b/site/src/pages/AgentsPage/components/ChatConversation/streamingActivity.ts
index 7175bccdb1831..06751690a6a1c 100644
--- a/site/src/pages/AgentsPage/components/ChatConversation/streamingActivity.ts
+++ b/site/src/pages/AgentsPage/components/ChatConversation/streamingActivity.ts
@@ -1,24 +1,24 @@
import type { LiveStatusModel } from "./liveStatusModel";
-import type { MergedTool, StreamState } from "./types";
+import type { MergedTool, RenderBlock } from "./types";
-const hasTextOrThinkingBlock = (streamState: StreamState | null): boolean =>
- streamState?.blocks.some(
+const hasTextOrThinkingBlock = (blocks: readonly RenderBlock[]): boolean =>
+ blocks.some(
(block) => block.type === "response" || block.type === "thinking",
- ) ?? false;
+ );
-const hasRunningTool = (streamTools: readonly MergedTool[]): boolean =>
- streamTools.some((tool) => tool.status === "running");
+const hasRunningTool = (tools: readonly MergedTool[]): boolean =>
+ tools.some((tool) => tool.status === "running");
export const shouldShowGenericThinking = ({
liveStatus,
- streamState,
- streamTools,
+ blocks,
+ tools,
}: {
liveStatus: LiveStatusModel;
- streamState: StreamState | null;
- streamTools: readonly MergedTool[];
+ blocks: readonly RenderBlock[];
+ tools: readonly MergedTool[];
}): boolean =>
liveStatus.phase === "starting" ||
(liveStatus.phase === "streaming" &&
- !hasTextOrThinkingBlock(streamState) &&
- !hasRunningTool(streamTools));
+ !hasTextOrThinkingBlock(blocks) &&
+ !hasRunningTool(tools));
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.test.ts
new file mode 100644
index 0000000000000..7ccfc6135e005
--- /dev/null
+++ b/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from "vitest";
+import type * as TypesGen from "#/api/typesGenerated";
+import { assignTimelineRows } from "./timelineRows";
+import type { ParsedMessageContent, ParsedMessageEntry } from "./types";
+
+const emptyParsed: ParsedMessageContent = {
+ markdown: "",
+ reasoning: "",
+ toolCalls: [],
+ toolResults: [],
+ tools: [],
+ blocks: [],
+ sources: [],
+ hookNotices: [],
+};
+
+const entry = (
+ message: TypesGen.ChatMessage,
+ text: string,
+): ParsedMessageEntry => ({
+ message,
+ parsed: { ...emptyParsed, markdown: text },
+});
+
+const durable = (
+ id: number,
+ role: TypesGen.ChatMessage["role"],
+ text: string,
+): ParsedMessageEntry =>
+ entry(
+ {
+ id,
+ chat_id: "chat-1",
+ role,
+ created_at: "2026-08-12T00:00:00Z",
+ content: [{ type: "text", text }],
+ },
+ text,
+ );
+
+const keys = (rows: ReturnType): string[] =>
+ rows.map((row) => row.key);
+
+describe("assignTimelineRows", () => {
+ it("keys durable rows by message ID and the live row separately", () => {
+ const rows = assignTimelineRows(
+ [durable(1, "user", "prompt"), durable(2, "assistant", "answer")],
+ true,
+ );
+
+ expect(keys(rows)).toEqual(["message:1", "message:2", "live-assistant"]);
+ });
+
+ it("marks only the last message of an assistant chain", () => {
+ const rows = assignTimelineRows(
+ [
+ durable(1, "user", "prompt"),
+ durable(2, "assistant", "first"),
+ durable(3, "assistant", "second"),
+ durable(4, "user", "follow up"),
+ ],
+ false,
+ );
+
+ expect(
+ rows.map((row) => row.type === "message" && row.isLastInAssistantChain),
+ ).toEqual([false, false, true, false]);
+ expect(
+ rows.map((row) => row.type === "message" && row.isLastMessage),
+ ).toEqual([false, false, false, true]);
+ });
+});
diff --git a/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.ts b/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.ts
new file mode 100644
index 0000000000000..0888e08a3c7eb
--- /dev/null
+++ b/site/src/pages/AgentsPage/components/ChatConversation/timelineRows.ts
@@ -0,0 +1,49 @@
+import type { ParsedMessageEntry } from "./types";
+
+type TimelineMessageRow = {
+ type: "message";
+ entry: ParsedMessageEntry;
+ key: string;
+ isLastInAssistantChain: boolean;
+ isLastMessage: boolean;
+};
+
+type TimelineRow = TimelineMessageRow | { type: "live"; key: string };
+
+export const assignTimelineRows = (
+ displayMessages: readonly ParsedMessageEntry[],
+ hasLiveAssistant: boolean,
+): readonly TimelineRow[] => {
+ const rows: TimelineMessageRow[] = [];
+
+ for (const [index, entry] of displayMessages.entries()) {
+ rows.push({
+ type: "message",
+ entry,
+ key: `message:${entry.message.id}`,
+ isLastInAssistantChain: false,
+ isLastMessage: index === displayMessages.length - 1,
+ });
+ }
+
+ // Message actions only belong on the final message of a consecutive
+ // assistant chain, so walk backwards and mark the ones a user message
+ // (or the end of the transcript) follows.
+ let nextVisibleIsUser = true;
+ for (let i = rows.length - 1; i >= 0; i--) {
+ const { message } = rows[i].entry;
+ if (message.role === "system") {
+ nextVisibleIsUser = true;
+ continue;
+ }
+ if (message.role !== "user") {
+ rows[i].isLastInAssistantChain = nextVisibleIsUser;
+ }
+ nextVisibleIsUser = message.role === "user";
+ }
+
+ if (!hasLiveAssistant) {
+ return rows;
+ }
+ return [...rows, { type: "live", key: "live-assistant" }];
+};
diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx
index 89f51604dc3c3..3edecea02caff 100644
--- a/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx
+++ b/site/src/pages/AgentsPage/components/ChatElements/tools/Tool.stories.tsx
@@ -8,7 +8,7 @@ import type * as TypesGen from "#/api/typesGenerated";
import { MockChatModelConfig } from "#/testHelpers/chatModels";
import { MockWorkspace, MockWorkspaceBuild } from "#/testHelpers/entities";
import { ChatWorkspaceContext } from "../../../context/ChatWorkspaceContext";
-import { BlockList } from "../../ChatConversation/ConversationTimeline";
+import { BlockList } from "../../ChatConversation/MessageBlocks";
import { DesktopPanelContext } from "./DesktopPanelContext";
import { Tool, toolRendererNames } from "./Tool";
diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx
index fb08c2e30323e..a87deb9e18820 100644
--- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx
+++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx
@@ -29,15 +29,22 @@ import {
selectMessagesByID,
selectOrderedMessageIDs,
selectQueuedMessages,
+ selectReconnectState,
+ selectRetryState,
+ selectStreamError,
+ selectStreamState,
+ selectSubagentStatusOverrides,
useChatSelector,
type useChatStore,
} from "./ChatConversation/chatStore";
-import { LiveStreamTail } from "./ChatConversation/LiveStreamTail";
+import { LiveStreamTailContent } from "./ChatConversation/LiveStreamTail";
+import { deriveLiveStatus } from "./ChatConversation/liveStatusModel";
import {
buildSubagentMaps,
getPendingToolCallIDs,
parseMessagesWithMergedTools,
} from "./ChatConversation/messageParsing";
+import { buildStreamTools } from "./ChatConversation/streamState";
import { useOnRenderProfiler } from "./ChatConversation/useOnRenderProfiler";
import type { ModelSelectorOption } from "./ChatElements";
import type { SkillMetadata } from "./ChatMessageInput/SkillsTriggerMenu";
@@ -109,8 +116,29 @@ export const ChatPageTimeline: FC = ({
store,
selectIsAwaitingFirstStreamChunk,
);
+ const streamState = useChatSelector(store, selectStreamState);
+ const streamError = useChatSelector(store, selectStreamError);
+ const retryState = useChatSelector(store, selectRetryState);
+ const reconnectState = useChatSelector(store, selectReconnectState);
+ const subagentStatusOverrides = useChatSelector(
+ store,
+ selectSubagentStatusOverrides,
+ );
const isChatCompleted = !hasStream;
+ const liveStatus = deriveLiveStatus({
+ streamState,
+ retryState,
+ reconnectState,
+ streamError,
+ persistedError: persistedError ?? null,
+ isAwaitingFirstStreamChunk,
+ });
+ const streamTools = buildStreamTools(
+ streamState?.toolCalls,
+ streamState?.toolResults,
+ );
+
const messages = orderedMessageIDs
.map((messageID) => {
const message = messagesByID.get(messageID);
@@ -148,6 +176,10 @@ export const ChatPageTimeline: FC = ({
renders correctly. */}
= ({
mcpServers={mcpServers}
showDesktopPreviews={false}
/>
-