diff --git a/ui/src/components/ChatMessageList/ChatMessageList.tsx b/ui/src/components/ChatMessageList/ChatMessageList.tsx index 931c336..c877799 100644 --- a/ui/src/components/ChatMessageList/ChatMessageList.tsx +++ b/ui/src/components/ChatMessageList/ChatMessageList.tsx @@ -281,6 +281,11 @@ export function ChatMessageList({ enabled: messageGroups.length > 0, }); + // Don't adjust scroll position when the actively-streaming item grows — + // the default correction pushes the user further down on every token. + virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => + !(item.index === instance.options.count - 1 && hasStreamingResponses); + // Track message count to detect new user messages const prevMessagesLengthRef = useRef(messages.length); @@ -347,15 +352,32 @@ export function ChatMessageList({
- {/* Virtualized message groups */} {virtualizer.getVirtualItems().map((virtualItem) => { const group = messageGroups[virtualItem.index]; + const isLastGroup = virtualItem.index === messageGroups.length - 1; + const activeStreamingIds = + isLastGroup && hasStreamingResponses + ? new Set(filteredModelResponses.map((r) => r.instanceId ?? r.model)) + : null; + const committedInstanceIds = new Set( + group.assistantResponses + .filter((r) => !activeStreamingIds?.has(r.instanceId ?? r.model ?? "")) + .map((r) => r.instanceId ?? r.model ?? "") + ); + const showStreaming = + isLastGroup && + hasStreamingResponses && + filteredModelResponses.some( + (r) => !committedInstanceIds.has(r.instanceId ?? r.model) + ); + const committedResponses = activeStreamingIds + ? group.assistantResponses.filter( + (r) => !activeStreamingIds.has(r.instanceId ?? r.model ?? "") + ) + : group.assistantResponses; return (
- {group.assistantResponses.length > 0 && ( + {showStreaming && ( + <> + + !disabledModels.includes(m))} + /> + !disabledModels.includes(m))} + /> + + + !disabledModels.includes(m))} + /> + !disabledModels.includes(m))} + /> + !disabledModels.includes(m))} + /> + !disabledModels.includes(m))} + /> + !disabledModels.includes(m))} + /> + + + + !disabledModels.includes(m))} + /> +
+ { + const instanceId = r.instanceId ?? r.model; + return { + ...r, + instanceId, + label: instanceLabels.get(instanceId), + }; + })} + timestamp={streamingTimestampRef.current} + actionConfig={actionConfig} + /> +
+ + )} + {committedResponses.length > 0 && ( <> {/* Show persisted mode indicators for chained/routed messages */} {group.assistantResponses[0].modeMetadata?.mode === "routed" && ( @@ -543,7 +616,7 @@ export function ChatMessageList({
)} { + responses={committedResponses.map((m) => { // Use instanceId if set, otherwise fall back to model for backwards compat const instanceId = m.instanceId ?? m.model ?? "unknown"; return { @@ -560,6 +633,7 @@ export function ChatMessageList({ citations: m.citations, artifacts: m.artifacts, toolExecutionRounds: m.toolExecutionRounds, + completedRounds: m.completedRounds, debugMessageId: m.debugMessageId, }; })} @@ -587,92 +661,6 @@ export function ChatMessageList({
); })} - - {/* - STREAMING SECTION - Outside Virtualization - - Active streaming responses render here, positioned absolutely at the bottom. - This is intentionally outside the virtualized list because: - 1. Streaming content height changes constantly (every token) - 2. Virtualization re-measures heights, which would cause jank - 3. The streaming section should always be visible (no virtualization cutoff) - - The key={streamingSessionIdRef.current} ensures animation only plays once - per streaming session, not on every content update. - */} - {/* Show streaming section when we have streaming responses */} - {hasStreamingResponses && ( -
- {/* Routing decision indicator for routed mode */} - - {/* Chain progress indicator for chained mode */} - !disabledModels.includes(m))} - /> - {/* Synthesis progress indicator for synthesized mode */} - !disabledModels.includes(m))} - /> - {/* Refinement progress indicator for refined mode */} - - {/* Critique progress indicator for critiqued mode */} - - {/* Election progress indicator for elected mode */} - !disabledModels.includes(m))} - /> - {/* Tournament progress indicator for tournament mode */} - !disabledModels.includes(m))} - /> - {/* Consensus progress indicator for consensus mode */} - !disabledModels.includes(m))} - /> - {/* Debate progress indicator for debated mode */} - !disabledModels.includes(m))} - /> - {/* Council progress indicator for council mode */} - !disabledModels.includes(m))} - /> - {/* Hierarchical progress indicator for hierarchical mode */} - - {/* Scattershot progress indicator for scattershot mode */} - - {/* Explainer progress indicator for explainer mode */} - - {/* Confidence-weighted progress indicator for confidence-weighted mode */} - !disabledModels.includes(m))} - /> - {/* Key ensures animation only plays once per streaming session */} - {hasStreamingResponses && ( -
- { - // Use instanceId if set, otherwise fall back to model - const instanceId = r.instanceId ?? r.model; - return { - ...r, - instanceId, - label: instanceLabels.get(instanceId), - }; - })} - timestamp={streamingTimestampRef.current} - actionConfig={actionConfig} - /> -
- )} -
- )} )} diff --git a/ui/src/components/ChatView/ChatView.stories.tsx b/ui/src/components/ChatView/ChatView.stories.tsx index 7052de1..a033a38 100644 --- a/ui/src/components/ChatView/ChatView.stories.tsx +++ b/ui/src/components/ChatView/ChatView.stories.tsx @@ -100,23 +100,27 @@ const meta: Meta = { }, }, decorators: [ - (Story) => ( - - - - - - -
- -
-
-
-
-
-
-
- ), + (Story) => { + // Show reasoning & tools in tests + useChatUIStore.setState({ compactMode: false }); + return ( + + + + + + +
+ +
+
+
+
+
+
+
+ ); + }, ], }; diff --git a/ui/src/components/MultiModelResponse/ContentRound.stories.tsx b/ui/src/components/MultiModelResponse/ContentRound.stories.tsx new file mode 100644 index 0000000..f035ed4 --- /dev/null +++ b/ui/src/components/MultiModelResponse/ContentRound.stories.tsx @@ -0,0 +1,262 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { expect, within, fn } from "storybook/test"; +import { ContentRound } from "./ContentRound"; +import { PreferencesProvider } from "@/preferences/PreferencesProvider"; +import { useChatUIStore } from "@/stores/chatUIStore"; +import type { ToolExecutionRound, ToolExecution, Artifact } from "@/components/chat-types"; + +const makeExecution = ( + toolName: string, + status: ToolExecution["status"], + duration?: number, +): ToolExecution => ({ + id: `exec-${Math.random().toString(36).slice(2)}`, + toolName, + status, + startTime: Date.now() - (duration || 0), + endTime: status !== "running" ? Date.now() : undefined, + duration, + input: {}, + inputArtifacts: [], + outputArtifacts: [], + round: 1, +}); + +const makeRound = (round: number, executions: ToolExecution[]): ToolExecutionRound => ({ + round, + executions, + hasError: executions.some((e) => e.status === "error"), + totalDuration: executions.reduce((sum, e) => sum + (e.duration || 0), 0), +}); + +const makeArtifact = (id: string, title: string): Artifact => ({ + id, + type: "table", + title, + data: { + columns: [ + { key: "name", label: "Name" }, + { key: "value", label: "Value" }, + ], + rows: [ + { name: "Item 1", value: 100 }, + { name: "Item 2", value: 200 }, + ], + }, + role: "output", +}); + +const meta: Meta = { + title: "Chat/MultiModelResponse/ContentRound", + component: ContentRound, + parameters: { + layout: "padded", + }, + decorators: [ + (Story) => { + useChatUIStore.setState({ + compactMode: false, + viewMode: "grid", + expandedModel: null, + editingKey: null, + }); + return ( + +
+ +
+
+ ); + }, + ], +}; + +export default meta; +type Story = StoryObj; + +/** Basic text content renders markdown */ +export const TextOnly: Story = { + args: { + content: "Hello! This is a **bold** response with `inline code` and a list:\n\n- Item one\n- Item two", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/Hello!/)).toBeInTheDocument(); + }, +}; + +/** Reasoning section shown above content */ +export const WithReasoning: Story = { + args: { + reasoning: + "Let me think step by step...\n\n1. First consideration\n2. Second consideration", + reasoningTokenCount: 42, + content: "Based on my analysis, the answer is 42.", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/the answer is 42/)).toBeInTheDocument(); + // Reasoning section should be present (collapsed by default shows token count) + await expect(canvas.getByText(/42 tokens/)).toBeInTheDocument(); + }, +}; + +/** Tool execution summary bar with expand/collapse */ +export const WithToolExecution: Story = { + args: { + content: "I ran the code and got the results.", + toolExecutionRound: makeRound(1, [ + makeExecution("code_interpreter", "success", 1200), + makeExecution("file_search", "success", 300), + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/I ran the code/)).toBeInTheDocument(); + // Tool summary bar should be visible + await expect(canvas.getByText(/2 tools/i)).toBeInTheDocument(); + }, +}; + +/** Tool execution still in progress */ +export const ToolsStreaming: Story = { + args: { + content: "Running analysis...", + isStreaming: true, + toolExecutionRound: makeRound(1, [makeExecution("code_interpreter", "running")]), + isToolsStreaming: true, + }, +}; + +/** Display selection with inline layout */ +export const WithDisplayedArtifacts: Story = { + args: { + content: "Here are the results:", + displaySelection: { artifactIds: ["art-1", "art-2"], layout: "inline" }, + allOutputArtifacts: [makeArtifact("art-1", "Sales Data"), makeArtifact("art-2", "Revenue")], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/Here are the results/)).toBeInTheDocument(); + await expect(canvas.getByText("Sales Data")).toBeInTheDocument(); + await expect(canvas.getByText("Revenue")).toBeInTheDocument(); + }, +}; + +/** Display selection with gallery (grid) layout */ +export const GalleryLayout: Story = { + args: { + content: "Gallery view:", + displaySelection: { artifactIds: ["art-1", "art-2"], layout: "gallery" }, + allOutputArtifacts: [makeArtifact("art-1", "Chart A"), makeArtifact("art-2", "Chart B")], + }, +}; + +/** Full round: reasoning + content + tools + artifacts */ +export const FullRound: Story = { + args: { + reasoning: "Analyzing the data set...\n\nFound 3 relevant patterns.", + reasoningTokenCount: 128, + content: + "I analyzed the dataset and found interesting patterns. Here's a summary:\n\n```python\ndf.describe()\n```", + toolExecutionRound: makeRound(1, [makeExecution("code_interpreter", "success", 2400)]), + displaySelection: { artifactIds: ["art-1"], layout: "inline" }, + allOutputArtifacts: [makeArtifact("art-1", "Analysis Results")], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/interesting patterns/)).toBeInTheDocument(); + await expect(canvas.getByText("Analysis Results")).toBeInTheDocument(); + }, +}; + +/** Compact mode shows only content and artifacts */ +export const CompactMode: Story = { + decorators: [ + (Story) => { + useChatUIStore.setState({ compactMode: true }); + return ( + +
+ +
+
+ ); + }, + ], + args: { + reasoning: "This reasoning should be hidden in compact mode.", + reasoningTokenCount: 50, + content: "Only this content shows in compact mode.", + toolExecutionRound: makeRound(1, [makeExecution("code_interpreter", "success", 500)]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/Only this content shows/)).toBeInTheDocument(); + // Reasoning and tools should not be visible + await expect(canvas.queryByText(/50 tokens/)).not.toBeInTheDocument(); + await expect(canvas.queryByText(/1 tool/i)).not.toBeInTheDocument(); + }, +}; + +/** Compact mode with no content returns null (empty round) */ +export const CompactModeEmpty: Story = { + decorators: [ + (Story) => { + useChatUIStore.setState({ compactMode: true }); + return ( + +
+ +
+
+ ); + }, + ], + args: { + reasoning: "Only reasoning, no content.", + reasoningTokenCount: 30, + toolExecutionRound: makeRound(1, [makeExecution("code_interpreter", "success", 200)]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const wrapper = canvas.getByTestId("wrapper"); + // Component should render nothing — wrapper should be empty + await expect(wrapper.children.length).toBe(0); + }, +}; + +/** Empty round returns null */ +export const EmptyRound: Story = { + args: {}, + play: async ({ canvasElement }) => { + // No visible content + await expect(canvasElement.querySelector(".space-y-1")).not.toBeInTheDocument(); + }, +}; + +/** Streaming content with active cursor */ +export const StreamingContent: Story = { + args: { + content: "I'm currently generating this response and it's still being", + isStreaming: true, + }, +}; + +/** Reasoning streaming without content yet */ +export const ReasoningStreaming: Story = { + args: { + reasoning: "Hmm, let me think about this...", + isReasoningStreaming: true, + }, +}; + +/** Artifact click callback fires */ +export const ArtifactClickCallback: Story = { + args: { + content: "Check the results below.", + onArtifactClick: fn(), + displaySelection: { artifactIds: ["art-1"], layout: "inline" }, + allOutputArtifacts: [makeArtifact("art-1", "Clickable Artifact")], + }, +}; diff --git a/ui/src/components/MultiModelResponse/ContentRound.tsx b/ui/src/components/MultiModelResponse/ContentRound.tsx new file mode 100644 index 0000000..5db29dc --- /dev/null +++ b/ui/src/components/MultiModelResponse/ContentRound.tsx @@ -0,0 +1,131 @@ +import { memo, useState, useCallback, useMemo } from "react"; +import type { ToolExecutionRound, Artifact, DisplaySelectionData } from "@/components/chat-types"; +import { Artifact as ArtifactComponent } from "@/components/Artifact"; +import { ReasoningSection } from "@/components/ReasoningSection/ReasoningSection"; +import { StreamingMarkdown } from "@/components/StreamingMarkdown/StreamingMarkdown"; +import { ExecutionSummaryBar, ExecutionTimeline } from "@/components/ToolExecution"; +import { useCompactMode } from "@/stores/chatUIStore"; + +interface ContentRoundProps { + /** Round's reasoning content */ + reasoning?: string | null; + /** Round's text content */ + content?: string | null; + /** Whether text content is actively streaming */ + isStreaming?: boolean; + /** Whether reasoning is actively streaming */ + isReasoningStreaming?: boolean; + /** Token count for reasoning (shown in ReasoningSection) */ + reasoningTokenCount?: number; + /** Tool execution round for this round (if tools were called) */ + toolExecutionRound?: ToolExecutionRound; + /** Whether tool execution is still in progress for this round */ + isToolsStreaming?: boolean; + /** Artifact click handler for tool execution timeline */ + onArtifactClick?: (artifact: Artifact) => void; + /** Display selection if display_artifacts was called in this round */ + displaySelection?: DisplaySelectionData | null; + /** All output artifacts across all rounds (for resolving display selection IDs) */ + allOutputArtifacts?: Artifact[]; +} + +/** + * A single round of model output: reasoning → content → tool execution summary. + * + * Used in multi-round tool calling to render each iteration as a distinct block + * with consistent spacing, replacing raw `
` separators. + */ +function ContentRoundComponent({ + reasoning, + content, + isStreaming = false, + isReasoningStreaming = false, + reasoningTokenCount, + toolExecutionRound, + isToolsStreaming = false, + onArtifactClick, + displaySelection, + allOutputArtifacts, +}: ContentRoundProps) { + const [toolsExpanded, setToolsExpanded] = useState(false); + const handleToggleTools = useCallback(() => setToolsExpanded((p) => !p), []); + const compactMode = useCompactMode(); + + // Resolve display selection to actual artifacts + const displayedArtifacts = useMemo(() => { + if (!displaySelection?.artifactIds.length || !allOutputArtifacts) return []; + const displayed: Artifact[] = []; + for (const id of displaySelection.artifactIds) { + const artifact = allOutputArtifacts.find((a) => a.id === id); + if (artifact) displayed.push(artifact); + } + return displayed; + }, [displaySelection, allOutputArtifacts]); + + const hasContent = !!content?.trim(); + const hasReasoning = !!reasoning; + const hasTools = !!toolExecutionRound; + const hasDisplayedArtifacts = displayedArtifacts.length > 0; + + if (!hasContent && !hasReasoning && !hasTools && !hasDisplayedArtifacts) return null; + + if (compactMode) { + // Compact: show content + artifacts only; collapse reasoning/tool-only rounds + if (hasContent || hasDisplayedArtifacts) { + const layoutClass = + displaySelection?.layout === "gallery" ? "grid grid-cols-2 gap-3" : "space-y-3"; + return ( +
+ {hasContent && } + {hasDisplayedArtifacts && ( +
+ {displayedArtifacts.map((artifact) => ( + + ))} +
+ )} +
+ ); + } + // No content in compact mode — parent manages status indicators + return null; + } + + const layoutClass = + displaySelection?.layout === "gallery" ? "grid grid-cols-2 gap-3" : "space-y-3"; + + return ( +
+ {hasReasoning && ( + + )} + {hasContent && } + {hasTools && ( +
+ + {toolsExpanded && ( + + )} +
+ )} + {hasDisplayedArtifacts && ( +
+ {displayedArtifacts.map((artifact) => ( + + ))} +
+ )} +
+ ); +} + +export const ContentRound = memo(ContentRoundComponent); diff --git a/ui/src/components/MultiModelResponse/MultiModelResponse.stories.tsx b/ui/src/components/MultiModelResponse/MultiModelResponse.stories.tsx index fb1ca82..672b68a 100644 --- a/ui/src/components/MultiModelResponse/MultiModelResponse.stories.tsx +++ b/ui/src/components/MultiModelResponse/MultiModelResponse.stories.tsx @@ -4,7 +4,6 @@ import { MultiModelResponse } from "./MultiModelResponse"; import { PreferencesProvider } from "@/preferences/PreferencesProvider"; import { useChatUIStore } from "@/stores/chatUIStore"; import { useStreamingStore } from "@/stores/streamingStore"; -import type { ToolCallState } from "@/pages/chat/utils/toolCallParser"; const meta: Meta = { title: "Chat/MultiModelResponse", @@ -20,6 +19,7 @@ const meta: Meta = { viewMode: "grid", expandedModel: null, editingKey: null, // Reset editing state + compactMode: false, // Show reasoning & tools in tests }); // Reset streaming store to ensure isStreaming is false useStreamingStore.setState({ @@ -63,16 +63,13 @@ export const SingleResponse: Story = { canvas.getByText(/Hello! I'm Claude, an AI assistant made by Anthropic/) ).toBeInTheDocument(); - // Verify model name badge is shown (look for the styled badge element) - const modelBadge = canvasElement.querySelector('[class*="rounded-md"][class*="border"]'); + // Verify model name badge is shown (look for the styled badge with text-xs font-semibold) + const modelBadge = canvasElement.querySelector( + 'span[class*="rounded-md"][class*="font-semibold"]' + ); await expect(modelBadge).toBeInTheDocument(); await expect(modelBadge?.textContent).toContain("Claude"); - // Verify view toggle buttons are NOT shown for single response - // (toggle buttons have h-6 w-6 classes) - const toggleButtons = canvasElement.querySelectorAll('button[class*="h-6"][class*="w-6"]'); - await expect(toggleButtons.length).toBe(0); - // Verify "responses" count badge is NOT shown for single response const responsesBadge = canvas.queryByText(/\d+ responses/); await expect(responsesBadge).not.toBeInTheDocument(); @@ -115,8 +112,10 @@ export const MultipleResponses: Story = { const cards = canvasElement.querySelectorAll('[class*="shadow-sm"][class*="rounded-xl"]'); await expect(cards.length).toBe(2); - // Verify view toggle buttons are present for multi-response - const toggleButtons = canvasElement.querySelectorAll('button[class*="h-6"][class*="w-6"]'); + // Verify view toggle buttons are present for multi-response (grid + stacked inside toggle group) + const toggleGroup = canvasElement.querySelector('[class*="gap-0.5"][class*="rounded-md"]'); + await expect(toggleGroup).toBeInTheDocument(); + const toggleButtons = toggleGroup!.querySelectorAll("button"); await expect(toggleButtons.length).toBe(2); }, }; @@ -147,8 +146,8 @@ export const Streaming: Story = { // Verify first model shows partial content await expect(canvas.getByText(/I'm thinking about your question/i)).toBeInTheDocument(); - // Verify second model shows "Thinking..." indicator (empty content during streaming) - await expect(canvas.getByText("Thinking...")).toBeInTheDocument(); + // Verify second model shows "Thinking" indicator (empty content during streaming) + await expect(canvas.getByText("Thinking")).toBeInTheDocument(); // Verify typing indicator dots are present (the animated dots) const typingDots = canvasElement.querySelectorAll('[class*="animate-typing"]'); @@ -272,20 +271,12 @@ export const ViewModeToggle: Story = { groupId: "test-group-viewmode", }, play: async ({ canvasElement }) => { - const canvas = within(canvasElement); + // Find the grid/stacked toggle buttons inside the toggle group + const toggleGroup = canvasElement.querySelector('[class*="gap-0.5"][class*="rounded-md"]'); + await expect(toggleGroup).toBeInTheDocument(); + const toggleButtons = Array.from(toggleGroup!.querySelectorAll("button")); - // Find the view toggle buttons by their tooltip text - // Grid button (side by side) should be active by default - const buttons = canvas.getAllByRole("button"); - - // Find the grid/stacked toggle buttons (small icon buttons in the header) - const toggleButtons = buttons.filter((btn) => { - const hasGridIcon = btn.querySelector("svg"); - const isSmall = btn.className.includes("h-6") || btn.className.includes("w-6"); - return hasGridIcon && isSmall; - }); - - // Should have 2 toggle buttons + // Should have 2 toggle buttons (grid + stacked) await expect(toggleButtons.length).toBe(2); // In grid mode, cards should have basis-[min(500px,85vw)] class (horizontal layout) @@ -519,26 +510,7 @@ export const WithReasoningContent: Story = { }; /** - * Helper to create tool call state for stories - */ -function createToolCallState( - id: string, - name: string, - status: "pending" | "executing" | "completed" | "failed" -): ToolCallState { - return { - id, - callId: `call_${id}`, - name, - outputIndex: 0, - argumentsBuffer: '{"query": "test query"}', - status, - parsedArguments: { query: "test query" }, - }; -} - -/** - * Test: Tool call indicator shows when file_search is executing (no content yet) + * Test: Streaming with empty content shows Thinking indicator */ export const WithToolCallSearching: Story = { args: { @@ -547,54 +519,38 @@ export const WithToolCallSearching: Story = { model: "anthropic/claude-3-opus", content: "", isStreaming: true, + toolExecutionRounds: [ + { + round: 1, + executions: [ + { + id: "tc_1", + toolName: "file_search", + status: "running", + startTime: Date.now(), + input: { query: "test query" }, + inputArtifacts: [], + outputArtifacts: [], + round: 1, + }, + ], + }, + ], }, ], timestamp: new Date(), groupId: "test-group-toolcall", }, - decorators: [ - (Story) => { - // Set up streaming store with tool call state - const toolCallsMap = new Map(); - toolCallsMap.set("tc_1", createToolCallState("tc_1", "file_search", "executing")); - - useStreamingStore.setState({ - streams: new Map([ - [ - "anthropic/claude-3-opus", - { - model: "anthropic/claude-3-opus", - content: "", - reasoningContent: "", - isStreaming: true, - toolCalls: toolCallsMap, - }, - ], - ]), - isStreaming: true, - modeState: { mode: null }, - }); - - return ; - }, - ], play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // Verify tool call indicator is shown instead of "Thinking..." - await expect(canvas.getByText("Searching documents")).toBeInTheDocument(); - - // Verify the indicator has the correct role for accessibility - const statusElement = canvasElement.querySelector('[role="status"]'); - await expect(statusElement).toBeInTheDocument(); - - // Verify "Thinking..." is NOT shown when tool call is active - await expect(canvas.queryByText("Thinking...")).not.toBeInTheDocument(); + // While streaming with no content, shows Thinking indicator + await expect(canvas.getByText("Thinking")).toBeInTheDocument(); }, }; /** - * Test: Tool call indicator shows above content while tool is executing + * Test: Tool execution round with content shows both tool block and content */ export const WithToolCallAndContent: Story = { args: { @@ -603,102 +559,103 @@ export const WithToolCallAndContent: Story = { model: "anthropic/claude-3-opus", content: "Based on my search of your documents, I found the following...", isStreaming: true, + toolExecutionRounds: [ + { + round: 1, + executions: [ + { + id: "tc_2", + toolName: "file_search", + status: "success", + startTime: Date.now() - 1000, + endTime: Date.now(), + duration: 1000, + input: { query: "test query" }, + inputArtifacts: [], + outputArtifacts: [], + round: 1, + }, + ], + }, + ], }, ], timestamp: new Date(), groupId: "test-group-toolcall-content", }, - decorators: [ - (Story) => { - // Set up streaming store with tool call state and content - const toolCallsMap = new Map(); - toolCallsMap.set("tc_2", createToolCallState("tc_2", "file_search", "executing")); - - useStreamingStore.setState({ - streams: new Map([ - [ - "anthropic/claude-3-opus", - { - model: "anthropic/claude-3-opus", - content: "Based on my search of your documents, I found the following...", - reasoningContent: "", - isStreaming: true, - toolCalls: toolCallsMap, - }, - ], - ]), - isStreaming: true, - modeState: { mode: null }, - }); - - return ; - }, - ], play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // Verify both tool call indicator and content are shown - await expect(canvas.getByText("Searching documents")).toBeInTheDocument(); + // Verify content is shown await expect(canvas.getByText(/Based on my search of your documents/i)).toBeInTheDocument(); - // Verify indicator appears above content (has margin-bottom) - const indicator = canvasElement.querySelector('[role="status"]'); - await expect(indicator?.className).toContain("mb-3"); + // Verify tool execution block is rendered + await expect(canvas.getByText("File Search")).toBeInTheDocument(); }, }; /** - * Test: Multiple tool calls shown in indicator + * Test: Multiple tool calls shown in execution rounds */ export const WithMultipleToolCalls: Story = { args: { responses: [ { model: "anthropic/claude-3-opus", - content: "", - isStreaming: true, + content: "Here are the results from my research...", + isStreaming: false, + toolExecutionRounds: [ + { + round: 1, + executions: [ + { + id: "tc_3", + toolName: "file_search", + status: "success", + startTime: Date.now() - 2000, + endTime: Date.now() - 1000, + duration: 1000, + input: { query: "test query" }, + inputArtifacts: [], + outputArtifacts: [], + round: 1, + }, + { + id: "tc_4", + toolName: "web_search", + status: "success", + startTime: Date.now() - 1000, + endTime: Date.now(), + duration: 1000, + input: { query: "web query" }, + inputArtifacts: [], + outputArtifacts: [], + round: 1, + }, + ], + }, + ], }, ], timestamp: new Date(), groupId: "test-group-multi-toolcall", }, - decorators: [ - (Story) => { - // Set up streaming store with multiple tool calls - const toolCallsMap = new Map(); - toolCallsMap.set("tc_3", createToolCallState("tc_3", "file_search", "executing")); - toolCallsMap.set("tc_4", createToolCallState("tc_4", "web_search", "pending")); - - useStreamingStore.setState({ - streams: new Map([ - [ - "anthropic/claude-3-opus", - { - model: "anthropic/claude-3-opus", - content: "", - reasoningContent: "", - isStreaming: true, - toolCalls: toolCallsMap, - }, - ], - ]), - isStreaming: true, - modeState: { mode: null }, - }); - - return ; - }, - ], play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // Verify both tool calls are shown - await expect(canvas.getByText("Searching documents")).toBeInTheDocument(); - await expect(canvas.getByText("Searching web")).toBeInTheDocument(); + // Verify content is shown + await expect(canvas.getByText(/Here are the results/i)).toBeInTheDocument(); + + // Verify tool execution summary bar shows "2 tools" (collapsed by default when not streaming) + await expect(canvas.getByText(/2 tools/)).toBeInTheDocument(); + + // Click the summary bar to expand and show individual tool names + const summaryBar = canvas.getByText(/2 tools/); + await userEvent.click(summaryBar); - // Verify summary shows running/queued counts - await expect(canvas.getByText(/1 running/i)).toBeInTheDocument(); - await expect(canvas.getByText(/1 queued/i)).toBeInTheDocument(); + // Verify both tool names are now visible in the expanded timeline + await expect(canvas.getByText("File Search")).toBeInTheDocument(); + await expect(canvas.getByText("Web Search")).toBeInTheDocument(); }, }; @@ -721,7 +678,6 @@ export const WithHideCallback: Story = { ], timestamp: new Date(), groupId: "test-group-hide", - onFeedback: fn(), onSelectBest: fn(), onRegenerate: fn(), onHide: fn(), diff --git a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx index 7cb3b61..1a391c2 100644 --- a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx +++ b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx @@ -8,6 +8,8 @@ import { Eye, EyeOff, GitFork, + MessageSquare, + MessageSquarePlus, Loader2, MoreHorizontal, Pencil, @@ -36,11 +38,13 @@ import { QuoteSelectionPopover } from "@/components/QuoteSelectionPopover"; import { ToolExecutionBlock } from "@/components/ToolExecution"; import type { Artifact as ArtifactType, DisplaySelectionData } from "@/components/chat-types"; import { useDebugInfo } from "@/stores/debugStore"; +import { ContentRound } from "./ContentRound"; import { Avatar, AvatarFallback } from "@/components/Avatar/Avatar"; import { Button } from "@/components/Button/Button"; import type { Artifact, + CompletedRound, HistoryMode, MessageModeMetadata, MessageUsage, @@ -56,11 +60,6 @@ import { ResponseActions, type ResponseActionConfig as ActionConfig, } from "@/components/ResponseActions/ResponseActions"; -import { - ToolCallIndicator, - type ToolCall, - type ToolCallType, -} from "@/components/ToolCallIndicator"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/Tooltip/Tooltip"; import { UsageDisplay } from "@/components/UsageDisplay/UsageDisplay"; import { @@ -71,56 +70,26 @@ import { DropdownSeparator, } from "@/components/Dropdown/Dropdown"; import { Textarea } from "@/components/Textarea/Textarea"; -import { useViewMode, useExpandedModel, useChatUIStore, useIsEditing } from "@/stores/chatUIStore"; +import { + useViewMode, + useExpandedModel, + useChatUIStore, + useIsEditing, + useCompactMode, +} from "@/stores/chatUIStore"; import type { PlaybackState } from "@/hooks/useAudioPlayback"; import { useTTSForResponse } from "@/hooks/useTTSManager"; import { - usePendingToolCalls, useCitations, useArtifacts, useToolExecutionRounds, useIsStreaming, - type ToolCallState, + useHasActiveToolCalls, } from "@/stores/streamingStore"; import { cn } from "@/utils/cn"; import { getModelDisplayName } from "@/utils/modelNames"; import { getModelStyle } from "@/utils/providers"; -/** - * Convert tool name to ToolCallType for UI display - */ -function mapToolNameToType(name: string): ToolCallType { - switch (name) { - case "file_search": - return "file_search"; - case "web_search": - return "web_search"; - case "code_interpreter": - return "code_interpreter"; - case "js_code_interpreter": - return "js_code_interpreter"; - case "sql_query": - return "sql_query"; - case "chart_render": - return "chart_render"; - default: - return "function"; - } -} - -/** - * Convert ToolCallState from streaming store to ToolCall for indicator display - */ -function convertToolCallStateToToolCall(state: ToolCallState): ToolCall { - return { - id: state.id, - type: mapToolNameToType(state.name), - name: state.name, - status: state.status, - error: state.error, - }; -} - /** * MultiModelResponse - Renders Multiple Model Responses with Layout Options * @@ -189,8 +158,10 @@ interface ModelResponse { */ label?: string; content: string; - /** Reasoning content (extended thinking) */ + /** Reasoning content for current/last round (extended thinking) */ reasoningContent?: string; + /** Completed rounds bundling reasoning, content, and tool execution (multi-round tool execution) */ + completedRounds?: CompletedRound[]; isStreaming: boolean; error?: string; usage?: MessageUsage; @@ -241,12 +212,116 @@ interface MultiModelResponseProps { historyMode?: HistoryMode; } -function TypingIndicator() { +/** + * Streaming phase for a model response. + * - "idle": not streaming or content is actively flowing + * - "thinking": waiting for network, model reasoning, or content stalled + * - "processing": tool calls are executing + */ +type StreamingPhase = "idle" | "thinking" | "processing"; + +/** Detect when streaming content has stalled (no new tokens for a threshold period). */ +function useContentStalled(content: string, isStreaming: boolean, thresholdMs = 1500): boolean { + const [stalled, setStalled] = useState(false); + + useEffect(() => { + if (!isStreaming || !content) { + setStalled(false); + return; + } + setStalled(false); + const timer = setTimeout(() => setStalled(true), thresholdMs); + return () => clearTimeout(timer); + }, [content, isStreaming, thresholdMs]); + + return stalled; +} + +/** + * Compute the streaming phase for a model response. Centralises all status + * logic so the rendering layer has a single value to check. + */ +function useStreamingPhase( + response: { + isStreaming: boolean; + content: string; + reasoningContent?: string; + completedRounds?: CompletedRound[]; + }, + hasActiveTools: boolean, + compactMode: boolean +): StreamingPhase { + const isStalled = useContentStalled(response.content, response.isStreaming); + + if (!response.isStreaming) return "idle"; + + const hasContent = !!response.content?.trim(); + const hasReasoning = !!response.reasoningContent; + const rounds = response.completedRounds; + const hasRounds = !!rounds?.length; + + // No output at all yet — waiting for first token + if (!hasContent && !hasReasoning && !hasRounds) { + return "thinking"; + } + + // Multi-round: check whether the current (in-flight) round has content yet + if (hasRounds) { + const currentReasoning = + hasReasoning && !rounds!.some((r) => r.reasoning === response.reasoningContent) + ? response.reasoningContent + : null; + const currentContent = hasContent ? response.content : null; + + // Between rounds — no new content flowing + if (!currentReasoning && !currentContent) { + return hasActiveTools ? "processing" : "thinking"; + } + + // Current round content stalled + if (currentContent && isStalled) { + return hasActiveTools ? "processing" : "thinking"; + } + + // Compact mode hides reasoning — show thinking when only reasoning is flowing + if (compactMode && currentReasoning && !currentContent) { + return "thinking"; + } + + return "idle"; + } + + // Single-round: reasoning streaming but no content yet + // (non-compact shows ReasoningSection which has its own indicator) + if (hasReasoning && !hasContent) { + return compactMode ? "thinking" : "idle"; + } + + // Content stalled + if (isStalled) { + return hasActiveTools ? "processing" : "thinking"; + } + + return "idle"; +} + +const PHASE_LABEL: Record = { + idle: "", + thinking: "Thinking", + processing: "Processing", +}; + +/** Animated dots + label shown when the model is thinking or processing. */ +function StreamingStatusIndicator({ phase }: { phase: StreamingPhase }) { + if (phase === "idle") return null; return ( -
- - - +
+ {PHASE_LABEL[phase]} +
+ + + +
); } @@ -471,6 +546,9 @@ const ModelResponseCard = memo(function ModelResponseCard({ const style = getModelStyle(model); const isComplete = !response.isStreaming && response.content && !response.error; const isAnyStreaming = useIsStreaming(); + const compactMode = useCompactMode(); + const hasActiveTools = useHasActiveToolCalls(model); + const streamingPhase = useStreamingPhase(response, hasActiveTools, compactMode); // State for artifact modal const [selectedArtifact, setSelectedArtifact] = useState(null); @@ -587,14 +665,6 @@ const ModelResponseCard = memo(function ModelResponseCard({ stop: handleStopSpeaking, } = useTTSForResponse(response.content, groupId, instanceId); - // Get pending tool calls for this model (for client-side RAG indicator) - const pendingToolCallStates = usePendingToolCalls(model); - const toolCalls = useMemo( - () => pendingToolCallStates.map(convertToolCallStateToToolCall), - [pendingToolCallStates] - ); - const hasActiveToolCalls = toolCalls.length > 0; - // Get citations from streaming store (for active/recent streams) or from response props const streamingCitations = useCitations(model); const citations = useMemo(() => { @@ -617,13 +687,6 @@ const ModelResponseCard = memo(function ModelResponseCard({ }, [streamingArtifacts, response.artifacts]); const hasArtifacts = artifacts.length > 0; - // Extract display selection from artifacts (if model called display_artifacts) - const displaySelection = useMemo((): DisplaySelectionData | null => { - const selectionArtifact = artifacts.find((a) => a.type === "display_selection"); - if (!selectionArtifact) return null; - return selectionArtifact.data as DisplaySelectionData; - }, [artifacts]); - // Get tool execution rounds from streaming store (for active/recent streams) or from response props const streamingToolExecutionRounds = useToolExecutionRounds(model); const toolExecutionRounds = useMemo(() => { @@ -635,6 +698,52 @@ const ModelResponseCard = memo(function ModelResponseCard({ }, [streamingToolExecutionRounds, response.toolExecutionRounds]); const hasToolExecutionRounds = toolExecutionRounds.length > 0; + // All output artifacts across all rounds (for resolving display_artifacts selections) + const allOutputArtifacts = useMemo(() => { + const result: ArtifactType[] = []; + // Check completedRounds first (unified source for multi-round) + const rounds = response.completedRounds ?? []; + for (const round of rounds) { + if (round.toolExecution) { + for (const execution of round.toolExecution.executions) { + for (const a of execution.outputArtifacts) { + if (a.type !== "display_selection") result.push(a); + } + } + } + } + // Also check standalone toolExecutionRounds (single-round path) + for (const round of toolExecutionRounds) { + for (const execution of round.executions) { + for (const a of execution.outputArtifacts) { + if (a.type !== "display_selection") result.push(a); + } + } + } + return result; + }, [response.completedRounds, toolExecutionRounds]); + + // Extract display selection for a specific tool execution round + const getDisplaySelectionForRound = useCallback( + (round: ToolExecutionRound): DisplaySelectionData | null => { + for (const execution of round.executions) { + if (execution.toolName === "display_artifacts") { + const sel = execution.outputArtifacts.find((a) => a.type === "display_selection"); + if (sel) return sel.data as DisplaySelectionData; + } + } + return null; + }, + [] + ); + + // Global display selection (for single-round fallback) + const displaySelection = useMemo((): DisplaySelectionData | null => { + const selectionArtifact = artifacts.find((a) => a.type === "display_selection"); + if (!selectionArtifact) return null; + return selectionArtifact.data as DisplaySelectionData; + }, [artifacts]); + // Measure header width to determine if we should collapse controls const headerRef = useRef(null); const [isCollapsed, setIsCollapsed] = useState(false); @@ -790,79 +899,145 @@ const ModelResponseCard = memo(function ModelResponseCard({ {response.error}
- ) : response.isStreaming && !response.content && !response.reasoningContent ? ( - // Show tool call indicator or typing indicator during initial streaming - hasActiveToolCalls ? ( - - ) : ( -
- - Thinking... + ) : isEditing ? ( +
+