mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
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:
+38
-38
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user