refactor(site): render live assistant output as a chat timeline row (#28079)

Refactor the Agents chat timeline so live assistant output renders as a
timeline row through the same components as durable messages, ahead of
the stacked MessageScroller migration in #28130.

`ConversationTimeline`'s block rendering is extracted into
`MessageBlocks`, the streaming/durable assistant split collapses into a
shared `AssistantOutput`, and `LiveStreamTail` shrinks to the empty
state and terminal failure callout. Row keys are plain `message:<id>`
strings; the live assistant row is a separate ephemeral row. This PR
does not change scrolling behavior and adds no backend, API, or database
fields.

<details>
<summary>Implementation notes</summary>

- Extract `BlockList` and friends from `ConversationTimeline` into
`MessageBlocks` (pure move).
- Replace `StreamingOutput` with `AssistantOutput`, used for both live
and durable assistant rows.
- Render the live assistant as a timeline row via `assignTimelineRows`
instead of separate transient content below the transcript.
- Keep existing transcript grouping, prompt navigation, and the current
scroll container unchanged; the scroller swap happens in #28130.

</details>

Generated by Coder Agents on behalf of @DanielleMaywood.
This commit is contained in:
Danielle Maywood
2026-08-14 12:59:18 +01:00
committed by GitHub
parent 1aa3553b52
commit 7617b6bdcc
15 changed files with 880 additions and 984 deletions
@@ -1,24 +1,38 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, screen, waitFor, within } from "storybook/test";
import { StreamingOutput } from "./StreamingOutput";
import { AssistantOutput } from "./AssistantOutput";
import {
buildLiveStatus,
buildReconnectState,
buildRetryState,
buildStreamRenderState,
pinFixtureClock,
type StoryStreamRenderState,
} from "./storyFixtures";
// StreamingOutput renders inside a ConversationItem > Message > MessageContent
// chain, but it's self-contained enough to render standalone.
// Mirrors how ConversationTimeline normalizes a live row before handing it to
// AssistantOutput.
const LiveAssistantOutput = ({
streamState,
streamTools,
liveStatus,
}: StoryStreamRenderState) => (
<AssistantOutput
keyPrefix="stream"
blocks={streamState?.blocks ?? []}
tools={streamTools}
isStreaming={liveStatus.phase === "streaming"}
liveStatus={liveStatus}
/>
);
const meta: Meta<typeof StreamingOutput> = {
title: "pages/AgentsPage/ChatConversation/StreamingOutput",
component: StreamingOutput,
const meta: Meta<typeof LiveAssistantOutput> = {
title: "pages/AgentsPage/ChatConversation/AssistantOutput",
component: LiveAssistantOutput,
beforeEach: pinFixtureClock,
};
export default meta;
type Story = StoryObj<typeof StreamingOutput>;
type Story = StoryObj<typeof LiveAssistantOutput>;
/** Transport reconnects render a non-terminal reconnecting callout. */
export const ReconnectingAfterDisconnect: Story = {
@@ -245,11 +259,7 @@ export const StartingShowsThinkingActivity: Story = {
};
export const ResponseDoesNotRenderActivitySlot: Story = {
args: {
streamState: responseStreamState.streamState,
streamTools: responseStreamState.streamTools,
liveStatus: responseStreamState.liveStatus,
},
args: responseStreamState,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.queryByTestId("live-activity-slot")).not.toBeInTheDocument();
@@ -258,22 +268,20 @@ export const ResponseDoesNotRenderActivitySlot: Story = {
/** Tool-only streams use running tool affordances instead of generic thinking. */
export const RunningToolsSuppressThinkingActivity: Story = {
args: {
...buildStreamRenderState([
{
type: "tool-call",
tool_name: "execute",
tool_call_id: "tc-1",
args: { command: "ls -la" },
},
{
type: "tool-call",
tool_name: "read_file",
tool_call_id: "tc-2",
args: { path: "README.md" },
},
]),
},
args: buildStreamRenderState([
{
type: "tool-call",
tool_name: "execute",
tool_call_id: "tc-1",
args: { command: "ls -la" },
},
{
type: "tool-call",
tool_name: "read_file",
tool_call_id: "tc-2",
args: { path: "README.md" },
},
]),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.queryByTestId("live-activity-slot")).not.toBeInTheDocument();
@@ -334,18 +342,10 @@ export const EditFilesEmptyDeltaKeepsRunningHeight: Story = {
return (
<div className="flex flex-col gap-2">
<div data-testid="running-edit-files">
<StreamingOutput
streamState={editFilesRunningState.streamState}
streamTools={editFilesRunningState.streamTools}
liveStatus={editFilesRunningState.liveStatus}
/>
<LiveAssistantOutput {...editFilesRunningState} />
</div>
<div data-testid="empty-delta-edit-files">
<StreamingOutput
streamState={editFilesEmptyDeltaState.streamState}
streamTools={editFilesEmptyDeltaState.streamTools}
liveStatus={editFilesEmptyDeltaState.liveStatus}
/>
<LiveAssistantOutput {...editFilesEmptyDeltaState} />
</div>
</div>
);
@@ -0,0 +1,51 @@
import type { FC } from "react";
import { Shimmer } from "../ChatElements";
import { ToolIcon } from "../ChatElements/tools/ToolIcon";
import { ChatStatusCallout } from "./ChatStatusCallout";
import type { LiveStatusModel } from "./liveStatusModel";
import { BlockList, type BlockListProps } from "./MessageBlocks";
import { shouldShowGenericThinking } from "./streamingActivity";
const LiveActivitySlot: FC = () => (
<div
data-testid="live-activity-slot"
className="flex h-6 items-center gap-2 text-content-secondary"
>
<ToolIcon name="thinking" />
<Shimmer as="span" className="text-[13px] leading-6">
Thinking
</Shimmer>
</div>
);
type AssistantOutputProps = BlockListProps & {
// Present only while the turn is still live. Drives the retry/reconnect
// callout and the generic thinking indicator.
liveStatus?: LiveStatusModel;
};
/**
* Renders assistant output from already-normalized blocks and tools, so a live
* turn and the durable message that replaces it render through the same path.
*/
export const AssistantOutput: FC<AssistantOutputProps> = ({
liveStatus,
...blockProps
}) => {
const { blocks, tools } = blockProps;
const callout =
liveStatus?.phase === "retrying" || liveStatus?.phase === "reconnecting"
? liveStatus
: undefined;
return (
<div className="relative flex flex-col gap-2 overflow-visible">
<BlockList {...blockProps} />
{callout && <ChatStatusCallout status={callout} />}
{liveStatus &&
shouldShowGenericThinking({ liveStatus, blocks, tools }) && (
<LiveActivitySlot />
)}
</div>
);
};
@@ -6,7 +6,6 @@ import {
} from "lucide-react";
import {
type FC,
Fragment,
memo,
type ReactNode,
useLayoutEffect,
@@ -14,11 +13,8 @@ import {
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 { AlertTitle } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
@@ -35,36 +31,29 @@ import {
Message,
MessageContent,
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 { ImageLightbox } from "../ImageLightbox";
import { TextPreviewDialog } from "../TextPreviewDialog";
import {
AttachmentBlock,
type PreviewTextAttachment,
} from "./AttachmentBlocks";
import { groupSequentialReadFileBlocks } from "./blockUtils";
import { AssistantOutput } from "./AssistantOutput";
import type { PreviewTextAttachment } from "./AttachmentBlocks";
import { FileProbeProvider } from "./FileProbeContext";
import {
type LiveStatusModel,
shouldRenderLiveAssistant,
} from "./liveStatusModel";
import {
buildDisplayMessages,
deriveMessageDisplayState,
} from "./messageHelpers";
import { getEditableUserMessagePayload } from "./messageParsing";
import { useSmoothStreamingText } from "./SmoothText";
import { getThinkingDisclosureDisplay } from "./thinkingTitle";
import { assignTimelineRows } from "./timelineRows";
import type {
MergedTool,
ParsedMessageContent,
ParsedMessageEntry,
RenderBlock,
StreamState,
} from "./types";
import { UserMessageContent } from "./UserMessageContent";
@@ -85,441 +74,6 @@ const getChatMessageTextContent = (
return textContent.length > 0 ? textContent : undefined;
};
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<boolean | null>(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<HTMLDivElement>(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 (
<div data-transcript-row="">
<ToolCall.Root
className="w-full"
status={isStreaming ? "running" : "completed"}
hasContent={hasText}
expanded={expanded}
onExpandedChange={(open) => setManualToggle(open)}
>
<ToolCall.Header
iconName="thinking"
label={title}
showStatus={false}
/>
<ToolCall.Content>
<div
ref={previewScrollRef}
className={cn(
"mt-1.5",
isPreviewConstrained && "max-h-24 overflow-y-auto",
)}
>
<Response
className="text-[11px] text-content-secondary"
urlTransform={urlTransform}
streaming={isStreaming}
>
{body}
</Response>
</div>
</ToolCall.Content>
</ToolCall.Root>
</div>
);
},
);
// Wrapper that runs the smooth-streaming jitter buffer on a single
// response block. Only used during live streaming — historical
// messages render through <Response> directly.
const SmoothedResponse = memo<{
text: string;
streamKey: string;
urlTransform?: UrlTransform;
}>(({ text, streamKey, urlTransform }) => {
const { visibleText } = useSmoothStreamingText({
fullText: text,
isStreaming: true,
bypassSmoothing: false,
streamKey,
});
return (
<Response streaming urlTransform={urlTransform}>
{visibleText}
</Response>
);
});
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 (
<ToolCall.PolicyProvider hookRewritten={firstTool.hookRewritten ?? false}>
<div data-tool-call="">
<ReadFileTool
{...readFile}
status={firstTool.status}
expanded={expanded}
onExpandedChange={setExpanded}
/>
</div>
</ToolCall.PolicyProvider>
);
}
return (
<ReadFilesTool
tools={tools}
expanded={expanded}
onExpandedChange={setExpanded}
/>
);
});
// Shared block renderer used by both ChatMessageItem (historical
// messages) and StreamingOutput (live stream). 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: readonly RenderBlock[];
tools: readonly MergedTool[];
keyPrefix: string;
isStreaming?: boolean;
subagentTitles?: Map<string, string>;
subagentVariants?: Map<string, SubagentVariant>;
showDesktopPreviews?: boolean;
subagentStatusOverrides?: Map<string, TypesGen.ChatStatus>;
mcpServers?: readonly TypesGen.MCPServerConfig[];
onImageClick?: (src: string) => void;
onTextFileClick?: (attachment: PreviewTextAttachment) => void;
onImplementPlan?: () => Promise<void> | void;
onSendAskUserQuestionResponse?: (message: string) => Promise<void> | void;
isChatCompleted?: boolean;
latestAskUserQuestionToolId?: string;
askUserQuestionResponseTextByToolId?: ReadonlyMap<string, string>;
hasUserResponseAfterAskQuestion?: boolean;
urlTransform?: UrlTransform;
}> = ({
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": {
const responseEl = isStreaming ? (
<SmoothedResponse
key={`${keyPrefix}-response-${index}`}
text={block.text}
streamKey={keyPrefix}
urlTransform={urlTransform}
/>
) : (
<Response
key={`${keyPrefix}-response-${index}`}
urlTransform={urlTransform}
>
{block.text}
</Response>
);
return (
<Fragment key={`${keyPrefix}-response-${index}`}>
{responseEl}
</Fragment>
);
}
case "thinking":
return (
<ReasoningDisclosure
key={`${keyPrefix}-thinking-${index}`}
id={`${keyPrefix}-thinking-${index}`}
text={block.text}
isStreaming={
isStreaming &&
lastDisplayBlockIsThinking &&
index === displayBlocks.length - 1
}
urlTransform={urlTransform}
thinkingDisplayMode={thinkingDisplayMode}
/>
);
case "file-reference":
return (
<div
key={`${keyPrefix}-file-reference-${index}`}
className="my-1 flex items-start gap-2 rounded-md border border-content-link/20 bg-content-link/5 px-2.5 py-1.5"
>
<span className="shrink-0 text-xs font-medium text-content-link">
{block.file_name}:
{block.start_line === block.end_line
? block.start_line
: `${block.start_line}\u2013${block.end_line}`}
</span>
</div>
);
case "tool-group": {
const [firstGroupTool, ...restGroupTools] = block.ids
.map((id) => toolByID.get(id))
.filter((tool) => tool !== undefined);
if (!firstGroupTool) {
return null;
}
return (
<ReadFileTimelineBlock
key={firstGroupTool.id}
tools={[firstGroupTool, ...restGroupTools]}
/>
);
}
case "tool": {
const tool = toolByID.get(block.id);
if (!tool) {
if (!isStreaming) {
return null;
}
// Streaming placeholder for not-yet-resolved tool.
return (
<Tool
key={block.id}
name="Tool"
status="running"
isError={false}
shellToolDisplayMode={shellToolDisplayMode}
codeDiffDisplayMode={codeDiffDisplayMode}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
subagentStatusOverrides={subagentStatusOverrides}
mcpServers={mcpServers}
/>
);
}
if (tool.name === "read_file") {
return <ReadFileTimelineBlock key={tool.id} tools={[tool]} />;
}
return (
<Tool
key={tool.id}
name={tool.name}
args={tool.args}
result={tool.result}
status={tool.status}
isError={tool.isError}
killedBySignal={tool.killedBySignal}
shellToolDisplayMode={shellToolDisplayMode}
codeDiffDisplayMode={codeDiffDisplayMode}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
showDesktopPreviews={showDesktopPreviews}
subagentStatusOverrides={
isStreaming ? subagentStatusOverrides : undefined
}
mcpServerConfigId={tool.mcpServerConfigId}
mcpServers={mcpServers}
onImplementPlan={onImplementPlan}
onSendAskUserQuestionResponse={onSendAskUserQuestionResponse}
isChatCompleted={isChatCompleted}
isLatestAskUserQuestion={
tool.id === latestAskUserQuestionToolId &&
!hasUserResponseAfterAskQuestion
}
previousResponseText={
tool.name === "ask_user_question"
? askUserQuestionResponseTextByToolId?.get(tool.id)
: undefined
}
modelIntent={tool.modelIntent}
parsedCommands={tool.parsedCommands}
hookRewritten={tool.hookRewritten}
/>
);
}
case "file":
return (
<AttachmentBlock
key={`${keyPrefix}-file-${block.file_id ?? index}`}
block={block}
onImageClick={onImageClick}
onTextFileClick={onTextFileClick}
framePreview
showTextStatus
/>
);
case "sources":
return (
<WebSearchSources
key={`${keyPrefix}-sources-${index}`}
sources={block.sources}
/>
);
default: {
const _exhaustive: never = block;
return _exhaustive;
}
}
})}
{remainingTools.map((tool) => (
<Tool
key={tool.id}
name={tool.name}
args={tool.args}
result={tool.result}
status={tool.status}
isError={tool.isError}
killedBySignal={tool.killedBySignal}
shellToolDisplayMode={shellToolDisplayMode}
codeDiffDisplayMode={codeDiffDisplayMode}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
showDesktopPreviews={showDesktopPreviews}
subagentStatusOverrides={
isStreaming ? subagentStatusOverrides : undefined
}
mcpServerConfigId={tool.mcpServerConfigId}
mcpServers={mcpServers}
onImplementPlan={onImplementPlan}
onSendAskUserQuestionResponse={onSendAskUserQuestionResponse}
isChatCompleted={isChatCompleted}
isLatestAskUserQuestion={
tool.id === latestAskUserQuestionToolId &&
!hasUserResponseAfterAskQuestion
}
previousResponseText={
tool.name === "ask_user_question"
? askUserQuestionResponseTextByToolId?.get(tool.id)
: undefined
}
modelIntent={tool.modelIntent}
parsedCommands={tool.parsedCommands}
hookRewritten={tool.hookRewritten}
/>
))}
</>
);
};
// Avoid announcing historical hook notices as live alerts.
const TimelineNotice: FC<{ children?: ReactNode }> = ({ children }) => (
<div
@@ -546,8 +100,17 @@ const LifecycleHookNotice: FC<{
);
const ChatMessageItem = memo<{
message: TypesGen.ChatMessage;
parsed: ParsedMessageContent;
renderKey: string;
// Durable rows render from a message. The live assistant row renders from
// liveStatus and the stream buffers instead.
message?: TypesGen.ChatMessage;
parsed?: ParsedMessageContent;
liveStatus?: LiveStatusModel;
// Live blocks and tools are normalized at the live row callsite, so this
// component never has to decide when stream output is visible.
liveBlocks?: readonly RenderBlock[];
liveTools?: readonly MergedTool[];
subagentStatusOverrides?: Map<string, TypesGen.ChatStatus>;
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<string | null>(null);
const [previewText, setPreviewText] =
useState<PreviewTextAttachment | null>(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 (
<div
className={cn(
@@ -637,7 +209,7 @@ const ChatMessageItem = memo<{
{parsed.hookNotices.length > 0 ? (
parsed.hookNotices.map((notice, index) => (
<LifecycleHookNotice
key={`${message.id}-hook-notice-${index}`}
key={`${renderKey}-hook-notice-${index}`}
urlTransform={urlTransform}
>
{notice}
@@ -658,6 +230,9 @@ const ChatMessageItem = memo<{
return (
<div
// User rows render inside StickyUserMessage, which owns the row
// identity attributes for both the flow copy and the sticky copy.
data-testid={isUser ? undefined : `chat-message-${renderKey}`}
className={cn(
isAfterEditingMessage && "opacity-40 pointer-events-none",
"group/msg relative transition-opacity duration-200",
@@ -665,11 +240,13 @@ const ChatMessageItem = memo<{
inert={isAfterEditingMessage ? true : undefined}
>
<ConversationItem {...conversationItemProps}>
{isUser ? (
{isUser && displayState && parsed ? (
<UserMessageContent
displayState={displayState}
markdown={parsed.markdown}
isEditing={editingMessageId === message.id}
isEditing={
messageId !== undefined && editingMessageId === messageId
}
fadeFromBottom={fadeFromBottom}
onImageClick={setPreviewImage}
onTextFileClick={setPreviewText}
@@ -677,46 +254,45 @@ const ChatMessageItem = memo<{
) : (
<Message className="w-full">
<MessageContent className="whitespace-normal">
{/* Keep assistant content spacing consistent by letting the parent stack own every top-level gap. */}
<div className="relative flex flex-col gap-2 overflow-visible">
<BlockList
blocks={parsed.blocks}
tools={parsed.tools}
keyPrefix={String(message.id)}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
showDesktopPreviews={showDesktopPreviews}
onImplementPlan={onImplementPlan}
onSendAskUserQuestionResponse={
onSendAskUserQuestionResponse
}
isChatCompleted={isChatCompleted}
latestAskUserQuestionToolId={latestAskUserQuestionToolId}
askUserQuestionResponseTextByToolId={
askUserQuestionResponseTextByToolId
}
hasUserResponseAfterAskQuestion={
hasUserResponseAfterAskQuestion
}
onImageClick={setPreviewImage}
onTextFileClick={setPreviewText}
urlTransform={urlTransform}
mcpServers={mcpServers}
/>
</div>
<AssistantOutput
keyPrefix={renderKey}
blocks={parsed?.blocks ?? liveBlocks}
tools={parsed?.tools ?? liveTools}
isStreaming={liveStatus?.phase === "streaming"}
liveStatus={liveStatus}
subagentStatusOverrides={subagentStatusOverrides}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
showDesktopPreviews={showDesktopPreviews}
onImplementPlan={onImplementPlan}
onSendAskUserQuestionResponse={onSendAskUserQuestionResponse}
isChatCompleted={isChatCompleted}
latestAskUserQuestionToolId={latestAskUserQuestionToolId}
askUserQuestionResponseTextByToolId={
askUserQuestionResponseTextByToolId
}
hasUserResponseAfterAskQuestion={
hasUserResponseAfterAskQuestion
}
onImageClick={setPreviewImage}
onTextFileClick={setPreviewText}
urlTransform={urlTransform}
mcpServers={mcpServers}
/>
</MessageContent>
</Message>
)}
</ConversationItem>
{parsed.hookNotices.map((notice, index) => (
{parsed?.hookNotices.map((notice, index) => (
<LifecycleHookNotice
key={`${message.id}-hook-notice-${index}`}
key={`${renderKey}-hook-notice-${index}`}
urlTransform={urlTransform}
>
{notice}
</LifecycleHookNotice>
))}
{!hideActions &&
{displayState &&
!hideActions &&
(displayState.hasCopyableContent ||
(isUser && onEditUserMessage)) && (
<div
@@ -726,7 +302,7 @@ const ChatMessageItem = memo<{
)}
data-testid="message-actions"
>
{displayState.hasCopyableContent && (
{displayState.hasCopyableContent && parsed && (
<CopyButton
text={parsed.markdown}
label="Copy message"
@@ -734,7 +310,7 @@ const ChatMessageItem = memo<{
tooltipSide="bottom"
/>
)}
{isUser && onEditUserMessage && (
{isUser && messageId !== undefined && onEditUserMessage && (
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -745,7 +321,7 @@ const ChatMessageItem = memo<{
onClick={() => {
const { text, fileBlocks } =
getEditableUserMessagePayload(message);
onEditUserMessage(message.id, text, fileBlocks);
onEditUserMessage(messageId, text, fileBlocks);
}}
>
<PencilIcon />
@@ -812,7 +388,7 @@ const ChatMessageItem = memo<{
)}
</div>
)}
{displayState.needsAssistantBottomSpacer && !isLastMessage && (
{displayState?.needsAssistantBottomSpacer && !isLastMessage && (
<div className="min-h-6" data-testid="assistant-bottom-spacer" />
)}
{previewImage && (
@@ -866,6 +442,7 @@ const StickyUserMessage = memo<{
const [isReady, setIsReady] = useState(false);
const [isTooTall, setIsTooTall] = useState(false);
const sentinelRef = useRef<HTMLDivElement>(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<{
<div ref={setSentinelRef} className="h-0" data-user-sentinel />
<div
ref={containerRef}
data-testid={`chat-message-${messageKey}`}
className={cn(
"relative px-3 -mx-3 -mt-2",
!isTooTall && "sticky z-10",
@@ -1096,6 +674,7 @@ const StickyUserMessage = memo<{
inert={isStuck && !isTooTall ? true : undefined}
>
<ChatMessageItem
renderKey={messageKey}
message={message}
parsed={parsed}
onEditUserMessage={handleEditUserMessage}
@@ -1142,6 +721,7 @@ const StickyUserMessage = memo<{
to GPU layer. */}
<div className="relative px-3 pointer-events-auto will-change-[max-height]">
<ChatMessageItem
renderKey={messageKey}
message={message}
parsed={parsed}
onEditUserMessage={handleEditUserMessage}
@@ -1162,27 +742,12 @@ const StickyUserMessage = memo<{
},
);
function computeLastInChainFlags(
displayMessages: readonly ParsedMessageEntry[],
): boolean[] {
const flags = new Array<boolean>(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<string, TypesGen.ChatStatus>;
subagentTitles: Map<string, string>;
subagentVariants?: Map<string, SubagentVariant>;
onEditUserMessage?: (
@@ -1204,6 +769,10 @@ interface ConversationTimelineProps {
export const ConversationTimeline = memo<ConversationTimelineProps>(
({
parsedMessages,
streamState,
streamTools = [],
liveStatus,
subagentStatusOverrides,
subagentTitles,
subagentVariants,
onEditUserMessage,
@@ -1233,9 +802,20 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
};
const displayMessages = buildDisplayMessages(parsedMessages);
const lastInChainFlags = computeLastInChainFlags(displayMessages);
const renderRows = assignTimelineRows(
displayMessages,
Boolean(liveStatus && shouldRenderLiveAssistant(liveStatus)),
);
if (parsedMessages.length === 0) {
// 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 (renderRows.length === 0) {
return null;
}
@@ -1259,16 +839,10 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
// 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<ConversationTimelineProps>(
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 (
<ChatMessageItem
key={row.key}
renderKey={row.key}
liveStatus={liveStatus}
liveBlocks={liveBlocks}
liveTools={liveTools}
subagentStatusOverrides={
showsStreamOutput ? subagentStatusOverrides : undefined
}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
urlTransform={urlTransform}
mcpServers={mcpServers}
/>
);
}
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 (
<StickyUserMessage
key={message.id}
key={row.key}
message={message}
parsed={parsed}
onEditUserMessage={onEditUserMessage}
editingMessageId={editingMessageId}
isAfterEditingMessage={afterEditingMessageIds.has(message.id)}
prevUserMessageId={userNeighborsById.get(message.id)?.prevId}
nextUserMessageId={userNeighborsById.get(message.id)?.nextId}
isAfterEditingMessage={isAfterEditingMessage}
prevUserMessageId={neighbors?.prevId}
nextUserMessageId={neighbors?.nextId}
onJumpToUserMessage={jumpToUserMessage}
registerSentinel={registerSentinel}
urlTransform={urlTransform}
/>
);
}
// 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 (
<ChatMessageItem
key={message.id}
key={row.key}
renderKey={row.key}
message={message}
parsed={parsed}
onImplementPlan={onImplementPlan}
@@ -1373,11 +958,11 @@ export const ConversationTimeline = memo<ConversationTimelineProps>(
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}
@@ -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<typeof LiveStreamTailContent> = {
isTranscriptEmpty: true,
streamState: null,
streamTools: [],
liveStatus: buildLiveStatus(),
subagentTitles: new Map(),
subagentStatusOverrides: new Map(),
};
const meta: Meta<typeof LiveStreamTailContent> = {
@@ -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();
},
};
@@ -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<typeof useChatStore>["store"];
import type { LiveStatusModel } from "./liveStatusModel";
interface LiveStreamTailContentProps {
isTranscriptEmpty: boolean;
streamState: StreamState | null;
streamTools: readonly MergedTool[];
liveStatus: LiveStatusModel;
subagentTitles: Map<string, string>;
subagentVariants?: Map<string, SubagentVariant>;
subagentStatusOverrides: Map<string, TypesGen.ChatStatus>;
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 = ({
<p className="text-sm">Start a conversation with your agent.</p>
</div>
)}
{shouldRenderStreamSection && (
<StreamingOutput
streamState={streamState}
streamTools={streamTools}
liveStatus={liveStatus}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
subagentStatusOverrides={subagentStatusOverrides}
urlTransform={urlTransform}
mcpServers={mcpServers}
/>
)}
{terminalStatus && <ChatStatusCallout status={terminalStatus} />}
</div>
);
};
interface LiveStreamTailProps {
store: ChatStoreHandle;
persistedError: ChatDetailError | undefined;
isTranscriptEmpty: boolean;
subagentTitles: Map<string, string>;
subagentVariants?: Map<string, SubagentVariant>;
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 (
<LiveStreamTailContent
isTranscriptEmpty={isTranscriptEmpty}
streamState={streamState}
streamTools={streamTools}
liveStatus={liveStatus}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
subagentStatusOverrides={subagentStatusOverrides}
urlTransform={urlTransform}
mcpServers={mcpServers}
/>
);
};
@@ -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<boolean | null>(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<HTMLDivElement>(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 (
<div data-transcript-row="">
<ToolCall.Root
className="w-full"
status={isStreaming ? "running" : "completed"}
hasContent={hasText}
expanded={expanded}
onExpandedChange={(open) => setManualToggle(open)}
>
<ToolCall.Header
iconName="thinking"
label={title}
showStatus={false}
/>
<ToolCall.Content>
<div
ref={previewScrollRef}
className={cn(
"mt-1.5",
isPreviewConstrained && "max-h-24 overflow-y-auto",
)}
>
<Response
className="text-[11px] text-content-secondary"
urlTransform={urlTransform}
streaming={isStreaming}
>
{body}
</Response>
</div>
</ToolCall.Content>
</ToolCall.Root>
</div>
);
},
);
// 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 (
<Response streaming={isStreaming} urlTransform={urlTransform}>
{isStreaming ? visibleText : text}
</Response>
);
});
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 (
<ToolCall.PolicyProvider hookRewritten={firstTool.hookRewritten ?? false}>
<div data-tool-call="">
<ReadFileTool
{...readFile}
status={firstTool.status}
expanded={expanded}
onExpandedChange={setExpanded}
/>
</div>
</ToolCall.PolicyProvider>
);
}
return (
<ReadFilesTool
tools={tools}
expanded={expanded}
onExpandedChange={setExpanded}
/>
);
});
export type BlockListProps = {
blocks: readonly RenderBlock[];
tools: readonly MergedTool[];
keyPrefix: string;
isStreaming?: boolean;
subagentTitles?: Map<string, string>;
subagentVariants?: Map<string, SubagentVariant>;
showDesktopPreviews?: boolean;
subagentStatusOverrides?: Map<string, TypesGen.ChatStatus>;
mcpServers?: readonly TypesGen.MCPServerConfig[];
onImageClick?: (src: string) => void;
onTextFileClick?: (attachment: PreviewTextAttachment) => void;
onImplementPlan?: () => Promise<void> | void;
onSendAskUserQuestionResponse?: (message: string) => Promise<void> | void;
isChatCompleted?: boolean;
latestAskUserQuestionToolId?: string;
askUserQuestionResponseTextByToolId?: ReadonlyMap<string, string>;
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<BlockListProps> = ({
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 (
<ResponseBlock
key={`${keyPrefix}-response-${index}`}
text={block.text}
isStreaming={isStreaming}
streamKey={keyPrefix}
urlTransform={urlTransform}
/>
);
case "thinking":
return (
<ReasoningDisclosure
key={`${keyPrefix}-thinking-${index}`}
id={`${keyPrefix}-thinking-${index}`}
text={block.text}
isStreaming={
isStreaming &&
lastDisplayBlockIsThinking &&
index === displayBlocks.length - 1
}
urlTransform={urlTransform}
thinkingDisplayMode={thinkingDisplayMode}
/>
);
case "file-reference":
return (
<div
key={`${keyPrefix}-file-reference-${index}`}
className="my-1 flex items-start gap-2 rounded-md border border-content-link/20 bg-content-link/5 px-2.5 py-1.5"
>
<span className="shrink-0 text-xs font-medium text-content-link">
{block.file_name}:
{block.start_line === block.end_line
? block.start_line
: `${block.start_line}\u2013${block.end_line}`}
</span>
</div>
);
case "tool-group": {
const [firstGroupTool, ...restGroupTools] = block.ids
.map((id) => toolByID.get(id))
.filter((tool) => tool !== undefined);
if (!firstGroupTool) {
return null;
}
return (
<ReadFileTimelineBlock
key={firstGroupTool.id}
tools={[firstGroupTool, ...restGroupTools]}
/>
);
}
case "tool": {
const tool = toolByID.get(block.id);
if (!tool) {
if (!isStreaming) {
return null;
}
// Streaming placeholder for not-yet-resolved tool.
return (
<Tool
key={block.id}
name="Tool"
status="running"
isError={false}
shellToolDisplayMode={shellToolDisplayMode}
codeDiffDisplayMode={codeDiffDisplayMode}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
subagentStatusOverrides={subagentStatusOverrides}
mcpServers={mcpServers}
/>
);
}
if (tool.name === "read_file") {
return <ReadFileTimelineBlock key={tool.id} tools={[tool]} />;
}
return (
<Tool
key={tool.id}
name={tool.name}
args={tool.args}
result={tool.result}
status={tool.status}
isError={tool.isError}
killedBySignal={tool.killedBySignal}
shellToolDisplayMode={shellToolDisplayMode}
codeDiffDisplayMode={codeDiffDisplayMode}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
showDesktopPreviews={showDesktopPreviews}
subagentStatusOverrides={
isStreaming ? subagentStatusOverrides : undefined
}
mcpServerConfigId={tool.mcpServerConfigId}
mcpServers={mcpServers}
onImplementPlan={onImplementPlan}
onSendAskUserQuestionResponse={onSendAskUserQuestionResponse}
isChatCompleted={isChatCompleted}
isLatestAskUserQuestion={
tool.id === latestAskUserQuestionToolId &&
!hasUserResponseAfterAskQuestion
}
previousResponseText={
tool.name === "ask_user_question"
? askUserQuestionResponseTextByToolId?.get(tool.id)
: undefined
}
modelIntent={tool.modelIntent}
parsedCommands={tool.parsedCommands}
hookRewritten={tool.hookRewritten}
/>
);
}
case "file":
return (
<AttachmentBlock
key={`${keyPrefix}-file-${block.file_id ?? index}`}
block={block}
onImageClick={onImageClick}
onTextFileClick={onTextFileClick}
framePreview
showTextStatus
/>
);
case "sources":
return (
<WebSearchSources
key={`${keyPrefix}-sources-${index}`}
sources={block.sources}
/>
);
default: {
const _exhaustive: never = block;
return _exhaustive;
}
}
})}
{remainingTools.map((tool) => (
<Tool
key={tool.id}
name={tool.name}
args={tool.args}
result={tool.result}
status={tool.status}
isError={tool.isError}
killedBySignal={tool.killedBySignal}
shellToolDisplayMode={shellToolDisplayMode}
codeDiffDisplayMode={codeDiffDisplayMode}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
showDesktopPreviews={showDesktopPreviews}
subagentStatusOverrides={
isStreaming ? subagentStatusOverrides : undefined
}
mcpServerConfigId={tool.mcpServerConfigId}
mcpServers={mcpServers}
onImplementPlan={onImplementPlan}
onSendAskUserQuestionResponse={onSendAskUserQuestionResponse}
isChatCompleted={isChatCompleted}
isLatestAskUserQuestion={
tool.id === latestAskUserQuestionToolId &&
!hasUserResponseAfterAskQuestion
}
previousResponseText={
tool.name === "ask_user_question"
? askUserQuestionResponseTextByToolId?.get(tool.id)
: undefined
}
modelIntent={tool.modelIntent}
parsedCommands={tool.parsedCommands}
hookRewritten={tool.hookRewritten}
/>
))}
</>
);
};
@@ -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 = () => (
<div
data-testid="live-activity-slot"
className="flex h-6 items-center gap-2 text-content-secondary"
>
<ToolIcon name="thinking" />
<Shimmer as="span" className="text-[13px] leading-6">
Thinking
</Shimmer>
</div>
);
export const StreamingOutput: FC<{
streamState: StreamState | null;
streamTools: readonly MergedTool[];
subagentTitles?: Map<string, string>;
subagentVariants?: Map<string, SubagentVariant>;
subagentStatusOverrides?: Map<string, TypesGen.ChatStatus>;
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 (
<ConversationItem {...conversationItemProps}>
<Message className="w-full">
<MessageContent className="whitespace-normal">
<div className="relative flex flex-col gap-2 overflow-visible">
{shouldShowBlocks && (
<BlockList
blocks={blocks}
tools={streamTools}
keyPrefix="stream"
isStreaming={isStreaming}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
subagentStatusOverrides={subagentStatusOverrides}
urlTransform={urlTransform}
mcpServers={mcpServers}
/>
)}
{hasCalloutLiveStatus(liveStatus) && (
<ChatStatusCallout status={liveStatus} />
)}
{showActivity && <LiveActivitySlot />}
</div>
</MessageContent>
</Message>
</ConversationItem>
);
};
@@ -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;
@@ -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;
@@ -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);
});
@@ -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));
@@ -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<typeof assignTimelineRows>): 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]);
});
});
@@ -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" }];
};
@@ -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";
@@ -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<ChatPageTimelineProps> = ({
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<ChatPageTimelineProps> = ({
renders correctly. */}
<ConversationTimeline
parsedMessages={parsedMessages}
streamState={streamState}
streamTools={streamTools}
liveStatus={liveStatus}
subagentStatusOverrides={subagentStatusOverrides}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
onEditUserMessage={onEditUserMessage}
@@ -161,14 +193,9 @@ export const ChatPageTimeline: FC<ChatPageTimelineProps> = ({
mcpServers={mcpServers}
showDesktopPreviews={false}
/>
<LiveStreamTail
store={store}
persistedError={persistedError}
<LiveStreamTailContent
isTranscriptEmpty={parsedMessages.length === 0}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
urlTransform={urlTransform}
mcpServers={mcpServers}
liveStatus={liveStatus}
/>
</div>
</Profiler>