diff --git a/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AssistantOutput.stories.tsx similarity index 88% rename from site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.stories.tsx rename to site/src/pages/AgentsPage/components/ChatConversation/AssistantOutput.stories.tsx index 2bd9b9d52c58c..d524feada72e2 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/StreamingOutput.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/AssistantOutput.stories.tsx @@ -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) => ( + +); -const meta: Meta = { - title: "pages/AgentsPage/ChatConversation/StreamingOutput", - component: StreamingOutput, +const meta: Meta = { + title: "pages/AgentsPage/ChatConversation/AssistantOutput", + component: LiveAssistantOutput, beforeEach: pinFixtureClock, }; export default meta; -type Story = StoryObj; +type Story = StoryObj; /** 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 (
- +
- +
); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/AssistantOutput.tsx b/site/src/pages/AgentsPage/components/ChatConversation/AssistantOutput.tsx new file mode 100644 index 0000000000000..c7eb2e8f51556 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatConversation/AssistantOutput.tsx @@ -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 = () => ( +
+ + + Thinking + +
+); + +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 = ({ + liveStatus, + ...blockProps +}) => { + const { blocks, tools } = blockProps; + const callout = + liveStatus?.phase === "retrying" || liveStatus?.phase === "reconnecting" + ? liveStatus + : undefined; + + return ( +
+ + {callout && } + {liveStatus && + shouldShowGenericThinking({ liveStatus, blocks, tools }) && ( + + )} +
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index 20bdf50b3555b..1fa291b840391 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -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(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} - -
-
-
-
- ); - }, -); - -// Wrapper that runs the smooth-streaming jitter buffer on a single -// response block. Only used during live streaming — historical -// messages render through directly. -const SmoothedResponse = memo<{ - text: string; - streamKey: string; - urlTransform?: UrlTransform; -}>(({ text, streamKey, urlTransform }) => { - const { visibleText } = useSmoothStreamingText({ - fullText: text, - isStreaming: true, - bypassSmoothing: false, - streamKey, - }); - return ( - - {visibleText} - - ); -}); - -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 ( - - ); -}); - -// 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; - 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; -}> = ({ - 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 ? ( - - ) : ( - - {block.text} - - ); - return ( - - {responseEl} - - ); - } - 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) => ( - - ))} - - ); -}; - // Avoid announcing historical hook notices as live alerts. const TimelineNotice: FC<{ children?: ReactNode }> = ({ children }) => (
; 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} /> -