From 592df5e6914ee2e08cbcec232673ee66507d7196 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Sat, 21 Mar 2026 14:08:16 +1000
Subject: [PATCH 01/23] Fix overwriting final round text

---
 ui/src/pages/chat/useChat.ts | 22 +++++++++++++++++-----
 1 file changed, 17 insertions(+), 5 deletions(-)

diff --git a/ui/src/pages/chat/useChat.ts b/ui/src/pages/chat/useChat.ts
index eb8bc3cc..33d9b2a4 100644
--- a/ui/src/pages/chat/useChat.ts
+++ b/ui/src/pages/chat/useChat.ts
@@ -202,6 +202,8 @@ const MAX_TOOL_ITERATIONS = 5;
 /** Result from streaming a response, including any tool calls */
 interface StreamResponseResult {
   content: string;
+  /** Whether any output_text deltas were received (vs reasoning-only fallback) */
+  hasOutputText: boolean;
   usage?: MessageUsage;
   reasoningContent?: string;
   /** Tool calls detected during streaming (only when clientSideToolExecution is enabled) */
@@ -800,6 +802,7 @@ export function useChat({
         let usage: MessageUsage | undefined;
         // Fallback: extract tool calls from response.completed if not captured during streaming
         let completedToolCalls: ParsedToolCall[] = [];
+        let hasOutputText = false;
         // Capture response output for debugging
         let responseOutput: unknown[] | undefined;
 
@@ -856,6 +859,7 @@ export function useChat({
 
                 // Handle different Responses API event types
                 if (event.type === "response.output_text.delta" && event.delta) {
+                  hasOutputText = true;
                   content += event.delta;
                   streamingStore.appendContent(storeKey, event.delta);
                 } else if (
@@ -1129,6 +1133,7 @@ export function useChat({
 
         return {
           content,
+          hasOutputText,
           usage,
           reasoningContent: reasoningContent || undefined,
           toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
@@ -1312,6 +1317,7 @@ export function useChat({
             ? null
             : {
                 content: accumulatedContent,
+                hasOutputText: true,
                 usage: accumulatedUsage,
                 reasoningContent: lastReasoningContent,
                 toolExecutionRounds: executionRounds.length > 0 ? executionRounds : undefined,
@@ -1329,10 +1335,9 @@ export function useChat({
         }
 
         // Accumulate content across rounds with separator.
-        // Skip reasoning-only rounds (where content was set from reasoning fallback
-        // rather than actual output text — e.g., rounds that only call display_artifacts).
-        const isActualOutput = result.content && result.content !== result.reasoningContent;
-        if (isActualOutput) {
+        // Only include rounds that had actual text output (output_text deltas),
+        // skipping reasoning-only rounds (e.g., rounds that only call display_artifacts).
+        if (result.hasOutputText) {
           if (accumulatedContent) {
             accumulatedContent += "\n\n---\n\n" + result.content;
           } else {
@@ -1554,11 +1559,17 @@ export function useChat({
         // builds on top of the accumulated content with a visual break.
         // Only add if this round had actual text output (avoid double separators
         // from rounds that only had tool calls with no text).
-        if (isActualOutput) {
+        if (result.hasOutputText) {
           streamingStore.appendContent(storeKey, "\n\n---\n\n");
         }
       }
 
+      // Sync streaming store with final accumulated content (removes any
+      // trailing separators and ensures consistency with committed content)
+      if (iterations > 1) {
+        streamingStore.setContent(storeKey, accumulatedContent);
+      }
+
       // Complete debug capture successfully
       if (messageId) {
         debugStore.completeDebugCapture(messageId, model, true);
@@ -1566,6 +1577,7 @@ export function useChat({
 
       return {
         content: accumulatedContent,
+        hasOutputText: true,
         usage: accumulatedUsage,
         reasoningContent: lastReasoningContent,
         toolExecutionRounds: executionRounds.length > 0 ? executionRounds : undefined,

From a54706b9996b8a71df151c04a9d7adfff8cadeda Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Sat, 21 Mar 2026 15:16:41 +1000
Subject: [PATCH 02/23] Separate reasoning blocks

---
 .../MultiModelResponse/MultiModelResponse.tsx | 81 ++++++++++++++++---
 ui/src/components/chat-types.ts               |  4 +-
 ui/src/pages/chat/useChat.ts                  | 14 ++++
 ui/src/stores/streamingStore.ts               | 21 ++++-
 4 files changed, 107 insertions(+), 13 deletions(-)

diff --git a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
index 7cb3b617..32f4b9dc 100644
--- a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
+++ b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
@@ -189,8 +189,10 @@ interface ModelResponse {
    */
   label?: string;
   content: string;
-  /** Reasoning content (extended thinking) */
+  /** Reasoning content for current/last round (extended thinking) */
   reasoningContent?: string;
+  /** Completed rounds' reasoning and content for multi-round tool execution */
+  completedRounds?: Array<{ reasoning?: string; content?: string }>;
   isStreaming: boolean;
   error?: string;
   usage?: MessageUsage;
@@ -804,15 +806,7 @@ const ModelResponseCard = memo(function ModelResponseCard({
           <>
             {/* Tool call indicator (shown above content when tools are executing) */}
             {hasActiveToolCalls && <ToolCallIndicator toolCalls={toolCalls} className="mb-3" />}
-            {/* Reasoning section (extended thinking) */}
-            {(response.reasoningContent || response.usage?.reasoningContent) && (
-              <ReasoningSection
-                content={response.reasoningContent || response.usage?.reasoningContent || ""}
-                isStreaming={response.isStreaming && !response.content}
-                tokenCount={response.usage?.reasoningTokens}
-              />
-            )}
-            {/* Main response content */}
+            {/* Reasoning + content — interleaved per round for multi-round, or single block */}
             {isEditing ? (
               <div className="flex flex-col gap-3">
                 <Textarea
@@ -844,7 +838,71 @@ const ModelResponseCard = memo(function ModelResponseCard({
                 </div>
               </div>
             ) : (
-              <StreamingMarkdown content={response.content} isStreaming={response.isStreaming} />
+              (() => {
+                const rounds = response.completedRounds ?? response.usage?.completedRounds;
+                if (rounds && rounds.length > 0) {
+                  // Multi-round: render each round's reasoning + content, interleaved
+                  // Extract current streaming round's content (not yet in completedRounds)
+                  const parts = response.content ? response.content.split("\n\n---\n\n") : [];
+                  const roundsWithContent = rounds.filter((r) => r.content).length;
+                  const currentContent =
+                    parts.length > roundsWithContent ? parts[parts.length - 1] : null;
+                  const currentReasoning =
+                    response.reasoningContent &&
+                    !rounds.some((r) => r.reasoning === response.reasoningContent)
+                      ? response.reasoningContent
+                      : null;
+                  return (
+                    <>
+                      {rounds.map((round, i) => (
+                        <div key={i}>
+                          {i > 0 && <hr className="my-4 border-border" />}
+                          {round.reasoning && <ReasoningSection content={round.reasoning} />}
+                          {round.content && (
+                            <StreamingMarkdown content={round.content} isStreaming={false} />
+                          )}
+                        </div>
+                      ))}
+                      {/* Current streaming round (not yet in completedRounds) */}
+                      {(currentReasoning || currentContent) && (
+                        <div>
+                          <hr className="my-4 border-border" />
+                          {currentReasoning && (
+                            <ReasoningSection
+                              content={currentReasoning}
+                              isStreaming={response.isStreaming && !currentContent}
+                            />
+                          )}
+                          {currentContent && (
+                            <StreamingMarkdown
+                              content={currentContent}
+                              isStreaming={response.isStreaming}
+                            />
+                          )}
+                        </div>
+                      )}
+                    </>
+                  );
+                }
+                // Single-round: reasoning then content
+                const singleReasoning =
+                  response.reasoningContent || response.usage?.reasoningContent;
+                return (
+                  <>
+                    {singleReasoning && (
+                      <ReasoningSection
+                        content={singleReasoning}
+                        isStreaming={response.isStreaming && !response.content}
+                        tokenCount={response.usage?.reasoningTokens}
+                      />
+                    )}
+                    <StreamingMarkdown
+                      content={response.content}
+                      isStreaming={response.isStreaming}
+                    />
+                  </>
+                );
+              })()
             )}
             {/* Citations from file_search/web_search */}
             {hasCitations && (
@@ -1312,6 +1370,7 @@ function areMultiModelResponsePropsEqual(
     if (prevR.label !== nextR.label) return false;
     if (prevR.content !== nextR.content) return false;
     if (prevR.reasoningContent !== nextR.reasoningContent) return false;
+    if ((prevR.completedRounds?.length ?? 0) !== (nextR.completedRounds?.length ?? 0)) return false;
     if (prevR.isStreaming !== nextR.isStreaming) return false;
     if (prevR.error !== nextR.error) return false;
     if (prevR.usage?.totalTokens !== nextR.usage?.totalTokens) return false;
diff --git a/ui/src/components/chat-types.ts b/ui/src/components/chat-types.ts
index a1f5355b..033297c1 100644
--- a/ui/src/components/chat-types.ts
+++ b/ui/src/components/chat-types.ts
@@ -396,8 +396,10 @@ export interface MessageUsage {
   cachedTokens?: number;
   /** Reasoning tokens count (if applicable) */
   reasoningTokens?: number;
-  /** Reasoning content (extended thinking output) */
+  /** Reasoning content (extended thinking output — last/only round) */
   reasoningContent?: string;
+  /** Per-round reasoning and content for multi-round tool execution */
+  completedRounds?: Array<{ reasoning?: string; content?: string }>;
 
   // Timing stats (captured client-side during streaming)
   /** Time to first token in milliseconds (from request start) */
diff --git a/ui/src/pages/chat/useChat.ts b/ui/src/pages/chat/useChat.ts
index 33d9b2a4..cd4e4433 100644
--- a/ui/src/pages/chat/useChat.ts
+++ b/ui/src/pages/chat/useChat.ts
@@ -206,6 +206,8 @@ interface StreamResponseResult {
   hasOutputText: boolean;
   usage?: MessageUsage;
   reasoningContent?: string;
+  /** Per-round reasoning and content for multi-round tool execution */
+  completedRounds?: Array<{ reasoning?: string; content?: string }>;
   /** Tool calls detected during streaming (only when clientSideToolExecution is enabled) */
   toolCalls?: ParsedToolCall[];
   /** Tool execution timeline for progressive disclosure UI */
@@ -1279,6 +1281,7 @@ export function useChat({
       let accumulatedContent = "";
       let accumulatedUsage: MessageUsage | undefined;
       let lastReasoningContent: string | undefined;
+      const allCompletedRounds: Array<{ reasoning?: string; content?: string }> = [];
       let iterations = 0;
 
       // Track execution rounds locally (also mirrored in store for real-time UI)
@@ -1320,6 +1323,7 @@ export function useChat({
                 hasOutputText: true,
                 usage: accumulatedUsage,
                 reasoningContent: lastReasoningContent,
+                completedRounds: allCompletedRounds.length > 0 ? allCompletedRounds : undefined,
                 toolExecutionRounds: executionRounds.length > 0 ? executionRounds : undefined,
               };
         }
@@ -1334,6 +1338,14 @@ export function useChat({
           }
         }
 
+        // Track per-round data for interleaved reasoning/content display
+        const roundData: { reasoning?: string; content?: string } = {};
+        if (result.reasoningContent) roundData.reasoning = result.reasoningContent;
+        if (result.hasOutputText) roundData.content = result.content;
+        allCompletedRounds.push(roundData);
+        // Push to streaming store so the UI can render interleaved rounds during streaming
+        streamingStore.pushCompletedRound(storeKey, roundData);
+
         // Accumulate content across rounds with separator.
         // Only include rounds that had actual text output (output_text deltas),
         // skipping reasoning-only rounds (e.g., rounds that only call display_artifacts).
@@ -1358,6 +1370,7 @@ export function useChat({
               reasoningTokens:
                 (accumulatedUsage.reasoningTokens ?? 0) + (result.usage.reasoningTokens ?? 0),
               reasoningContent: lastReasoningContent,
+              completedRounds: allCompletedRounds.length > 1 ? [...allCompletedRounds] : undefined,
             };
           } else {
             accumulatedUsage = { ...result.usage };
@@ -1580,6 +1593,7 @@ export function useChat({
         hasOutputText: true,
         usage: accumulatedUsage,
         reasoningContent: lastReasoningContent,
+        completedRounds: allCompletedRounds.length > 1 ? allCompletedRounds : undefined,
         toolExecutionRounds: executionRounds.length > 0 ? executionRounds : undefined,
       };
     },
diff --git a/ui/src/stores/streamingStore.ts b/ui/src/stores/streamingStore.ts
index f635886e..1dd985b3 100644
--- a/ui/src/stores/streamingStore.ts
+++ b/ui/src/stores/streamingStore.ts
@@ -70,8 +70,10 @@ export interface StreamingResponse {
    */
   instanceId?: string;
   content: string;
-  /** Reasoning content (extended thinking) */
+  /** Reasoning content for the current round (extended thinking) */
   reasoningContent: string;
+  /** Completed rounds' reasoning and content (for multi-round tool execution) */
+  completedRounds: Array<{ reasoning?: string; content?: string }>;
   isStreaming: boolean;
   error?: string;
   usage?: MessageUsage;
@@ -713,6 +715,8 @@ interface StreamingActions {
   appendReasoningContent: (instanceId: string, delta: string) => void;
   /** Set the full reasoning content for an instance */
   setReasoningContent: (instanceId: string, content: string) => void;
+  /** Push a completed round's reasoning and content, then reset reasoningContent */
+  pushCompletedRound: (instanceId: string, round: { reasoning?: string; content?: string }) => void;
   /** Mark an instance's stream as complete */
   completeStream: (instanceId: string, usage?: MessageUsage) => void;
   /** Set an error for an instance's stream */
@@ -813,6 +817,7 @@ export const useStreamingStore = create<StreamingStore>((set) => ({
           instanceId,
           content: "",
           reasoningContent: "",
+          completedRounds: [],
           isStreaming: true,
           startTime,
         });
@@ -878,6 +883,20 @@ export const useStreamingStore = create<StreamingStore>((set) => ({
       return { streams: newStreams };
     }),
 
+  pushCompletedRound: (model, round) =>
+    set((state) => {
+      const existing = state.streams.get(model);
+      if (!existing) return state;
+
+      const newStreams = new Map(state.streams);
+      newStreams.set(model, {
+        ...existing,
+        completedRounds: [...existing.completedRounds, round],
+        reasoningContent: "",
+      });
+      return { streams: newStreams };
+    }),
+
   completeStream: (model, usage) =>
     set((state) => {
       const existing = state.streams.get(model);

From 9436b8f09727339760b2209d33c2c3f1e18b4e4a Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Sat, 21 Mar 2026 15:31:39 +1000
Subject: [PATCH 03/23] Fix double spacing

---
 .../MultiModelResponse/MultiModelResponse.tsx |  4 +--
 ui/src/pages/chat/useChat.ts                  | 28 +++++++++++--------
 2 files changed, 18 insertions(+), 14 deletions(-)

diff --git a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
index 32f4b9dc..8fa86cc2 100644
--- a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
+++ b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
@@ -856,9 +856,9 @@ const ModelResponseCard = memo(function ModelResponseCard({
                     <>
                       {rounds.map((round, i) => (
                         <div key={i}>
-                          {i > 0 && <hr className="my-4 border-border" />}
+                          {i > 0 && round.content?.trim() && <hr className="my-4 border-border" />}
                           {round.reasoning && <ReasoningSection content={round.reasoning} />}
-                          {round.content && (
+                          {round.content?.trim() && (
                             <StreamingMarkdown content={round.content} isStreaming={false} />
                           )}
                         </div>
diff --git a/ui/src/pages/chat/useChat.ts b/ui/src/pages/chat/useChat.ts
index cd4e4433..39dd91dc 100644
--- a/ui/src/pages/chat/useChat.ts
+++ b/ui/src/pages/chat/useChat.ts
@@ -986,10 +986,11 @@ export function useChat({
                   const outputText =
                     event.response.output_text ||
                     event.response.output
-                      ?.flatMap((item) =>
-                        item.content
-                          ?.filter((c) => c.type === "output_text")
-                          .map((c) => c.text || "")
+                      ?.flatMap(
+                        (item) =>
+                          item.content
+                            ?.filter((c) => c.type === "output_text")
+                            .map((c) => c.text || "") ?? []
                       )
                       .join("\n\n---\n\n");
 
@@ -1338,18 +1339,21 @@ export function useChat({
           }
         }
 
-        // Track per-round data for interleaved reasoning/content display
+        // Track per-round data for interleaved reasoning/content display.
+        // Only count content as meaningful if it has non-whitespace text —
+        // models sometimes emit trivial whitespace before tool calls.
+        const hasNonTrivialContent = result.hasOutputText && !!result.content?.trim();
         const roundData: { reasoning?: string; content?: string } = {};
         if (result.reasoningContent) roundData.reasoning = result.reasoningContent;
-        if (result.hasOutputText) roundData.content = result.content;
+        if (hasNonTrivialContent) roundData.content = result.content;
         allCompletedRounds.push(roundData);
         // Push to streaming store so the UI can render interleaved rounds during streaming
         streamingStore.pushCompletedRound(storeKey, roundData);
 
         // Accumulate content across rounds with separator.
-        // Only include rounds that had actual text output (output_text deltas),
-        // skipping reasoning-only rounds (e.g., rounds that only call display_artifacts).
-        if (result.hasOutputText) {
+        // Only include rounds that had non-trivial text output,
+        // skipping reasoning-only rounds and whitespace-only rounds.
+        if (hasNonTrivialContent) {
           if (accumulatedContent) {
             accumulatedContent += "\n\n---\n\n" + result.content;
           } else {
@@ -1570,9 +1574,9 @@ export function useChat({
 
         // Add separator to streaming store so the next round's appendContent
         // builds on top of the accumulated content with a visual break.
-        // Only add if this round had actual text output (avoid double separators
-        // from rounds that only had tool calls with no text).
-        if (result.hasOutputText) {
+        // Only add if this round had non-trivial text output (avoid double separators
+        // from rounds that only had tool calls or whitespace-only text).
+        if (hasNonTrivialContent) {
           streamingStore.appendContent(storeKey, "\n\n---\n\n");
         }
       }

From 97ab20230a5522b763ef18f9ff1e9c08098d78d5 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Sat, 21 Mar 2026 15:40:45 +1000
Subject: [PATCH 04/23] Separate ContentRound component

---
 .../MultiModelResponse/ContentRound.tsx       | 78 ++++++++++++++++++
 .../MultiModelResponse/MultiModelResponse.tsx | 81 +++++++++++--------
 2 files changed, 124 insertions(+), 35 deletions(-)
 create mode 100644 ui/src/components/MultiModelResponse/ContentRound.tsx

diff --git a/ui/src/components/MultiModelResponse/ContentRound.tsx b/ui/src/components/MultiModelResponse/ContentRound.tsx
new file mode 100644
index 00000000..65326909
--- /dev/null
+++ b/ui/src/components/MultiModelResponse/ContentRound.tsx
@@ -0,0 +1,78 @@
+import { memo, useState, useCallback } from "react";
+import type { ToolExecutionRound, Artifact } from "@/components/chat-types";
+import { ReasoningSection } from "@/components/ReasoningSection/ReasoningSection";
+import { StreamingMarkdown } from "@/components/StreamingMarkdown/StreamingMarkdown";
+import { ExecutionSummaryBar, ExecutionTimeline } from "@/components/ToolExecution";
+
+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;
+}
+
+/**
+ * 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 `<hr>` separators.
+ */
+function ContentRoundComponent({
+  reasoning,
+  content,
+  isStreaming = false,
+  isReasoningStreaming = false,
+  reasoningTokenCount,
+  toolExecutionRound,
+  isToolsStreaming = false,
+  onArtifactClick,
+}: ContentRoundProps) {
+  const [toolsExpanded, setToolsExpanded] = useState(false);
+  const handleToggleTools = useCallback(() => setToolsExpanded((p) => !p), []);
+
+  const hasContent = !!content?.trim();
+  const hasReasoning = !!reasoning;
+  const hasTools = !!toolExecutionRound;
+
+  if (!hasContent && !hasReasoning && !hasTools) return null;
+
+  return (
+    <div className="space-y-1">
+      {hasReasoning && (
+        <ReasoningSection
+          content={reasoning!}
+          isStreaming={isReasoningStreaming}
+          tokenCount={reasoningTokenCount}
+        />
+      )}
+      {hasContent && <StreamingMarkdown content={content!} isStreaming={isStreaming} />}
+      {hasTools && (
+        <div className="mt-1.5">
+          <ExecutionSummaryBar
+            rounds={[toolExecutionRound!]}
+            isExpanded={toolsExpanded}
+            onToggle={handleToggleTools}
+            isStreaming={isToolsStreaming}
+          />
+          {toolsExpanded && (
+            <ExecutionTimeline rounds={[toolExecutionRound!]} onArtifactClick={onArtifactClick} />
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+export const ContentRound = memo(ContentRoundComponent);
diff --git a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
index 8fa86cc2..dc1e7640 100644
--- a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
+++ b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
@@ -36,6 +36,7 @@ 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";
@@ -841,8 +842,7 @@ const ModelResponseCard = memo(function ModelResponseCard({
               (() => {
                 const rounds = response.completedRounds ?? response.usage?.completedRounds;
                 if (rounds && rounds.length > 0) {
-                  // Multi-round: render each round's reasoning + content, interleaved
-                  // Extract current streaming round's content (not yet in completedRounds)
+                  // Multi-round: render each round as a ContentRound with per-round tool execution
                   const parts = response.content ? response.content.split("\n\n---\n\n") : [];
                   const roundsWithContent = rounds.filter((r) => r.content).length;
                   const currentContent =
@@ -853,35 +853,31 @@ const ModelResponseCard = memo(function ModelResponseCard({
                       ? response.reasoningContent
                       : null;
                   return (
-                    <>
+                    <div className="space-y-3">
                       {rounds.map((round, i) => (
-                        <div key={i}>
-                          {i > 0 && round.content?.trim() && <hr className="my-4 border-border" />}
-                          {round.reasoning && <ReasoningSection content={round.reasoning} />}
-                          {round.content?.trim() && (
-                            <StreamingMarkdown content={round.content} isStreaming={false} />
-                          )}
-                        </div>
+                        <ContentRound
+                          key={i}
+                          reasoning={round.reasoning}
+                          content={round.content}
+                          toolExecutionRound={
+                            i < toolExecutionRounds.length ? toolExecutionRounds[i] : undefined
+                          }
+                          isToolsStreaming={
+                            response.isStreaming && i === toolExecutionRounds.length - 1
+                          }
+                          onArtifactClick={handleArtifactClick}
+                        />
                       ))}
                       {/* Current streaming round (not yet in completedRounds) */}
                       {(currentReasoning || currentContent) && (
-                        <div>
-                          <hr className="my-4 border-border" />
-                          {currentReasoning && (
-                            <ReasoningSection
-                              content={currentReasoning}
-                              isStreaming={response.isStreaming && !currentContent}
-                            />
-                          )}
-                          {currentContent && (
-                            <StreamingMarkdown
-                              content={currentContent}
-                              isStreaming={response.isStreaming}
-                            />
-                          )}
-                        </div>
+                        <ContentRound
+                          reasoning={currentReasoning}
+                          content={currentContent}
+                          isStreaming={response.isStreaming}
+                          isReasoningStreaming={response.isStreaming && !currentContent}
+                        />
                       )}
-                    </>
+                    </div>
                   );
                 }
                 // Single-round: reasoning then content
@@ -908,16 +904,31 @@ const ModelResponseCard = memo(function ModelResponseCard({
             {hasCitations && (
               <CitationList citations={citations} className="mt-4 pt-4 border-t" compact={false} />
             )}
-            {/* Tool execution timeline with progressive disclosure, or fallback to flat artifact list */}
+            {/* Tool execution / artifact display */}
             {hasToolExecutionRounds ? (
-              <div className="mt-4 pt-4 border-t">
-                <ToolExecutionBlock
-                  rounds={toolExecutionRounds}
-                  isStreaming={response.isStreaming}
-                  onArtifactClick={handleArtifactClick}
-                  displaySelection={displaySelection}
-                />
-              </div>
+              // Multi-round with completedRounds: per-round tool execution is rendered above.
+              // Only show ToolExecutionBlock for display-selected artifacts or single-round fallback.
+              (response.completedRounds ?? response.usage?.completedRounds) ? (
+                displaySelection && (
+                  <div className="mt-4 pt-4 border-t">
+                    <ToolExecutionBlock
+                      rounds={toolExecutionRounds}
+                      isStreaming={response.isStreaming}
+                      onArtifactClick={handleArtifactClick}
+                      displaySelection={displaySelection}
+                    />
+                  </div>
+                )
+              ) : (
+                <div className="mt-4 pt-4 border-t">
+                  <ToolExecutionBlock
+                    rounds={toolExecutionRounds}
+                    isStreaming={response.isStreaming}
+                    onArtifactClick={handleArtifactClick}
+                    displaySelection={displaySelection}
+                  />
+                </div>
+              )
             ) : (
               hasArtifacts && <ArtifactList artifacts={artifacts} className="mt-4 pt-4 border-t" />
             )}

From 112fe8297a9a6c353120db18e853d15ba54bf4ff Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Sat, 21 Mar 2026 15:53:21 +1000
Subject: [PATCH 05/23] Vertical border per-round

---
 ui/src/components/MultiModelResponse/ContentRound.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/ui/src/components/MultiModelResponse/ContentRound.tsx b/ui/src/components/MultiModelResponse/ContentRound.tsx
index 65326909..b0d65d06 100644
--- a/ui/src/components/MultiModelResponse/ContentRound.tsx
+++ b/ui/src/components/MultiModelResponse/ContentRound.tsx
@@ -49,7 +49,7 @@ function ContentRoundComponent({
   if (!hasContent && !hasReasoning && !hasTools) return null;
 
   return (
-    <div className="space-y-1">
+    <div className="space-y-1 border-l-2 border-zinc-200 pl-3 dark:border-zinc-700">
       {hasReasoning && (
         <ReasoningSection
           content={reasoning!}

From 79f1399e7da710c7ec9cf580d3e870c420c8636c Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Sat, 21 Mar 2026 16:05:21 +1000
Subject: [PATCH 06/23] Display artifacts within rounds

---
 .../MultiModelResponse/ContentRound.tsx       | 35 ++++++-
 .../MultiModelResponse/MultiModelResponse.tsx | 98 +++++++++++--------
 .../ToolExecution/ToolExecutionStep.tsx       | 37 ++++++-
 ui/src/pages/chat/useChat.ts                  |  5 +-
 4 files changed, 130 insertions(+), 45 deletions(-)

diff --git a/ui/src/components/MultiModelResponse/ContentRound.tsx b/ui/src/components/MultiModelResponse/ContentRound.tsx
index b0d65d06..130562eb 100644
--- a/ui/src/components/MultiModelResponse/ContentRound.tsx
+++ b/ui/src/components/MultiModelResponse/ContentRound.tsx
@@ -1,5 +1,6 @@
-import { memo, useState, useCallback } from "react";
-import type { ToolExecutionRound, Artifact } from "@/components/chat-types";
+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";
@@ -21,6 +22,10 @@ interface ContentRoundProps {
   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[];
 }
 
 /**
@@ -38,15 +43,32 @@ function ContentRoundComponent({
   toolExecutionRound,
   isToolsStreaming = false,
   onArtifactClick,
+  displaySelection,
+  allOutputArtifacts,
 }: ContentRoundProps) {
   const [toolsExpanded, setToolsExpanded] = useState(false);
   const handleToggleTools = useCallback(() => setToolsExpanded((p) => !p), []);
 
+  // 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 (!hasContent && !hasReasoning && !hasTools) return null;
+  const layoutClass =
+    displaySelection?.layout === "gallery" ? "grid grid-cols-2 gap-3" : "space-y-3";
 
   return (
     <div className="space-y-1 border-l-2 border-zinc-200 pl-3 dark:border-zinc-700">
@@ -71,6 +93,13 @@ function ContentRoundComponent({
           )}
         </div>
       )}
+      {hasDisplayedArtifacts && (
+        <div className={layoutClass}>
+          {displayedArtifacts.map((artifact) => (
+            <ArtifactComponent key={artifact.id} artifact={artifact} />
+          ))}
+        </div>
+      )}
     </div>
   );
 }
diff --git a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
index dc1e7640..0fa825a4 100644
--- a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
+++ b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
@@ -620,13 +620,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(() => {
@@ -638,6 +631,40 @@ 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[] = [];
+    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;
+  }, [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<HTMLDivElement>(null);
   const [isCollapsed, setIsCollapsed] = useState(false);
@@ -854,20 +881,24 @@ const ModelResponseCard = memo(function ModelResponseCard({
                       : null;
                   return (
                     <div className="space-y-3">
-                      {rounds.map((round, i) => (
-                        <ContentRound
-                          key={i}
-                          reasoning={round.reasoning}
-                          content={round.content}
-                          toolExecutionRound={
-                            i < toolExecutionRounds.length ? toolExecutionRounds[i] : undefined
-                          }
-                          isToolsStreaming={
-                            response.isStreaming && i === toolExecutionRounds.length - 1
-                          }
-                          onArtifactClick={handleArtifactClick}
-                        />
-                      ))}
+                      {rounds.map((round, i) => {
+                        const ter =
+                          i < toolExecutionRounds.length ? toolExecutionRounds[i] : undefined;
+                        return (
+                          <ContentRound
+                            key={i}
+                            reasoning={round.reasoning}
+                            content={round.content}
+                            toolExecutionRound={ter}
+                            isToolsStreaming={
+                              response.isStreaming && i === toolExecutionRounds.length - 1
+                            }
+                            onArtifactClick={handleArtifactClick}
+                            displaySelection={ter ? getDisplaySelectionForRound(ter) : null}
+                            allOutputArtifacts={allOutputArtifacts}
+                          />
+                        );
+                      })}
                       {/* Current streaming round (not yet in completedRounds) */}
                       {(currentReasoning || currentContent) && (
                         <ContentRound
@@ -905,11 +936,10 @@ const ModelResponseCard = memo(function ModelResponseCard({
               <CitationList citations={citations} className="mt-4 pt-4 border-t" compact={false} />
             )}
             {/* Tool execution / artifact display */}
-            {hasToolExecutionRounds ? (
-              // Multi-round with completedRounds: per-round tool execution is rendered above.
-              // Only show ToolExecutionBlock for display-selected artifacts or single-round fallback.
-              (response.completedRounds ?? response.usage?.completedRounds) ? (
-                displaySelection && (
+            {hasToolExecutionRounds
+              ? // Multi-round with completedRounds: per-round tool execution + artifacts rendered above.
+                // Only show ToolExecutionBlock for single-round fallback (no completedRounds).
+                !(response.completedRounds ?? response.usage?.completedRounds) && (
                   <div className="mt-4 pt-4 border-t">
                     <ToolExecutionBlock
                       rounds={toolExecutionRounds}
@@ -919,19 +949,9 @@ const ModelResponseCard = memo(function ModelResponseCard({
                     />
                   </div>
                 )
-              ) : (
-                <div className="mt-4 pt-4 border-t">
-                  <ToolExecutionBlock
-                    rounds={toolExecutionRounds}
-                    isStreaming={response.isStreaming}
-                    onArtifactClick={handleArtifactClick}
-                    displaySelection={displaySelection}
-                  />
-                </div>
-              )
-            ) : (
-              hasArtifacts && <ArtifactList artifacts={artifacts} className="mt-4 pt-4 border-t" />
-            )}
+              : hasArtifacts && (
+                  <ArtifactList artifacts={artifacts} className="mt-4 pt-4 border-t" />
+                )}
           </>
         )}
       </div>
diff --git a/ui/src/components/ToolExecution/ToolExecutionStep.tsx b/ui/src/components/ToolExecution/ToolExecutionStep.tsx
index d8bd0a99..1acda31d 100644
--- a/ui/src/components/ToolExecution/ToolExecutionStep.tsx
+++ b/ui/src/components/ToolExecution/ToolExecutionStep.tsx
@@ -11,9 +11,15 @@ import {
   Braces,
   Database,
   BarChart3,
+  Eye,
   Maximize2,
 } from "lucide-react";
-import type { ToolExecution, Artifact, CodeArtifactData } from "@/components/chat-types";
+import type {
+  ToolExecution,
+  Artifact,
+  CodeArtifactData,
+  DisplaySelectionData,
+} from "@/components/chat-types";
 import { cn } from "@/utils/cn";
 import { Artifact as ArtifactComponent } from "@/components/Artifact";
 import { HighlightedCode } from "@/components/HighlightedCode/HighlightedCode";
@@ -35,6 +41,7 @@ const TOOL_ICONS: Record<string, typeof Terminal> = {
   js_code_interpreter: Braces,
   sql_query: Database,
   chart_render: BarChart3,
+  display_artifacts: Eye,
 };
 
 /** Tool name to display name mapping */
@@ -45,6 +52,14 @@ const TOOL_NAMES: Record<string, string> = {
   js_code_interpreter: "JavaScript",
   sql_query: "SQL Query",
   chart_render: "Chart",
+  display_artifacts: "Display",
+};
+
+/** Layout mode labels */
+const LAYOUT_LABELS: Record<string, string> = {
+  inline: "inline",
+  gallery: "gallery",
+  stacked: "stacked",
 };
 
 /** Tool name to language mapping for syntax highlighting */
@@ -122,6 +137,18 @@ function ToolExecutionStepComponent({
     [execution.inputArtifacts]
   );
 
+  // Extract display selection metadata for display_artifacts tool
+  const displayMeta = useMemo(() => {
+    if (execution.toolName !== "display_artifacts") return null;
+    const sel = execution.outputArtifacts.find((a) => a.type === "display_selection");
+    if (!sel) return null;
+    const data = sel.data as DisplaySelectionData;
+    return {
+      count: data.artifactIds.length,
+      layout: LAYOUT_LABELS[data.layout] || data.layout,
+    };
+  }, [execution.toolName, execution.outputArtifacts]);
+
   return (
     <div className="relative pl-5">
       {/* Timeline connector line */}
@@ -164,6 +191,14 @@ function ToolExecutionStepComponent({
           )}
         </div>
 
+        {/* Display artifacts metadata */}
+        {displayMeta && (
+          <div className="mt-1 text-[11px] text-zinc-500 dark:text-zinc-400">
+            Showing {displayMeta.count} {displayMeta.count === 1 ? "artifact" : "artifacts"}
+            {displayMeta.layout !== "inline" && <> &middot; {displayMeta.layout}</>}
+          </div>
+        )}
+
         {/* Inline code preview - always visible */}
         {inlineCode && (
           <div className="mt-1.5">
diff --git a/ui/src/pages/chat/useChat.ts b/ui/src/pages/chat/useChat.ts
index 39dd91dc..6416d63f 100644
--- a/ui/src/pages/chat/useChat.ts
+++ b/ui/src/pages/chat/useChat.ts
@@ -558,9 +558,10 @@ export function useChat({
             name: "display_artifacts",
             description:
               "After executing tools that produce outputs (code, charts, tables, images), " +
-              "call this to select which artifacts to display prominently to the user. " +
+              "call this to select which artifacts to display prominently to the user inline at this point in the conversation. " +
               "Artifacts not selected will be available in a collapsed 'more outputs' section. " +
-              "Always call this after your tool executions complete to curate the user's view. " +
+              "Call this each time you have outputs to show rather than waiting until the end — " +
+              "artifacts appear where you call this function, so call it right after the relevant tools complete. " +
               "Choose the most relevant and interesting outputs - typically final results rather than intermediate steps.",
             parameters: {
               type: "object",

From 9dbc160bf948f8c5e45e3d9ed37d1d7feca51676 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Sat, 21 Mar 2026 16:52:47 +1000
Subject: [PATCH 07/23] Don't jump to bottom at end

---
 .../ChatMessageList/ChatMessageList.tsx       | 146 +++++++-----------
 1 file changed, 55 insertions(+), 91 deletions(-)

diff --git a/ui/src/components/ChatMessageList/ChatMessageList.tsx b/ui/src/components/ChatMessageList/ChatMessageList.tsx
index 931c3366..b533d9f1 100644
--- a/ui/src/components/ChatMessageList/ChatMessageList.tsx
+++ b/ui/src/components/ChatMessageList/ChatMessageList.tsx
@@ -347,15 +347,14 @@ export function ChatMessageList({
             <div
               className="relative"
               style={{
-                // Use max of virtualizer size and estimated size to prevent layout jumps
-                height:
-                  Math.max(virtualizer.getTotalSize(), messageGroups.length * 200) +
-                  (hasStreamingResponses ? 200 : 0),
+                height: Math.max(virtualizer.getTotalSize(), messageGroups.length * 200),
               }}
             >
-              {/* Virtualized message groups */}
               {virtualizer.getVirtualItems().map((virtualItem) => {
                 const group = messageGroups[virtualItem.index];
+                const isLastGroup = virtualItem.index === messageGroups.length - 1;
+                const showStreaming =
+                  isLastGroup && hasStreamingResponses && group.assistantResponses.length === 0;
                 return (
                   <div
                     key={group.id}
@@ -370,6 +369,57 @@ export function ChatMessageList({
                       onSaveEdit={onEditAndRerun}
                       onRegenerate={onRegenerateAll}
                     />
+                    {showStreaming && (
+                      <>
+                        <RoutingDecision />
+                        <ChainProgress
+                          models={selectedModels.filter((m) => !disabledModels.includes(m))}
+                        />
+                        <SynthesisProgress
+                          allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
+                        />
+                        <RefinementProgress />
+                        <CritiqueProgress />
+                        <ElectedProgress
+                          allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
+                        />
+                        <TournamentProgress
+                          allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
+                        />
+                        <ConsensusProgress
+                          allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
+                        />
+                        <DebateProgress
+                          allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
+                        />
+                        <CouncilProgress
+                          allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
+                        />
+                        <HierarchicalProgress />
+                        <ScattershotProgress />
+                        <ExplainerProgress />
+                        <ConfidenceProgress
+                          allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
+                        />
+                        <div
+                          key={streamingSessionIdRef.current}
+                          className="animate-slide-up-bounce"
+                        >
+                          <MultiModelResponse
+                            responses={filteredModelResponses.map((r) => {
+                              const instanceId = r.instanceId ?? r.model;
+                              return {
+                                ...r,
+                                instanceId,
+                                label: instanceLabels.get(instanceId),
+                              };
+                            })}
+                            timestamp={streamingTimestampRef.current}
+                            actionConfig={actionConfig}
+                          />
+                        </div>
+                      </>
+                    )}
                     {group.assistantResponses.length > 0 && (
                       <>
                         {/* Show persisted mode indicators for chained/routed messages */}
@@ -587,92 +637,6 @@ export function ChatMessageList({
                   </div>
                 );
               })}
-
-              {/*
-              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 && (
-                <div
-                  className="absolute left-0 right-0"
-                  style={{
-                    // Use virtualizer total size, with fallback to estimated size for unmeasured groups
-                    transform: `translateY(${Math.max(virtualizer.getTotalSize(), messageGroups.length * 200)}px)`,
-                  }}
-                >
-                  {/* Routing decision indicator for routed mode */}
-                  <RoutingDecision />
-                  {/* Chain progress indicator for chained mode */}
-                  <ChainProgress
-                    models={selectedModels.filter((m) => !disabledModels.includes(m))}
-                  />
-                  {/* Synthesis progress indicator for synthesized mode */}
-                  <SynthesisProgress
-                    allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
-                  />
-                  {/* Refinement progress indicator for refined mode */}
-                  <RefinementProgress />
-                  {/* Critique progress indicator for critiqued mode */}
-                  <CritiqueProgress />
-                  {/* Election progress indicator for elected mode */}
-                  <ElectedProgress
-                    allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
-                  />
-                  {/* Tournament progress indicator for tournament mode */}
-                  <TournamentProgress
-                    allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
-                  />
-                  {/* Consensus progress indicator for consensus mode */}
-                  <ConsensusProgress
-                    allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
-                  />
-                  {/* Debate progress indicator for debated mode */}
-                  <DebateProgress
-                    allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
-                  />
-                  {/* Council progress indicator for council mode */}
-                  <CouncilProgress
-                    allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
-                  />
-                  {/* Hierarchical progress indicator for hierarchical mode */}
-                  <HierarchicalProgress />
-                  {/* Scattershot progress indicator for scattershot mode */}
-                  <ScattershotProgress />
-                  {/* Explainer progress indicator for explainer mode */}
-                  <ExplainerProgress />
-                  {/* Confidence-weighted progress indicator for confidence-weighted mode */}
-                  <ConfidenceProgress
-                    allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
-                  />
-                  {/* Key ensures animation only plays once per streaming session */}
-                  {hasStreamingResponses && (
-                    <div key={streamingSessionIdRef.current} className="animate-slide-up-bounce">
-                      <MultiModelResponse
-                        responses={filteredModelResponses.map((r) => {
-                          // 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}
-                      />
-                    </div>
-                  )}
-                </div>
-              )}
             </div>
           )}
         </div>

From de05d7f794bd46163a05ddd04d4215dca718bda8 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Sat, 21 Mar 2026 17:00:43 +1000
Subject: [PATCH 08/23] Fix virtualizer pushing scroll down

---
 ui/src/components/ChatMessageList/ChatMessageList.tsx | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/ui/src/components/ChatMessageList/ChatMessageList.tsx b/ui/src/components/ChatMessageList/ChatMessageList.tsx
index b533d9f1..e48ddf6c 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);
 

From 408d3d3fc57d84595febe11ba62018535c45bfcc Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Sat, 21 Mar 2026 17:23:17 +1000
Subject: [PATCH 09/23] Remove tool call indicator

---
 .../MultiModelResponse/MultiModelResponse.tsx |  65 +-----
 .../ToolCallIndicator.stories.tsx             | 101 ---------
 .../ToolCallIndicator/ToolCallIndicator.tsx   | 198 ------------------
 ui/src/components/ToolCallIndicator/index.ts  |   6 -
 ui/src/components/ToolIcons/ToolIcons.tsx     |  22 +-
 ui/src/components/ToolIcons/index.ts          |   1 +
 ui/src/pages/chat/useChat.ts                  |   2 +
 ui/src/pages/chat/utils/toolCallParser.ts     |  25 ++-
 8 files changed, 49 insertions(+), 371 deletions(-)
 delete mode 100644 ui/src/components/ToolCallIndicator/ToolCallIndicator.stories.tsx
 delete mode 100644 ui/src/components/ToolCallIndicator/ToolCallIndicator.tsx
 delete mode 100644 ui/src/components/ToolCallIndicator/index.ts

diff --git a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
index 0fa825a4..504f894a 100644
--- a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
+++ b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
@@ -57,11 +57,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 {
@@ -76,52 +71,15 @@ import { useViewMode, useExpandedModel, useChatUIStore, useIsEditing } from "@/s
 import type { PlaybackState } from "@/hooks/useAudioPlayback";
 import { useTTSForResponse } from "@/hooks/useTTSManager";
 import {
-  usePendingToolCalls,
   useCitations,
   useArtifacts,
   useToolExecutionRounds,
   useIsStreaming,
-  type ToolCallState,
 } 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
  *
@@ -590,14 +548,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(() => {
@@ -821,19 +771,12 @@ const ModelResponseCard = memo(function ModelResponseCard({
             <span className="text-sm leading-relaxed">{response.error}</span>
           </div>
         ) : response.isStreaming && !response.content && !response.reasoningContent ? (
-          // Show tool call indicator or typing indicator during initial streaming
-          hasActiveToolCalls ? (
-            <ToolCallIndicator toolCalls={toolCalls} />
-          ) : (
-            <div className="flex items-center gap-3 text-muted-foreground">
-              <TypingIndicator />
-              <span className="text-sm">Thinking...</span>
-            </div>
-          )
+          <div className="flex items-center gap-3 text-muted-foreground">
+            <TypingIndicator />
+            <span className="text-sm">Thinking...</span>
+          </div>
         ) : (
           <>
-            {/* Tool call indicator (shown above content when tools are executing) */}
-            {hasActiveToolCalls && <ToolCallIndicator toolCalls={toolCalls} className="mb-3" />}
             {/* Reasoning + content — interleaved per round for multi-round, or single block */}
             {isEditing ? (
               <div className="flex flex-col gap-3">
diff --git a/ui/src/components/ToolCallIndicator/ToolCallIndicator.stories.tsx b/ui/src/components/ToolCallIndicator/ToolCallIndicator.stories.tsx
deleted file mode 100644
index 92120f61..00000000
--- a/ui/src/components/ToolCallIndicator/ToolCallIndicator.stories.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react";
-
-import { ToolCallIndicator, type ToolCall } from "./ToolCallIndicator";
-
-const meta: Meta<typeof ToolCallIndicator> = {
-  title: "Components/ToolCallIndicator",
-  component: ToolCallIndicator,
-  parameters: {
-    layout: "centered",
-  },
-  decorators: [
-    (Story) => (
-      <div className="w-[500px]">
-        <Story />
-      </div>
-    ),
-  ],
-};
-
-export default meta;
-type Story = StoryObj<typeof ToolCallIndicator>;
-
-// Single tool call states
-export const FileSearchExecuting: Story = {
-  args: {
-    toolCalls: [{ id: "1", type: "file_search", status: "executing" }],
-  },
-};
-
-export const FileSearchPending: Story = {
-  args: {
-    toolCalls: [{ id: "1", type: "file_search", status: "pending" }],
-  },
-};
-
-export const FileSearchCompleted: Story = {
-  args: {
-    toolCalls: [{ id: "1", type: "file_search", status: "completed" }],
-  },
-};
-
-export const FileSearchFailed: Story = {
-  args: {
-    toolCalls: [{ id: "1", type: "file_search", status: "failed", error: "timeout" }],
-  },
-};
-
-export const WebSearchExecuting: Story = {
-  args: {
-    toolCalls: [{ id: "1", type: "web_search", status: "executing" }],
-  },
-};
-
-export const CodeInterpreterExecuting: Story = {
-  args: {
-    toolCalls: [{ id: "1", type: "code_interpreter", status: "executing" }],
-  },
-};
-
-export const FunctionCallExecuting: Story = {
-  args: {
-    toolCalls: [{ id: "1", type: "function", name: "get_weather", status: "executing" }],
-  },
-};
-
-// Multiple tool calls
-export const MultipleToolCalls: Story = {
-  args: {
-    toolCalls: [
-      { id: "1", type: "file_search", status: "completed" },
-      { id: "2", type: "web_search", status: "executing" },
-      { id: "3", type: "code_interpreter", status: "pending" },
-    ] satisfies ToolCall[],
-  },
-};
-
-export const AllExecuting: Story = {
-  args: {
-    toolCalls: [
-      { id: "1", type: "file_search", status: "executing" },
-      { id: "2", type: "web_search", status: "executing" },
-    ] satisfies ToolCall[],
-  },
-};
-
-export const MixedWithFailure: Story = {
-  args: {
-    toolCalls: [
-      { id: "1", type: "file_search", status: "completed" },
-      { id: "2", type: "web_search", status: "failed", error: "rate limit" },
-      { id: "3", type: "function", name: "analyze", status: "executing" },
-    ] satisfies ToolCall[],
-  },
-};
-
-// Empty state
-export const Empty: Story = {
-  args: {
-    toolCalls: [],
-  },
-};
diff --git a/ui/src/components/ToolCallIndicator/ToolCallIndicator.tsx b/ui/src/components/ToolCallIndicator/ToolCallIndicator.tsx
deleted file mode 100644
index 159a6a80..00000000
--- a/ui/src/components/ToolCallIndicator/ToolCallIndicator.tsx
+++ /dev/null
@@ -1,198 +0,0 @@
-import { Loader2, Database } from "lucide-react";
-
-import { cn } from "@/utils/cn";
-import { PythonIcon, JavaScriptIcon, SqlIcon, getToolIcon } from "@/components/ToolIcons";
-
-/** Types of tool calls that can be displayed */
-export type ToolCallType =
-  | "file_search"
-  | "web_search"
-  | "code_interpreter"
-  | "js_code_interpreter"
-  | "sql_query"
-  | "chart_render"
-  | "function";
-
-/** Status of a tool call execution */
-export type ToolCallStatus = "pending" | "executing" | "completed" | "failed";
-
-/** Represents a single tool call being executed */
-export interface ToolCall {
-  /** Unique identifier for this tool call */
-  id: string;
-  /** Type of tool being called */
-  type: ToolCallType;
-  /** Tool function name (for function type) */
-  name?: string;
-  /** Current execution status */
-  status: ToolCallStatus;
-  /** Error message if failed */
-  error?: string;
-}
-
-interface ToolCallIndicatorProps {
-  /** List of tool calls being executed */
-  toolCalls: ToolCall[];
-  /** Optional class name for the container */
-  className?: string;
-}
-
-/**
- * Get the display configuration for a tool type
- * Uses shared icons from ToolIcons for consistency across UI
- */
-function getToolConfig(type: ToolCallType, name?: string) {
-  switch (type) {
-    case "file_search":
-      return {
-        icon: getToolIcon("file_search"),
-        label: "Searching documents",
-        color: "text-blue-500",
-        bgColor: "bg-blue-500/10",
-      };
-    case "web_search":
-      return {
-        icon: getToolIcon("web_search"),
-        label: "Searching web",
-        color: "text-green-500",
-        bgColor: "bg-green-500/10",
-      };
-    case "code_interpreter":
-      return {
-        icon: PythonIcon,
-        label: "Running Python",
-        color: "text-orange-500",
-        bgColor: "bg-orange-500/10",
-      };
-    case "js_code_interpreter":
-      return {
-        icon: JavaScriptIcon,
-        label: "Running JavaScript",
-        color: "text-yellow-500",
-        bgColor: "bg-yellow-500/10",
-      };
-    case "sql_query":
-      return {
-        icon: SqlIcon,
-        label: "Running SQL",
-        color: "text-cyan-500",
-        bgColor: "bg-cyan-500/10",
-      };
-    case "chart_render":
-      return {
-        icon: getToolIcon("chart_render"),
-        label: "Rendering chart",
-        color: "text-emerald-500",
-        bgColor: "bg-emerald-500/10",
-      };
-    case "function":
-      return {
-        icon: Database,
-        label: name ? `Calling ${name}` : "Calling function",
-        color: "text-purple-500",
-        bgColor: "bg-purple-500/10",
-      };
-  }
-}
-
-/** Get the status icon/animation for a tool call */
-function ToolCallStatusIcon({ status, type }: { status: ToolCallStatus; type: ToolCallType }) {
-  const config = getToolConfig(type);
-  const Icon = config.icon;
-
-  if (status === "executing") {
-    return <Loader2 className={cn("h-4 w-4 animate-spin", config.color)} />;
-  }
-
-  return (
-    <Icon
-      className={cn("h-4 w-4", status === "completed" ? "text-muted-foreground" : config.color)}
-    />
-  );
-}
-
-/**
- * ToolCallIndicator - Visual indicator for tool call execution
- *
- * Displays the status of tool calls being executed during chat interactions.
- * Used in client-side RAG mode where the UI orchestrates tool execution.
- *
- * ## When to Use
- *
- * Show this component when:
- * - The model has requested a tool call (file_search, web_search, etc.)
- * - The UI is executing the tool call
- * - Waiting for tool results before continuing the response
- *
- * ## Usage Example
- *
- * ```tsx
- * <ToolCallIndicator
- *   toolCalls={[
- *     { id: "1", type: "file_search", status: "executing" },
- *     { id: "2", type: "web_search", status: "pending" },
- *   ]}
- * />
- * ```
- */
-export function ToolCallIndicator({ toolCalls, className }: ToolCallIndicatorProps) {
-  if (toolCalls.length === 0) {
-    return null;
-  }
-
-  // Group by status for display
-  const executing = toolCalls.filter((tc) => tc.status === "executing");
-  const pending = toolCalls.filter((tc) => tc.status === "pending");
-  const failed = toolCalls.filter((tc) => tc.status === "failed");
-
-  const activeCount = executing.length + pending.length;
-  const hasFailures = failed.length > 0;
-
-  return (
-    <div
-      className={cn(
-        "flex flex-wrap items-center gap-2 px-3 py-2 bg-muted/50 rounded-lg border border-border/50",
-        className
-      )}
-      role="status"
-      aria-live="polite"
-      aria-label={`${activeCount} tool call${activeCount !== 1 ? "s" : ""} in progress`}
-    >
-      {/* Show each tool call */}
-      {toolCalls.map((toolCall) => {
-        const config = getToolConfig(toolCall.type, toolCall.name);
-
-        return (
-          <div
-            key={toolCall.id}
-            className={cn(
-              "flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium transition-colors",
-              toolCall.status === "executing" && config.bgColor,
-              toolCall.status === "executing" && "animate-pulse",
-              toolCall.status === "pending" && "bg-muted text-muted-foreground",
-              toolCall.status === "completed" && "bg-muted/50 text-muted-foreground",
-              toolCall.status === "failed" && "bg-destructive/10 text-destructive"
-            )}
-          >
-            <ToolCallStatusIcon status={toolCall.status} type={toolCall.type} />
-            <span>{config.label}</span>
-            {toolCall.status === "executing" && <span className="sr-only">in progress</span>}
-            {toolCall.status === "failed" && toolCall.error && (
-              <span className="text-[10px] ml-1">({toolCall.error})</span>
-            )}
-          </div>
-        );
-      })}
-
-      {/* Summary for multiple calls */}
-      {toolCalls.length > 1 && (
-        <span className="text-xs text-muted-foreground ml-1">
-          {executing.length > 0 && `${executing.length} running`}
-          {executing.length > 0 && pending.length > 0 && ", "}
-          {pending.length > 0 && `${pending.length} queued`}
-          {hasFailures && ` (${failed.length} failed)`}
-        </span>
-      )}
-    </div>
-  );
-}
diff --git a/ui/src/components/ToolCallIndicator/index.ts b/ui/src/components/ToolCallIndicator/index.ts
deleted file mode 100644
index 67519f57..00000000
--- a/ui/src/components/ToolCallIndicator/index.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export {
-  ToolCallIndicator,
-  type ToolCall,
-  type ToolCallType,
-  type ToolCallStatus,
-} from "./ToolCallIndicator";
diff --git a/ui/src/components/ToolIcons/ToolIcons.tsx b/ui/src/components/ToolIcons/ToolIcons.tsx
index 0ba0fc49..e0804c58 100644
--- a/ui/src/components/ToolIcons/ToolIcons.tsx
+++ b/ui/src/components/ToolIcons/ToolIcons.tsx
@@ -1,7 +1,7 @@
 /**
  * ToolIcons - Custom icons for tool types
  *
- * These icons are used across ToolsMenu, ToolCallIndicator, ExecutionSummaryBar,
+ * These icons are used across ToolsMenu, ExecutionSummaryBar,
  * and ArtifactThumbnail for consistent tool identification.
  */
 
@@ -228,3 +228,23 @@ export const TOOL_SHORT_NAMES: Record<string, string> = {
 export function getToolShortName(toolId: string): string {
   return TOOL_SHORT_NAMES[toolId] || toolId;
 }
+
+/** Tool status labels for in-progress display */
+const TOOL_STATUS_LABELS: Record<string, string> = {
+  file_search: "Searching documents",
+  code_interpreter: "Running Python",
+  js_code_interpreter: "Running JavaScript",
+  sql_query: "Running SQL",
+  chart_render: "Rendering chart",
+  web_search: "Searching web",
+  web_fetch: "Fetching URL",
+  wikipedia: "Searching Wikipedia",
+  wikidata: "Querying Wikidata",
+  sub_agent: "Running agent",
+  mcp: "Calling tool",
+};
+
+/** Get human-readable status label for a running tool */
+export function getToolStatusLabel(toolId: string, toolName?: string): string {
+  return TOOL_STATUS_LABELS[toolId] || (toolName ? `Calling ${toolName}` : "Calling function");
+}
diff --git a/ui/src/components/ToolIcons/index.ts b/ui/src/components/ToolIcons/index.ts
index 4e6d6cff..a8e743f1 100644
--- a/ui/src/components/ToolIcons/index.ts
+++ b/ui/src/components/ToolIcons/index.ts
@@ -6,5 +6,6 @@ export {
   TOOL_SHORT_NAMES,
   getToolIcon,
   getToolShortName,
+  getToolStatusLabel,
   type ToolIconComponent,
 } from "./ToolIcons";
diff --git a/ui/src/pages/chat/useChat.ts b/ui/src/pages/chat/useChat.ts
index 6416d63f..eab19380 100644
--- a/ui/src/pages/chat/useChat.ts
+++ b/ui/src/pages/chat/useChat.ts
@@ -39,6 +39,7 @@ import {
   createMCPToolName,
   type ToolExecutorContext,
 } from "./utils/toolExecutors";
+import { getToolStatusLabel } from "@/components/ToolIcons";
 import { useMCPStore } from "@/stores/mcpStore";
 import {
   sendChainedMode,
@@ -1439,6 +1440,7 @@ export function useChat({
           inputArtifacts: [],
           outputArtifacts: [],
           round: roundNumber,
+          statusMessage: getToolStatusLabel(tc.name, tc.name),
         }));
 
         // Add executions to store for real-time UI updates
diff --git a/ui/src/pages/chat/utils/toolCallParser.ts b/ui/src/pages/chat/utils/toolCallParser.ts
index 990498b1..76e3a789 100644
--- a/ui/src/pages/chat/utils/toolCallParser.ts
+++ b/ui/src/pages/chat/utils/toolCallParser.ts
@@ -28,10 +28,27 @@
  * ```
  */
 
-import type { ToolCall, ToolCallType, ToolCallStatus } from "@/components/ToolCallIndicator";
-
-// Re-export for convenience
-export type { ToolCall, ToolCallType, ToolCallStatus };
+/** Types of tool calls that can be displayed */
+export type ToolCallType =
+  | "file_search"
+  | "web_search"
+  | "code_interpreter"
+  | "js_code_interpreter"
+  | "sql_query"
+  | "chart_render"
+  | "function";
+
+/** Status of a tool call execution */
+export type ToolCallStatus = "pending" | "executing" | "completed" | "failed";
+
+/** Represents a single tool call being executed */
+export interface ToolCall {
+  id: string;
+  type: ToolCallType;
+  name?: string;
+  status: ToolCallStatus;
+  error?: string;
+}
 
 /**
  * SSE event types emitted by the backend for function calls

From 3bf7049670a9eb1530cf615c03e0e02a3f55986e Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Sat, 21 Mar 2026 20:41:21 +1000
Subject: [PATCH 10/23] Add compact mode

---
 .../MultiModelResponse/ContentRound.tsx       | 35 ++++++++
 .../MultiModelResponse/MultiModelResponse.tsx | 85 +++++++++++++++----
 ui/src/stores/chatUIStore.ts                  | 42 +++++++++
 3 files changed, 144 insertions(+), 18 deletions(-)

diff --git a/ui/src/components/MultiModelResponse/ContentRound.tsx b/ui/src/components/MultiModelResponse/ContentRound.tsx
index 130562eb..15b1fd86 100644
--- a/ui/src/components/MultiModelResponse/ContentRound.tsx
+++ b/ui/src/components/MultiModelResponse/ContentRound.tsx
@@ -4,6 +4,7 @@ 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 */
@@ -48,6 +49,7 @@ function ContentRoundComponent({
 }: ContentRoundProps) {
   const [toolsExpanded, setToolsExpanded] = useState(false);
   const handleToggleTools = useCallback(() => setToolsExpanded((p) => !p), []);
+  const compactMode = useCompactMode();
 
   // Resolve display selection to actual artifacts
   const displayedArtifacts = useMemo(() => {
@@ -67,6 +69,39 @@ function ContentRoundComponent({
 
   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 (
+        <div className="space-y-1 border-l-2 border-zinc-200 pl-3 dark:border-zinc-700">
+          {hasContent && <StreamingMarkdown content={content!} isStreaming={isStreaming} />}
+          {hasDisplayedArtifacts && (
+            <div className={layoutClass}>
+              {displayedArtifacts.map((artifact) => (
+                <ArtifactComponent key={artifact.id} artifact={artifact} />
+              ))}
+            </div>
+          )}
+        </div>
+      );
+    }
+    // No content — only show an indicator while actively streaming, hide when done
+    const isActive = isReasoningStreaming || isToolsStreaming;
+    if (!isActive) return null;
+    return (
+      <div className="flex items-center gap-3 text-muted-foreground">
+        <div className="flex items-center gap-1.5 py-1">
+          <span className="h-2 w-2 rounded-full bg-muted-foreground/60 animate-typing-dot" />
+          <span className="h-2 w-2 rounded-full bg-muted-foreground/60 animate-typing-dot-delay-1" />
+          <span className="h-2 w-2 rounded-full bg-muted-foreground/60 animate-typing-dot-delay-2" />
+        </div>
+        <span className="text-sm">{hasReasoning ? "Thinking..." : "Processing..."}</span>
+      </div>
+    );
+  }
+
   const layoutClass =
     displaySelection?.layout === "gallery" ? "grid grid-cols-2 gap-3" : "space-y-3";
 
diff --git a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
index 504f894a..69adce99 100644
--- a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
+++ b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
@@ -8,6 +8,8 @@ import {
   Eye,
   EyeOff,
   GitFork,
+  ListChevronsDownUp,
+  ListChevronsUpDown,
   Loader2,
   MoreHorizontal,
   Pencil,
@@ -67,7 +69,13 @@ 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 {
@@ -432,6 +440,7 @@ const ModelResponseCard = memo(function ModelResponseCard({
   const style = getModelStyle(model);
   const isComplete = !response.isStreaming && response.content && !response.error;
   const isAnyStreaming = useIsStreaming();
+  const compactMode = useCompactMode();
 
   // State for artifact modal
   const [selectedArtifact, setSelectedArtifact] = useState<ArtifactType | null>(null);
@@ -857,6 +866,25 @@ const ModelResponseCard = memo(function ModelResponseCard({
                 // Single-round: reasoning then content
                 const singleReasoning =
                   response.reasoningContent || response.usage?.reasoningContent;
+                if (compactMode) {
+                  // Compact: show content only, with a "Thinking" indicator while actively streaming reasoning
+                  return (
+                    <>
+                      {singleReasoning && !response.content && response.isStreaming && (
+                        <div className="flex items-center gap-3 text-muted-foreground">
+                          <TypingIndicator />
+                          <span className="text-sm">Thinking...</span>
+                        </div>
+                      )}
+                      {response.content && (
+                        <StreamingMarkdown
+                          content={response.content}
+                          isStreaming={response.isStreaming}
+                        />
+                      )}
+                    </>
+                  );
+                }
                 return (
                   <>
                     {singleReasoning && (
@@ -879,22 +907,24 @@ const ModelResponseCard = memo(function ModelResponseCard({
               <CitationList citations={citations} className="mt-4 pt-4 border-t" compact={false} />
             )}
             {/* Tool execution / artifact display */}
-            {hasToolExecutionRounds
-              ? // Multi-round with completedRounds: per-round tool execution + artifacts rendered above.
-                // Only show ToolExecutionBlock for single-round fallback (no completedRounds).
-                !(response.completedRounds ?? response.usage?.completedRounds) && (
-                  <div className="mt-4 pt-4 border-t">
-                    <ToolExecutionBlock
-                      rounds={toolExecutionRounds}
-                      isStreaming={response.isStreaming}
-                      onArtifactClick={handleArtifactClick}
-                      displaySelection={displaySelection}
-                    />
-                  </div>
-                )
-              : hasArtifacts && (
-                  <ArtifactList artifacts={artifacts} className="mt-4 pt-4 border-t" />
-                )}
+            {compactMode
+              ? null
+              : hasToolExecutionRounds
+                ? // Multi-round with completedRounds: per-round tool execution + artifacts rendered above.
+                  // Only show ToolExecutionBlock for single-round fallback (no completedRounds).
+                  !(response.completedRounds ?? response.usage?.completedRounds) && (
+                    <div className="mt-4 pt-4 border-t">
+                      <ToolExecutionBlock
+                        rounds={toolExecutionRounds}
+                        isStreaming={response.isStreaming}
+                        onArtifactClick={handleArtifactClick}
+                        displaySelection={displaySelection}
+                      />
+                    </div>
+                  )
+                : hasArtifacts && (
+                    <ArtifactList artifacts={artifacts} className="mt-4 pt-4 border-t" />
+                  )}
           </>
         )}
       </div>
@@ -940,7 +970,8 @@ function MultiModelResponseComponent({
   // Use global UI state from store
   const viewMode = useViewMode();
   const expandedModel = useExpandedModel();
-  const { setViewMode, setExpandedModel } = useChatUIStore();
+  const compactMode = useCompactMode();
+  const { setViewMode, setExpandedModel, toggleCompactMode } = useChatUIStore();
 
   const isMultiResponse = responses.length > 1;
   const showViewToggle = isMultiResponse;
@@ -1169,6 +1200,24 @@ function MultiModelResponseComponent({
             </Tooltip>
           </div>
         )}
+        <Tooltip>
+          <TooltipTrigger asChild>
+            <Button
+              variant={compactMode ? "secondary" : "ghost"}
+              size="sm"
+              className="h-6 w-6 p-0 rounded-md border bg-muted/50"
+              onClick={toggleCompactMode}
+              aria-label={compactMode ? "Show reasoning & tools" : "Compact view"}
+            >
+              {compactMode ? (
+                <ListChevronsDownUp className="h-3.5 w-3.5" />
+              ) : (
+                <ListChevronsUpDown className="h-3.5 w-3.5" />
+              )}
+            </Button>
+          </TooltipTrigger>
+          <TooltipContent>{compactMode ? "Show reasoning & tools" : "Compact view"}</TooltipContent>
+        </Tooltip>
         <div className="h-px flex-1 bg-border" />
       </div>
 
diff --git a/ui/src/stores/chatUIStore.ts b/ui/src/stores/chatUIStore.ts
index c5225008..5044d14e 100644
--- a/ui/src/stores/chatUIStore.ts
+++ b/ui/src/stores/chatUIStore.ts
@@ -205,6 +205,12 @@ interface ChatUIState {
    * When null, uses the current streaming model as fallback.
    */
   subAgentModel: string | null;
+  /**
+   * Whether compact mode is enabled for model responses.
+   * Hides reasoning sections, tool execution details, and collapses
+   * rounds without content to minimal "Thinking" / "Processing" indicators.
+   */
+  compactMode: boolean;
 }
 
 interface ChatUIActions {
@@ -317,6 +323,10 @@ interface ChatUIActions {
   clearPendingPrompt: () => void;
   /** Set the default model for sub-agent tool */
   setSubAgentModel: (model: string | null) => void;
+  /** Set compact mode */
+  setCompactMode: (enabled: boolean) => void;
+  /** Toggle compact mode */
+  toggleCompactMode: () => void;
 }
 
 export type ChatUIStore = ChatUIState & ChatUIActions;
@@ -340,6 +350,14 @@ function loadViewMode(): ViewMode {
   return "grid";
 }
 
+function loadCompactMode(): boolean {
+  try {
+    return localStorage.getItem("hadrian:compactMode") === "true";
+  } catch {
+    return false;
+  }
+}
+
 const initialState: ChatUIState = {
   viewMode: loadViewMode(),
   expandedModel: null,
@@ -369,6 +387,7 @@ const initialState: ChatUIState = {
   editingMessageId: null,
   pendingPrompt: null,
   subAgentModel: null,
+  compactMode: loadCompactMode(),
 };
 
 export const useChatUIStore = create<ChatUIStore>((set) => ({
@@ -623,6 +642,26 @@ export const useChatUIStore = create<ChatUIStore>((set) => ({
   clearPendingPrompt: () => set({ pendingPrompt: null }),
 
   setSubAgentModel: (model) => set({ subAgentModel: model }),
+
+  setCompactMode: (enabled) => {
+    try {
+      localStorage.setItem("hadrian:compactMode", String(enabled));
+    } catch {
+      // localStorage unavailable
+    }
+    set({ compactMode: enabled });
+  },
+
+  toggleCompactMode: () =>
+    set((state) => {
+      const next = !state.compactMode;
+      try {
+        localStorage.setItem("hadrian:compactMode", String(next));
+      } catch {
+        // localStorage unavailable
+      }
+      return { compactMode: next };
+    }),
 }));
 
 /**
@@ -753,6 +792,9 @@ export const usePendingPrompt = () => useChatUIStore((state: ChatUIState) => sta
 /** Get the default model for sub-agent tool */
 export const useSubAgentModel = () => useChatUIStore((state: ChatUIState) => state.subAgentModel);
 
+/** Get compact mode state - hides reasoning/tools in model responses */
+export const useCompactMode = () => useChatUIStore((state: ChatUIState) => state.compactMode);
+
 /** Get MCP config modal open state */
 export const useMCPConfigModalOpen = () =>
   useChatUIStore((state: ChatUIState) => state.mcpConfigModalOpen);

From 25bcb217124461fc02f34b2d4e97795907975aa2 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Sun, 22 Mar 2026 10:10:36 +1000
Subject: [PATCH 11/23] Show rounds on hover

---
 ui/src/components/MultiModelResponse/ContentRound.tsx | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/ui/src/components/MultiModelResponse/ContentRound.tsx b/ui/src/components/MultiModelResponse/ContentRound.tsx
index 15b1fd86..662ef9a1 100644
--- a/ui/src/components/MultiModelResponse/ContentRound.tsx
+++ b/ui/src/components/MultiModelResponse/ContentRound.tsx
@@ -75,7 +75,7 @@ function ContentRoundComponent({
       const layoutClass =
         displaySelection?.layout === "gallery" ? "grid grid-cols-2 gap-3" : "space-y-3";
       return (
-        <div className="space-y-1 border-l-2 border-zinc-200 pl-3 dark:border-zinc-700">
+        <div className="space-y-1 border-l-2 border-transparent pl-3 transition-colors hover:border-zinc-200 dark:hover:border-zinc-700">
           {hasContent && <StreamingMarkdown content={content!} isStreaming={isStreaming} />}
           {hasDisplayedArtifacts && (
             <div className={layoutClass}>
@@ -106,7 +106,7 @@ function ContentRoundComponent({
     displaySelection?.layout === "gallery" ? "grid grid-cols-2 gap-3" : "space-y-3";
 
   return (
-    <div className="space-y-1 border-l-2 border-zinc-200 pl-3 dark:border-zinc-700">
+    <div className="space-y-1 border-l-2 border-transparent pl-3 transition-colors hover:border-zinc-200 dark:hover:border-zinc-700">
       {hasReasoning && (
         <ReasoningSection
           content={reasoning!}

From 793e574d7f07490b4d2e9b5af121db337b6f4a45 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Sun, 22 Mar 2026 10:39:48 +1000
Subject: [PATCH 12/23] Review fixes

---
 ui/src/components/ChatMessageList/ChatMessageList.tsx | 9 ++++++++-
 1 file changed, 8 insertions(+), 1 deletion(-)

diff --git a/ui/src/components/ChatMessageList/ChatMessageList.tsx b/ui/src/components/ChatMessageList/ChatMessageList.tsx
index e48ddf6c..0f6e90cf 100644
--- a/ui/src/components/ChatMessageList/ChatMessageList.tsx
+++ b/ui/src/components/ChatMessageList/ChatMessageList.tsx
@@ -358,8 +358,15 @@ export function ChatMessageList({
               {virtualizer.getVirtualItems().map((virtualItem) => {
                 const group = messageGroups[virtualItem.index];
                 const isLastGroup = virtualItem.index === messageGroups.length - 1;
+                const committedInstanceIds = new Set(
+                  group.assistantResponses.map((r) => r.instanceId ?? r.model ?? "")
+                );
                 const showStreaming =
-                  isLastGroup && hasStreamingResponses && group.assistantResponses.length === 0;
+                  isLastGroup &&
+                  hasStreamingResponses &&
+                  filteredModelResponses.some(
+                    (r) => !committedInstanceIds.has(r.instanceId ?? r.model)
+                  );
                 return (
                   <div
                     key={group.id}

From 87e5e032a75eaddeee7fd748274c0cd25dfad2e8 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Sun, 22 Mar 2026 11:34:07 +1000
Subject: [PATCH 13/23] Storybook fixes

---
 .../MultiModelResponse.stories.tsx            | 249 +++++++-----------
 1 file changed, 102 insertions(+), 147 deletions(-)

diff --git a/ui/src/components/MultiModelResponse/MultiModelResponse.stories.tsx b/ui/src/components/MultiModelResponse/MultiModelResponse.stories.tsx
index fb1ca822..aa8a426d 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<typeof MultiModelResponse> = {
   title: "Chat/MultiModelResponse",
@@ -63,16 +62,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 +111,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);
   },
 };
@@ -272,20 +270,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 +509,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 +518,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<string, ToolCallState>();
-      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 <Story />;
-    },
-  ],
   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 +558,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<string, ToolCallState>();
-      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 <Story />;
-    },
-  ],
   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<string, ToolCallState>();
-      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 <Story />;
-    },
-  ],
   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 +677,6 @@ export const WithHideCallback: Story = {
     ],
     timestamp: new Date(),
     groupId: "test-group-hide",
-    onFeedback: fn(),
     onSelectBest: fn(),
     onRegenerate: fn(),
     onHide: fn(),

From 3b41e25a88380290b5733daff2b0cc958171db71 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Sun, 22 Mar 2026 12:35:08 +1000
Subject: [PATCH 14/23] Better constructs for rounds

---
 .../ChatMessageList/ChatMessageList.tsx       |  1 +
 .../MultiModelResponse/MultiModelResponse.tsx | 76 ++++++++++++-------
 ui/src/components/chat-types.ts               | 11 ++-
 ui/src/pages/chat/useChat.ts                  | 62 ++++++++-------
 ui/src/stores/conversationStore.ts            |  3 +
 ui/src/stores/streamingStore.ts               | 24 +++++-
 6 files changed, 111 insertions(+), 66 deletions(-)

diff --git a/ui/src/components/ChatMessageList/ChatMessageList.tsx b/ui/src/components/ChatMessageList/ChatMessageList.tsx
index 0f6e90cf..659402e1 100644
--- a/ui/src/components/ChatMessageList/ChatMessageList.tsx
+++ b/ui/src/components/ChatMessageList/ChatMessageList.tsx
@@ -622,6 +622,7 @@ export function ChatMessageList({
                               citations: m.citations,
                               artifacts: m.artifacts,
                               toolExecutionRounds: m.toolExecutionRounds,
+                              completedRounds: m.completedRounds,
                               debugMessageId: m.debugMessageId,
                             };
                           })}
diff --git a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
index 69adce99..9b249f5a 100644
--- a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
+++ b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
@@ -44,6 +44,7 @@ import { Avatar, AvatarFallback } from "@/components/Avatar/Avatar";
 import { Button } from "@/components/Button/Button";
 import type {
   Artifact,
+  CompletedRound,
   HistoryMode,
   MessageModeMetadata,
   MessageUsage,
@@ -158,8 +159,8 @@ interface ModelResponse {
   content: string;
   /** Reasoning content for current/last round (extended thinking) */
   reasoningContent?: string;
-  /** Completed rounds' reasoning and content for multi-round tool execution */
-  completedRounds?: Array<{ reasoning?: string; content?: string }>;
+  /** Completed rounds bundling reasoning, content, and tool execution (multi-round tool execution) */
+  completedRounds?: CompletedRound[];
   isStreaming: boolean;
   error?: string;
   usage?: MessageUsage;
@@ -593,6 +594,18 @@ const ModelResponseCard = memo(function ModelResponseCard({
   // 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) {
@@ -601,7 +614,7 @@ const ModelResponseCard = memo(function ModelResponseCard({
       }
     }
     return result;
-  }, [toolExecutionRounds]);
+  }, [response.completedRounds, toolExecutionRounds]);
 
   // Extract display selection for a specific tool execution round
   const getDisplaySelectionForRound = useCallback(
@@ -819,38 +832,38 @@ const ModelResponseCard = memo(function ModelResponseCard({
               </div>
             ) : (
               (() => {
-                const rounds = response.completedRounds ?? response.usage?.completedRounds;
+                const rounds = response.completedRounds;
                 if (rounds && rounds.length > 0) {
-                  // Multi-round: render each round as a ContentRound with per-round tool execution
-                  const parts = response.content ? response.content.split("\n\n---\n\n") : [];
-                  const roundsWithContent = rounds.filter((r) => r.content).length;
-                  const currentContent =
-                    parts.length > roundsWithContent ? parts[parts.length - 1] : null;
+                  // Multi-round: render each round using its bundled toolExecution
                   const currentReasoning =
                     response.reasoningContent &&
                     !rounds.some((r) => r.reasoning === response.reasoningContent)
                       ? response.reasoningContent
                       : null;
+                  // During streaming, content field holds just the current round's text
+                  // (pushCompletedRound resets it). For committed messages, it's empty/null.
+                  const currentContent =
+                    response.isStreaming && response.content?.trim() ? response.content : null;
                   return (
                     <div className="space-y-3">
-                      {rounds.map((round, i) => {
-                        const ter =
-                          i < toolExecutionRounds.length ? toolExecutionRounds[i] : undefined;
-                        return (
-                          <ContentRound
-                            key={i}
-                            reasoning={round.reasoning}
-                            content={round.content}
-                            toolExecutionRound={ter}
-                            isToolsStreaming={
-                              response.isStreaming && i === toolExecutionRounds.length - 1
-                            }
-                            onArtifactClick={handleArtifactClick}
-                            displaySelection={ter ? getDisplaySelectionForRound(ter) : null}
-                            allOutputArtifacts={allOutputArtifacts}
-                          />
-                        );
-                      })}
+                      {rounds.map((round, i) => (
+                        <ContentRound
+                          key={i}
+                          reasoning={round.reasoning}
+                          content={round.content}
+                          toolExecutionRound={round.toolExecution}
+                          isToolsStreaming={
+                            response.isStreaming && i === rounds.length - 1 && !!round.toolExecution
+                          }
+                          onArtifactClick={handleArtifactClick}
+                          displaySelection={
+                            round.toolExecution
+                              ? getDisplaySelectionForRound(round.toolExecution)
+                              : null
+                          }
+                          allOutputArtifacts={allOutputArtifacts}
+                        />
+                      ))}
                       {/* Current streaming round (not yet in completedRounds) */}
                       {(currentReasoning || currentContent) && (
                         <ContentRound
@@ -912,7 +925,7 @@ const ModelResponseCard = memo(function ModelResponseCard({
               : hasToolExecutionRounds
                 ? // Multi-round with completedRounds: per-round tool execution + artifacts rendered above.
                   // Only show ToolExecutionBlock for single-round fallback (no completedRounds).
-                  !(response.completedRounds ?? response.usage?.completedRounds) && (
+                  !response.completedRounds && (
                     <div className="mt-4 pt-4 border-t">
                       <ToolExecutionBlock
                         rounds={toolExecutionRounds}
@@ -1394,6 +1407,13 @@ function areMultiModelResponsePropsEqual(
     if (prevR.content !== nextR.content) return false;
     if (prevR.reasoningContent !== nextR.reasoningContent) return false;
     if ((prevR.completedRounds?.length ?? 0) !== (nextR.completedRounds?.length ?? 0)) return false;
+    // Check if tool executions changed within completed rounds
+    if (prevR.completedRounds && nextR.completedRounds) {
+      for (let j = 0; j < prevR.completedRounds.length; j++) {
+        if (!!prevR.completedRounds[j].toolExecution !== !!nextR.completedRounds[j].toolExecution)
+          return false;
+      }
+    }
     if (prevR.isStreaming !== nextR.isStreaming) return false;
     if (prevR.error !== nextR.error) return false;
     if (prevR.usage?.totalTokens !== nextR.usage?.totalTokens) return false;
diff --git a/ui/src/components/chat-types.ts b/ui/src/components/chat-types.ts
index 033297c1..33bcf03f 100644
--- a/ui/src/components/chat-types.ts
+++ b/ui/src/components/chat-types.ts
@@ -29,6 +29,13 @@ export type ToolExecutionStatus = ToolExecutionStatusImport;
 export type ToolExecution = ToolExecutionImport;
 export type ToolExecutionRound = ToolExecutionRoundImport;
 
+/** A completed round of multi-round tool execution, bundling reasoning, content, and tool execution */
+export interface CompletedRound {
+  reasoning?: string;
+  content?: string;
+  toolExecution?: ToolExecutionRound;
+}
+
 /** History mode for conversation context sent to models */
 export type HistoryMode = "all" | "same-model";
 
@@ -398,8 +405,6 @@ export interface MessageUsage {
   reasoningTokens?: number;
   /** Reasoning content (extended thinking output — last/only round) */
   reasoningContent?: string;
-  /** Per-round reasoning and content for multi-round tool execution */
-  completedRounds?: Array<{ reasoning?: string; content?: string }>;
 
   // Timing stats (captured client-side during streaming)
   /** Time to first token in milliseconds (from request start) */
@@ -825,6 +830,8 @@ export interface ChatMessage {
   artifacts?: Artifact[];
   /** Tool execution timeline for multi-turn tool calling (assistant messages only) */
   toolExecutionRounds?: ToolExecutionRound[];
+  /** Completed rounds bundling reasoning, content, and tool execution (multi-round tool execution) */
+  completedRounds?: CompletedRound[];
   /** Debug message ID for looking up debug info in debugStore (assistant messages only) */
   debugMessageId?: string;
 }
diff --git a/ui/src/pages/chat/useChat.ts b/ui/src/pages/chat/useChat.ts
index eab19380..863ab30b 100644
--- a/ui/src/pages/chat/useChat.ts
+++ b/ui/src/pages/chat/useChat.ts
@@ -9,6 +9,7 @@ import {
 } from "@/stores/conversationStore";
 import { useDebugStore } from "@/stores/debugStore";
 import type {
+  CompletedRound,
   ConversationMode,
   ModeConfig,
   MessageModeMetadata,
@@ -207,8 +208,8 @@ interface StreamResponseResult {
   hasOutputText: boolean;
   usage?: MessageUsage;
   reasoningContent?: string;
-  /** Per-round reasoning and content for multi-round tool execution */
-  completedRounds?: Array<{ reasoning?: string; content?: string }>;
+  /** Per-round reasoning, content, and tool execution for multi-round tool execution */
+  completedRounds?: CompletedRound[];
   /** Tool calls detected during streaming (only when clientSideToolExecution is enabled) */
   toolCalls?: ParsedToolCall[];
   /** Tool execution timeline for progressive disclosure UI */
@@ -1281,10 +1282,9 @@ export function useChat({
       }
 
       let currentInputItems = [...initialInputItems];
-      let accumulatedContent = "";
       let accumulatedUsage: MessageUsage | undefined;
       let lastReasoningContent: string | undefined;
-      const allCompletedRounds: Array<{ reasoning?: string; content?: string }> = [];
+      const allCompletedRounds: CompletedRound[] = [];
       let iterations = 0;
 
       // Track execution rounds locally (also mirrored in store for real-time UI)
@@ -1322,7 +1322,10 @@ export function useChat({
           return iterations === 1
             ? null
             : {
-                content: accumulatedContent,
+                content: allCompletedRounds
+                  .map((r) => r.content)
+                  .filter(Boolean)
+                  .join("\n\n---\n\n"),
                 hasOutputText: true,
                 usage: accumulatedUsage,
                 reasoningContent: lastReasoningContent,
@@ -1345,23 +1348,12 @@ export function useChat({
         // Only count content as meaningful if it has non-whitespace text —
         // models sometimes emit trivial whitespace before tool calls.
         const hasNonTrivialContent = result.hasOutputText && !!result.content?.trim();
-        const roundData: { reasoning?: string; content?: string } = {};
+        const roundData: CompletedRound = {};
         if (result.reasoningContent) roundData.reasoning = result.reasoningContent;
         if (hasNonTrivialContent) roundData.content = result.content;
         allCompletedRounds.push(roundData);
         // Push to streaming store so the UI can render interleaved rounds during streaming
         streamingStore.pushCompletedRound(storeKey, roundData);
-
-        // Accumulate content across rounds with separator.
-        // Only include rounds that had non-trivial text output,
-        // skipping reasoning-only rounds and whitespace-only rounds.
-        if (hasNonTrivialContent) {
-          if (accumulatedContent) {
-            accumulatedContent += "\n\n---\n\n" + result.content;
-          } else {
-            accumulatedContent = result.content;
-          }
-        }
         lastReasoningContent = result.reasoningContent;
 
         // Accumulate usage (sum tokens across iterations)
@@ -1376,7 +1368,6 @@ export function useChat({
               reasoningTokens:
                 (accumulatedUsage.reasoningTokens ?? 0) + (result.usage.reasoningTokens ?? 0),
               reasoningContent: lastReasoningContent,
-              completedRounds: allCompletedRounds.length > 1 ? [...allCompletedRounds] : undefined,
             };
           } else {
             accumulatedUsage = { ...result.usage };
@@ -1519,6 +1510,13 @@ export function useChat({
         };
         executionRounds.push(round);
 
+        // Attach tool execution to the current completed round
+        if (allCompletedRounds.length > 0) {
+          const lastIdx = allCompletedRounds.length - 1;
+          allCompletedRounds[lastIdx] = { ...allCompletedRounds[lastIdx], toolExecution: round };
+          streamingStore.setCompletedRoundToolExecution(storeKey, round);
+        }
+
         // Build continuation input with tool results
         const toolResultItems = buildToolResultInputItems(result.toolCalls, toolResults);
 
@@ -1575,19 +1573,7 @@ export function useChat({
         // Clear tool calls from streaming store before next iteration
         streamingStore.clearToolCalls(storeKey);
 
-        // Add separator to streaming store so the next round's appendContent
-        // builds on top of the accumulated content with a visual break.
-        // Only add if this round had non-trivial text output (avoid double separators
-        // from rounds that only had tool calls or whitespace-only text).
-        if (hasNonTrivialContent) {
-          streamingStore.appendContent(storeKey, "\n\n---\n\n");
-        }
-      }
-
-      // Sync streaming store with final accumulated content (removes any
-      // trailing separators and ensures consistency with committed content)
-      if (iterations > 1) {
-        streamingStore.setContent(storeKey, accumulatedContent);
+        // Content for the next round will stream fresh (pushCompletedRound resets it)
       }
 
       // Complete debug capture successfully
@@ -1595,8 +1581,13 @@ export function useChat({
         debugStore.completeDebugCapture(messageId, model, true);
       }
 
+      const flatContent = allCompletedRounds
+        .map((r) => r.content)
+        .filter(Boolean)
+        .join("\n\n---\n\n");
+
       return {
-        content: accumulatedContent,
+        content: flatContent,
         hasOutputText: true,
         usage: accumulatedUsage,
         reasoningContent: lastReasoningContent,
@@ -1744,6 +1735,7 @@ export function useChat({
         citations?: Citation[];
         artifacts?: Artifact[];
         toolExecutionRounds?: ToolExecutionRound[];
+        completedRounds?: CompletedRound[];
         /** Debug message ID for looking up debug info */
         debugMessageId?: string;
       }> = [];
@@ -1773,6 +1765,7 @@ export function useChat({
               citations: stream?.citations,
               artifacts: stream?.artifacts,
               toolExecutionRounds: stream?.toolExecutionRounds,
+              completedRounds: stream?.completedRounds.length ? stream.completedRounds : undefined,
             });
           }
         }
@@ -1791,6 +1784,7 @@ export function useChat({
               citations: stream?.citations,
               artifacts: stream?.artifacts,
               toolExecutionRounds: stream?.toolExecutionRounds,
+              completedRounds: stream?.completedRounds.length ? stream.completedRounds : undefined,
             });
           }
         }
@@ -1811,6 +1805,7 @@ export function useChat({
               citations: stream?.citations,
               artifacts: stream?.artifacts,
               toolExecutionRounds: stream?.toolExecutionRounds,
+              completedRounds: stream?.completedRounds.length ? stream.completedRounds : undefined,
               // Only include debugMessageId for multiple mode (default)
               debugMessageId: conversationMode === "multiple" ? debugMessageId : undefined,
             });
@@ -1825,6 +1820,9 @@ export function useChat({
                 citations: stream?.citations,
                 artifacts: stream?.artifacts,
                 toolExecutionRounds: stream?.toolExecutionRounds,
+                completedRounds: stream?.completedRounds.length
+                  ? stream.completedRounds
+                  : undefined,
                 debugMessageId: conversationMode === "multiple" ? debugMessageId : undefined,
               });
             }
diff --git a/ui/src/stores/conversationStore.ts b/ui/src/stores/conversationStore.ts
index 46aac0e6..c76cea83 100644
--- a/ui/src/stores/conversationStore.ts
+++ b/ui/src/stores/conversationStore.ts
@@ -5,6 +5,7 @@ import type {
   Artifact,
   ChatMessage,
   Citation,
+  CompletedRound,
   Conversation,
   HistoryMode,
   MessageModeMetadata,
@@ -100,6 +101,7 @@ interface ConversationActions {
       citations?: Citation[];
       artifacts?: Artifact[];
       toolExecutionRounds?: ToolExecutionRound[];
+      completedRounds?: CompletedRound[];
       debugMessageId?: string;
     }>
   ) => void;
@@ -202,6 +204,7 @@ export const useConversationStore = create<ConversationStore>((set) => ({
           citations: m.citations,
           artifacts: m.artifacts,
           toolExecutionRounds: m.toolExecutionRounds,
+          completedRounds: m.completedRounds,
           debugMessageId: m.debugMessageId,
         })),
       ],
diff --git a/ui/src/stores/streamingStore.ts b/ui/src/stores/streamingStore.ts
index 1dd985b3..3e43f5f7 100644
--- a/ui/src/stores/streamingStore.ts
+++ b/ui/src/stores/streamingStore.ts
@@ -6,6 +6,7 @@ import type {
   ResponseFeedbackData,
   Citation,
   Artifact,
+  CompletedRound,
   ToolExecution,
   ToolExecutionRound,
 } from "@/components/chat-types";
@@ -72,8 +73,8 @@ export interface StreamingResponse {
   content: string;
   /** Reasoning content for the current round (extended thinking) */
   reasoningContent: string;
-  /** Completed rounds' reasoning and content (for multi-round tool execution) */
-  completedRounds: Array<{ reasoning?: string; content?: string }>;
+  /** Completed rounds bundling reasoning, content, and tool execution (multi-round tool execution) */
+  completedRounds: CompletedRound[];
   isStreaming: boolean;
   error?: string;
   usage?: MessageUsage;
@@ -715,8 +716,10 @@ interface StreamingActions {
   appendReasoningContent: (instanceId: string, delta: string) => void;
   /** Set the full reasoning content for an instance */
   setReasoningContent: (instanceId: string, content: string) => void;
-  /** Push a completed round's reasoning and content, then reset reasoningContent */
-  pushCompletedRound: (instanceId: string, round: { reasoning?: string; content?: string }) => void;
+  /** Push a completed round, then reset reasoningContent and content for the next round */
+  pushCompletedRound: (instanceId: string, round: CompletedRound) => void;
+  /** Attach tool execution data to the last completed round */
+  setCompletedRoundToolExecution: (instanceId: string, toolExecution: ToolExecutionRound) => void;
   /** Mark an instance's stream as complete */
   completeStream: (instanceId: string, usage?: MessageUsage) => void;
   /** Set an error for an instance's stream */
@@ -893,10 +896,23 @@ export const useStreamingStore = create<StreamingStore>((set) => ({
         ...existing,
         completedRounds: [...existing.completedRounds, round],
         reasoningContent: "",
+        content: "",
       });
       return { streams: newStreams };
     }),
 
+  setCompletedRoundToolExecution: (model, toolExecution) =>
+    set((state) => {
+      const existing = state.streams.get(model);
+      if (!existing || existing.completedRounds.length === 0) return state;
+
+      const rounds = [...existing.completedRounds];
+      rounds[rounds.length - 1] = { ...rounds[rounds.length - 1], toolExecution };
+      const newStreams = new Map(state.streams);
+      newStreams.set(model, { ...existing, completedRounds: rounds });
+      return { streams: newStreams };
+    }),
+
   completeStream: (model, usage) =>
     set((state) => {
       const existing = state.streams.get(model);

From 923967a49774a6e7575b0def2c354901be4d8f09 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Sun, 22 Mar 2026 12:58:49 +1000
Subject: [PATCH 15/23] Show thinking / processing interstitials between rounds

---
 .../MultiModelResponse/MultiModelResponse.tsx | 57 ++++++++++++++++++-
 ui/src/pages/chat/useChat.ts                  |  4 ++
 ui/src/stores/streamingStore.ts               | 12 ++++
 3 files changed, 71 insertions(+), 2 deletions(-)

diff --git a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
index 9b249f5a..7e2dc34a 100644
--- a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
+++ b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
@@ -84,6 +84,7 @@ import {
   useArtifacts,
   useToolExecutionRounds,
   useIsStreaming,
+  useHasActiveToolCalls,
 } from "@/stores/streamingStore";
 import { cn } from "@/utils/cn";
 import { getModelDisplayName } from "@/utils/modelNames";
@@ -211,6 +212,24 @@ interface MultiModelResponseProps {
   historyMode?: HistoryMode;
 }
 
+/** 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;
+    }
+    // Content just changed — reset stall and start timer
+    setStalled(false);
+    const timer = setTimeout(() => setStalled(true), thresholdMs);
+    return () => clearTimeout(timer);
+  }, [content, isStreaming, thresholdMs]);
+
+  return stalled;
+}
+
 function TypingIndicator() {
   return (
     <div className="flex items-center gap-1.5 py-1">
@@ -442,6 +461,8 @@ const ModelResponseCard = memo(function ModelResponseCard({
   const isComplete = !response.isStreaming && response.content && !response.error;
   const isAnyStreaming = useIsStreaming();
   const compactMode = useCompactMode();
+  const isContentStalled = useContentStalled(response.content, response.isStreaming);
+  const hasActiveTools = useHasActiveToolCalls(model);
 
   // State for artifact modal
   const [selectedArtifact, setSelectedArtifact] = useState<ArtifactType | null>(null);
@@ -792,7 +813,10 @@ const ModelResponseCard = memo(function ModelResponseCard({
             <AlertCircle className="h-5 w-5 shrink-0 mt-0.5" />
             <span className="text-sm leading-relaxed">{response.error}</span>
           </div>
-        ) : response.isStreaming && !response.content && !response.reasoningContent ? (
+        ) : response.isStreaming &&
+          !response.content &&
+          !response.reasoningContent &&
+          !response.completedRounds?.length ? (
           <div className="flex items-center gap-3 text-muted-foreground">
             <TypingIndicator />
             <span className="text-sm">Thinking...</span>
@@ -853,7 +877,11 @@ const ModelResponseCard = memo(function ModelResponseCard({
                           content={round.content}
                           toolExecutionRound={round.toolExecution}
                           isToolsStreaming={
-                            response.isStreaming && i === rounds.length - 1 && !!round.toolExecution
+                            response.isStreaming &&
+                            i === rounds.length - 1 &&
+                            !!round.toolExecution?.executions.some(
+                              (e) => e.status === "pending" || e.status === "running"
+                            )
                           }
                           onArtifactClick={handleArtifactClick}
                           displaySelection={
@@ -873,6 +901,24 @@ const ModelResponseCard = memo(function ModelResponseCard({
                           isReasoningStreaming={response.isStreaming && !currentContent}
                         />
                       )}
+                      {/* Between-round indicator: streaming but no new content yet */}
+                      {response.isStreaming &&
+                        !currentReasoning &&
+                        !currentContent &&
+                        (() => {
+                          const lastRound = rounds[rounds.length - 1];
+                          const isProcessing = lastRound?.toolExecution?.executions.some(
+                            (e) => e.status === "pending" || e.status === "running"
+                          );
+                          return (
+                            <div className="flex items-center gap-3 text-muted-foreground">
+                              <TypingIndicator />
+                              <span className="text-sm">
+                                {isProcessing ? "Processing..." : "Thinking..."}
+                              </span>
+                            </div>
+                          );
+                        })()}
                     </div>
                   );
                 }
@@ -915,6 +961,13 @@ const ModelResponseCard = memo(function ModelResponseCard({
                 );
               })()
             )}
+            {/* Stall indicator: prominent status when content stops flowing mid-stream */}
+            {isContentStalled && (
+              <div className="flex items-center gap-3 text-muted-foreground mt-2">
+                <TypingIndicator />
+                <span className="text-sm">{hasActiveTools ? "Processing..." : "Thinking..."}</span>
+              </div>
+            )}
             {/* Citations from file_search/web_search */}
             {hasCitations && (
               <CitationList citations={citations} className="mt-4 pt-4 border-t" compact={false} />
diff --git a/ui/src/pages/chat/useChat.ts b/ui/src/pages/chat/useChat.ts
index 863ab30b..d0060b12 100644
--- a/ui/src/pages/chat/useChat.ts
+++ b/ui/src/pages/chat/useChat.ts
@@ -1383,6 +1383,10 @@ export function useChat({
           break;
         }
 
+        // Resume streaming state so the UI shows between-round indicators
+        // (completeStream set isStreaming=false, but we have more rounds coming)
+        streamingStore.resumeStreaming(storeKey);
+
         // Capture tool calls for debug
         if (messageId) {
           debugStore.setRoundToolCalls(
diff --git a/ui/src/stores/streamingStore.ts b/ui/src/stores/streamingStore.ts
index 3e43f5f7..56ab9338 100644
--- a/ui/src/stores/streamingStore.ts
+++ b/ui/src/stores/streamingStore.ts
@@ -722,6 +722,8 @@ interface StreamingActions {
   setCompletedRoundToolExecution: (instanceId: string, toolExecution: ToolExecutionRound) => void;
   /** Mark an instance's stream as complete */
   completeStream: (instanceId: string, usage?: MessageUsage) => void;
+  /** Resume streaming for an instance (e.g., between tool-calling rounds) */
+  resumeStreaming: (instanceId: string) => void;
   /** Set an error for an instance's stream */
   setError: (instanceId: string, error: string) => void;
   /** Clear all streams and reset mode state */
@@ -934,6 +936,16 @@ export const useStreamingStore = create<StreamingStore>((set) => ({
       };
     }),
 
+  resumeStreaming: (model) =>
+    set((state) => {
+      const existing = state.streams.get(model);
+      if (!existing) return state;
+
+      const newStreams = new Map(state.streams);
+      newStreams.set(model, { ...existing, isStreaming: true });
+      return { streams: newStreams, isStreaming: true };
+    }),
+
   setError: (model, error) =>
     set((state) => {
       const existing = state.streams.get(model);

From fd9890179551ad25655070faf014084a83b36a73 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Sun, 22 Mar 2026 13:06:51 +1000
Subject: [PATCH 16/23] Higher-level management of streaming status indications

---
 .../MultiModelResponse/ContentRound.tsx       |  15 +-
 .../MultiModelResponse/MultiModelResponse.tsx | 340 ++++++++++--------
 2 files changed, 193 insertions(+), 162 deletions(-)

diff --git a/ui/src/components/MultiModelResponse/ContentRound.tsx b/ui/src/components/MultiModelResponse/ContentRound.tsx
index 662ef9a1..5db29dcd 100644
--- a/ui/src/components/MultiModelResponse/ContentRound.tsx
+++ b/ui/src/components/MultiModelResponse/ContentRound.tsx
@@ -87,19 +87,8 @@ function ContentRoundComponent({
         </div>
       );
     }
-    // No content — only show an indicator while actively streaming, hide when done
-    const isActive = isReasoningStreaming || isToolsStreaming;
-    if (!isActive) return null;
-    return (
-      <div className="flex items-center gap-3 text-muted-foreground">
-        <div className="flex items-center gap-1.5 py-1">
-          <span className="h-2 w-2 rounded-full bg-muted-foreground/60 animate-typing-dot" />
-          <span className="h-2 w-2 rounded-full bg-muted-foreground/60 animate-typing-dot-delay-1" />
-          <span className="h-2 w-2 rounded-full bg-muted-foreground/60 animate-typing-dot-delay-2" />
-        </div>
-        <span className="text-sm">{hasReasoning ? "Thinking..." : "Processing..."}</span>
-      </div>
-    );
+    // No content in compact mode — parent manages status indicators
+    return null;
   }
 
   const layoutClass =
diff --git a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
index 7e2dc34a..9b614fd8 100644
--- a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
+++ b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
@@ -212,6 +212,14 @@ interface MultiModelResponseProps {
   historyMode?: HistoryMode;
 }
 
+/**
+ * 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);
@@ -221,7 +229,6 @@ function useContentStalled(content: string, isStreaming: boolean, thresholdMs =
       setStalled(false);
       return;
     }
-    // Content just changed — reset stall and start timer
     setStalled(false);
     const timer = setTimeout(() => setStalled(true), thresholdMs);
     return () => clearTimeout(timer);
@@ -230,12 +237,86 @@ function useContentStalled(content: string, isStreaming: boolean, thresholdMs =
   return stalled;
 }
 
-function TypingIndicator() {
+/**
+ * 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";
+    }
+
+    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<StreamingPhase, string> = {
+  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 (
-    <div className="flex items-center gap-1.5 py-1">
-      <span className="h-2 w-2 rounded-full bg-muted-foreground/60 animate-typing-dot" />
-      <span className="h-2 w-2 rounded-full bg-muted-foreground/60 animate-typing-dot-delay-1" />
-      <span className="h-2 w-2 rounded-full bg-muted-foreground/60 animate-typing-dot-delay-2" />
+    <div className="flex items-center gap-3 text-muted-foreground">
+      <div className="flex items-center gap-1.5 py-1">
+        <span className="h-2 w-2 rounded-full bg-muted-foreground/60 animate-typing-dot" />
+        <span className="h-2 w-2 rounded-full bg-muted-foreground/60 animate-typing-dot-delay-1" />
+        <span className="h-2 w-2 rounded-full bg-muted-foreground/60 animate-typing-dot-delay-2" />
+      </div>
+      <span className="text-sm">{PHASE_LABEL[phase]}</span>
     </div>
   );
 }
@@ -461,8 +542,8 @@ const ModelResponseCard = memo(function ModelResponseCard({
   const isComplete = !response.isStreaming && response.content && !response.error;
   const isAnyStreaming = useIsStreaming();
   const compactMode = useCompactMode();
-  const isContentStalled = useContentStalled(response.content, response.isStreaming);
   const hasActiveTools = useHasActiveToolCalls(model);
+  const streamingPhase = useStreamingPhase(response, hasActiveTools, compactMode);
 
   // State for artifact modal
   const [selectedArtifact, setSelectedArtifact] = useState<ArtifactType | null>(null);
@@ -813,160 +894,121 @@ const ModelResponseCard = memo(function ModelResponseCard({
             <AlertCircle className="h-5 w-5 shrink-0 mt-0.5" />
             <span className="text-sm leading-relaxed">{response.error}</span>
           </div>
-        ) : response.isStreaming &&
-          !response.content &&
-          !response.reasoningContent &&
-          !response.completedRounds?.length ? (
-          <div className="flex items-center gap-3 text-muted-foreground">
-            <TypingIndicator />
-            <span className="text-sm">Thinking...</span>
+        ) : isEditing ? (
+          <div className="flex flex-col gap-3">
+            <Textarea
+              ref={textareaRef}
+              value={editContent}
+              onChange={(e) => setEditContent(e.target.value)}
+              onKeyDown={handleEditKeyDown}
+              className="min-h-[200px] resize-y font-mono text-sm"
+              placeholder="Edit response..."
+            />
+            <div className="flex items-center justify-between">
+              <span className="text-xs text-muted-foreground">
+                Ctrl+Enter to save · Escape to cancel
+              </span>
+              <div className="flex gap-2">
+                <Button variant="ghost" size="sm" onClick={handleCancelEdit}>
+                  <X className="h-3 w-3 mr-1" />
+                  Cancel
+                </Button>
+                <Button
+                  variant="primary"
+                  size="sm"
+                  onClick={handleSaveEditClick}
+                  disabled={!editContent.trim() || editContent === response.content}
+                >
+                  Save
+                </Button>
+              </div>
+            </div>
           </div>
         ) : (
           <>
-            {/* Reasoning + content — interleaved per round for multi-round, or single block */}
-            {isEditing ? (
-              <div className="flex flex-col gap-3">
-                <Textarea
-                  ref={textareaRef}
-                  value={editContent}
-                  onChange={(e) => setEditContent(e.target.value)}
-                  onKeyDown={handleEditKeyDown}
-                  className="min-h-[200px] resize-y font-mono text-sm"
-                  placeholder="Edit response..."
-                />
-                <div className="flex items-center justify-between">
-                  <span className="text-xs text-muted-foreground">
-                    Ctrl+Enter to save · Escape to cancel
-                  </span>
-                  <div className="flex gap-2">
-                    <Button variant="ghost" size="sm" onClick={handleCancelEdit}>
-                      <X className="h-3 w-3 mr-1" />
-                      Cancel
-                    </Button>
-                    <Button
-                      variant="primary"
-                      size="sm"
-                      onClick={handleSaveEditClick}
-                      disabled={!editContent.trim() || editContent === response.content}
-                    >
-                      Save
-                    </Button>
-                  </div>
-                </div>
-              </div>
-            ) : (
-              (() => {
-                const rounds = response.completedRounds;
-                if (rounds && rounds.length > 0) {
-                  // Multi-round: render each round using its bundled toolExecution
-                  const currentReasoning =
-                    response.reasoningContent &&
-                    !rounds.some((r) => r.reasoning === response.reasoningContent)
-                      ? response.reasoningContent
-                      : null;
-                  // During streaming, content field holds just the current round's text
-                  // (pushCompletedRound resets it). For committed messages, it's empty/null.
-                  const currentContent =
-                    response.isStreaming && response.content?.trim() ? response.content : null;
-                  return (
-                    <div className="space-y-3">
-                      {rounds.map((round, i) => (
-                        <ContentRound
-                          key={i}
-                          reasoning={round.reasoning}
-                          content={round.content}
-                          toolExecutionRound={round.toolExecution}
-                          isToolsStreaming={
-                            response.isStreaming &&
-                            i === rounds.length - 1 &&
-                            !!round.toolExecution?.executions.some(
-                              (e) => e.status === "pending" || e.status === "running"
-                            )
-                          }
-                          onArtifactClick={handleArtifactClick}
-                          displaySelection={
-                            round.toolExecution
-                              ? getDisplaySelectionForRound(round.toolExecution)
-                              : null
-                          }
-                          allOutputArtifacts={allOutputArtifacts}
-                        />
-                      ))}
-                      {/* Current streaming round (not yet in completedRounds) */}
-                      {(currentReasoning || currentContent) && (
-                        <ContentRound
-                          reasoning={currentReasoning}
-                          content={currentContent}
-                          isStreaming={response.isStreaming}
-                          isReasoningStreaming={response.isStreaming && !currentContent}
-                        />
-                      )}
-                      {/* Between-round indicator: streaming but no new content yet */}
-                      {response.isStreaming &&
-                        !currentReasoning &&
-                        !currentContent &&
-                        (() => {
-                          const lastRound = rounds[rounds.length - 1];
-                          const isProcessing = lastRound?.toolExecution?.executions.some(
+            {/* Content: rounds or single-round reasoning + content */}
+            {(() => {
+              const rounds = response.completedRounds;
+              if (rounds && rounds.length > 0) {
+                // Multi-round: render each completed round + in-flight round
+                const currentReasoning =
+                  response.reasoningContent &&
+                  !rounds.some((r) => r.reasoning === response.reasoningContent)
+                    ? response.reasoningContent
+                    : null;
+                const currentContent =
+                  response.isStreaming && response.content?.trim() ? response.content : null;
+                return (
+                  <div className="space-y-3">
+                    {rounds.map((round, i) => (
+                      <ContentRound
+                        key={i}
+                        reasoning={round.reasoning}
+                        content={round.content}
+                        toolExecutionRound={round.toolExecution}
+                        isToolsStreaming={
+                          response.isStreaming &&
+                          i === rounds.length - 1 &&
+                          !!round.toolExecution?.executions.some(
                             (e) => e.status === "pending" || e.status === "running"
-                          );
-                          return (
-                            <div className="flex items-center gap-3 text-muted-foreground">
-                              <TypingIndicator />
-                              <span className="text-sm">
-                                {isProcessing ? "Processing..." : "Thinking..."}
-                              </span>
-                            </div>
-                          );
-                        })()}
-                    </div>
-                  );
-                }
-                // Single-round: reasoning then content
-                const singleReasoning =
-                  response.reasoningContent || response.usage?.reasoningContent;
-                if (compactMode) {
-                  // Compact: show content only, with a "Thinking" indicator while actively streaming reasoning
-                  return (
-                    <>
-                      {singleReasoning && !response.content && response.isStreaming && (
-                        <div className="flex items-center gap-3 text-muted-foreground">
-                          <TypingIndicator />
-                          <span className="text-sm">Thinking...</span>
-                        </div>
-                      )}
-                      {response.content && (
-                        <StreamingMarkdown
-                          content={response.content}
-                          isStreaming={response.isStreaming}
-                        />
-                      )}
-                    </>
-                  );
-                }
+                          )
+                        }
+                        onArtifactClick={handleArtifactClick}
+                        displaySelection={
+                          round.toolExecution
+                            ? getDisplaySelectionForRound(round.toolExecution)
+                            : null
+                        }
+                        allOutputArtifacts={allOutputArtifacts}
+                      />
+                    ))}
+                    {(currentReasoning || currentContent) && (
+                      <ContentRound
+                        reasoning={currentReasoning}
+                        content={currentContent}
+                        isStreaming={response.isStreaming}
+                        isReasoningStreaming={response.isStreaming && !currentContent}
+                      />
+                    )}
+                    <StreamingStatusIndicator phase={streamingPhase} />
+                  </div>
+                );
+              }
+              // Single-round: reasoning then content
+              const singleReasoning = response.reasoningContent || response.usage?.reasoningContent;
+              if (compactMode) {
                 return (
                   <>
-                    {singleReasoning && (
-                      <ReasoningSection
-                        content={singleReasoning}
-                        isStreaming={response.isStreaming && !response.content}
-                        tokenCount={response.usage?.reasoningTokens}
+                    {response.content && (
+                      <StreamingMarkdown
+                        content={response.content}
+                        isStreaming={response.isStreaming}
                       />
                     )}
+                  </>
+                );
+              }
+              return (
+                <>
+                  {singleReasoning && (
+                    <ReasoningSection
+                      content={singleReasoning}
+                      isStreaming={response.isStreaming && !response.content}
+                      tokenCount={response.usage?.reasoningTokens}
+                    />
+                  )}
+                  {response.content && (
                     <StreamingMarkdown
                       content={response.content}
                       isStreaming={response.isStreaming}
                     />
-                  </>
-                );
-              })()
-            )}
-            {/* Stall indicator: prominent status when content stops flowing mid-stream */}
-            {isContentStalled && (
-              <div className="flex items-center gap-3 text-muted-foreground mt-2">
-                <TypingIndicator />
-                <span className="text-sm">{hasActiveTools ? "Processing..." : "Thinking..."}</span>
-              </div>
+                  )}
+                </>
+              );
+            })()}
+            {/* Streaming status indicator (outside multi-round div for single-round paths) */}
+            {!response.completedRounds?.length && (
+              <StreamingStatusIndicator phase={streamingPhase} />
             )}
             {/* Citations from file_search/web_search */}
             {hasCitations && (

From 49db7ab3fe8ad7b79953baf7fb28309980843576 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Sun, 22 Mar 2026 13:11:06 +1000
Subject: [PATCH 17/23] Chat UI details as two buttons

---
 .../MultiModelResponse/MultiModelResponse.tsx | 46 ++++++++++++-------
 1 file changed, 29 insertions(+), 17 deletions(-)

diff --git a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
index 9b614fd8..85c76b54 100644
--- a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
+++ b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
@@ -1308,24 +1308,36 @@ function MultiModelResponseComponent({
             </Tooltip>
           </div>
         )}
-        <Tooltip>
-          <TooltipTrigger asChild>
-            <Button
-              variant={compactMode ? "secondary" : "ghost"}
-              size="sm"
-              className="h-6 w-6 p-0 rounded-md border bg-muted/50"
-              onClick={toggleCompactMode}
-              aria-label={compactMode ? "Show reasoning & tools" : "Compact view"}
-            >
-              {compactMode ? (
-                <ListChevronsDownUp className="h-3.5 w-3.5" />
-              ) : (
+        <div className="flex items-center gap-0.5 rounded-md border bg-muted/50 p-0.5">
+          <Tooltip>
+            <TooltipTrigger asChild>
+              <Button
+                variant={!compactMode ? "secondary" : "ghost"}
+                size="sm"
+                className="h-6 w-6 p-0"
+                onClick={() => compactMode && toggleCompactMode()}
+                aria-label="Show reasoning & tools"
+              >
                 <ListChevronsUpDown className="h-3.5 w-3.5" />
-              )}
-            </Button>
-          </TooltipTrigger>
-          <TooltipContent>{compactMode ? "Show reasoning & tools" : "Compact view"}</TooltipContent>
-        </Tooltip>
+              </Button>
+            </TooltipTrigger>
+            <TooltipContent>Show reasoning & tools</TooltipContent>
+          </Tooltip>
+          <Tooltip>
+            <TooltipTrigger asChild>
+              <Button
+                variant={compactMode ? "secondary" : "ghost"}
+                size="sm"
+                className="h-6 w-6 p-0"
+                onClick={() => !compactMode && toggleCompactMode()}
+                aria-label="Compact view"
+              >
+                <ListChevronsDownUp className="h-3.5 w-3.5" />
+              </Button>
+            </TooltipTrigger>
+            <TooltipContent>Compact view</TooltipContent>
+          </Tooltip>
+        </div>
         <div className="h-px flex-1 bg-border" />
       </div>
 

From 1a43f9567a12f862e8270c1949ecc6c4d7a91693 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Sun, 22 Mar 2026 13:18:57 +1000
Subject: [PATCH 18/23] Make thinking indicator nicer

---
 .../MultiModelResponse/MultiModelResponse.tsx    | 16 ++++++++--------
 1 file changed, 8 insertions(+), 8 deletions(-)

diff --git a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
index 85c76b54..fb0d025c 100644
--- a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
+++ b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
@@ -302,21 +302,21 @@ function useStreamingPhase(
 
 const PHASE_LABEL: Record<StreamingPhase, string> = {
   idle: "",
-  thinking: "Thinking...",
-  processing: "Processing...",
+  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 (
-    <div className="flex items-center gap-3 text-muted-foreground">
-      <div className="flex items-center gap-1.5 py-1">
-        <span className="h-2 w-2 rounded-full bg-muted-foreground/60 animate-typing-dot" />
-        <span className="h-2 w-2 rounded-full bg-muted-foreground/60 animate-typing-dot-delay-1" />
-        <span className="h-2 w-2 rounded-full bg-muted-foreground/60 animate-typing-dot-delay-2" />
-      </div>
+    <div className="flex items-baseline gap-1.5 pl-3 border-l-2 border-transparent text-muted-foreground">
       <span className="text-sm">{PHASE_LABEL[phase]}</span>
+      <div className="flex items-baseline gap-1 py-1">
+        <span className="h-1 w-1 rounded-full bg-muted-foreground/60 animate-typing-dot" />
+        <span className="h-1 w-1 rounded-full bg-muted-foreground/60 animate-typing-dot-delay-1" />
+        <span className="h-1 w-1 rounded-full bg-muted-foreground/60 animate-typing-dot-delay-2" />
+      </div>
     </div>
   );
 }

From 1e9762fdd7d407193c65094789fbcea257b03628 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Sun, 22 Mar 2026 13:28:59 +1000
Subject: [PATCH 19/23] Fix missing reasoning indicator

---
 ui/src/components/MultiModelResponse/MultiModelResponse.tsx | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
index fb0d025c..f45237d9 100644
--- a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
+++ b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
@@ -283,6 +283,11 @@ function useStreamingPhase(
       return hasActiveTools ? "processing" : "thinking";
     }
 
+    // Compact mode hides reasoning — show thinking when only reasoning is flowing
+    if (compactMode && currentReasoning && !currentContent) {
+      return "thinking";
+    }
+
     return "idle";
   }
 

From a5450a994a47501e8e2522493706f3513dc39925 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Sun, 22 Mar 2026 13:45:07 +1000
Subject: [PATCH 20/23] Update compact UI

---
 .../MultiModelResponse/MultiModelResponse.tsx | 24 +++++++++----------
 ui/src/stores/chatUIStore.ts                  |  4 ++--
 2 files changed, 14 insertions(+), 14 deletions(-)

diff --git a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
index f45237d9..1a391c24 100644
--- a/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
+++ b/ui/src/components/MultiModelResponse/MultiModelResponse.tsx
@@ -8,8 +8,8 @@ import {
   Eye,
   EyeOff,
   GitFork,
-  ListChevronsDownUp,
-  ListChevronsUpDown,
+  MessageSquare,
+  MessageSquarePlus,
   Loader2,
   MoreHorizontal,
   Pencil,
@@ -1317,30 +1317,30 @@ function MultiModelResponseComponent({
           <Tooltip>
             <TooltipTrigger asChild>
               <Button
-                variant={!compactMode ? "secondary" : "ghost"}
+                variant={compactMode ? "secondary" : "ghost"}
                 size="sm"
                 className="h-6 w-6 p-0"
-                onClick={() => compactMode && toggleCompactMode()}
-                aria-label="Show reasoning & tools"
+                onClick={() => !compactMode && toggleCompactMode()}
+                aria-label="Compact view"
               >
-                <ListChevronsUpDown className="h-3.5 w-3.5" />
+                <MessageSquare className="h-3.5 w-3.5" />
               </Button>
             </TooltipTrigger>
-            <TooltipContent>Show reasoning & tools</TooltipContent>
+            <TooltipContent>Compact view</TooltipContent>
           </Tooltip>
           <Tooltip>
             <TooltipTrigger asChild>
               <Button
-                variant={compactMode ? "secondary" : "ghost"}
+                variant={!compactMode ? "secondary" : "ghost"}
                 size="sm"
                 className="h-6 w-6 p-0"
-                onClick={() => !compactMode && toggleCompactMode()}
-                aria-label="Compact view"
+                onClick={() => compactMode && toggleCompactMode()}
+                aria-label="Show reasoning & tools"
               >
-                <ListChevronsDownUp className="h-3.5 w-3.5" />
+                <MessageSquarePlus className="h-3.5 w-3.5" />
               </Button>
             </TooltipTrigger>
-            <TooltipContent>Compact view</TooltipContent>
+            <TooltipContent>Show reasoning & tools</TooltipContent>
           </Tooltip>
         </div>
         <div className="h-px flex-1 bg-border" />
diff --git a/ui/src/stores/chatUIStore.ts b/ui/src/stores/chatUIStore.ts
index 5044d14e..17107195 100644
--- a/ui/src/stores/chatUIStore.ts
+++ b/ui/src/stores/chatUIStore.ts
@@ -352,9 +352,9 @@ function loadViewMode(): ViewMode {
 
 function loadCompactMode(): boolean {
   try {
-    return localStorage.getItem("hadrian:compactMode") === "true";
+    return localStorage.getItem("hadrian:compactMode") !== "false";
   } catch {
-    return false;
+    return true;
   }
 }
 

From 99ddb1fe772cbff65c9a52f0cd9f3f7949da8dd7 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Sun, 22 Mar 2026 15:40:00 +1000
Subject: [PATCH 21/23] Fix storybook

---
 .../components/ChatView/ChatView.stories.tsx  | 38 ++++++++++---------
 .../MultiModelResponse.stories.tsx            |  7 ++--
 2 files changed, 25 insertions(+), 20 deletions(-)

diff --git a/ui/src/components/ChatView/ChatView.stories.tsx b/ui/src/components/ChatView/ChatView.stories.tsx
index 7052de16..a033a387 100644
--- a/ui/src/components/ChatView/ChatView.stories.tsx
+++ b/ui/src/components/ChatView/ChatView.stories.tsx
@@ -100,23 +100,27 @@ const meta: Meta<typeof ChatView> = {
     },
   },
   decorators: [
-    (Story) => (
-      <QueryClientProvider client={queryClient}>
-        <ConfigProvider>
-          <AuthProvider>
-            <PreferencesProvider>
-              <ToastProvider>
-                <TooltipProvider>
-                  <div className="h-screen">
-                    <Story />
-                  </div>
-                </TooltipProvider>
-              </ToastProvider>
-            </PreferencesProvider>
-          </AuthProvider>
-        </ConfigProvider>
-      </QueryClientProvider>
-    ),
+    (Story) => {
+      // Show reasoning & tools in tests
+      useChatUIStore.setState({ compactMode: false });
+      return (
+        <QueryClientProvider client={queryClient}>
+          <ConfigProvider>
+            <AuthProvider>
+              <PreferencesProvider>
+                <ToastProvider>
+                  <TooltipProvider>
+                    <div className="h-screen">
+                      <Story />
+                    </div>
+                  </TooltipProvider>
+                </ToastProvider>
+              </PreferencesProvider>
+            </AuthProvider>
+          </ConfigProvider>
+        </QueryClientProvider>
+      );
+    },
   ],
 };
 
diff --git a/ui/src/components/MultiModelResponse/MultiModelResponse.stories.tsx b/ui/src/components/MultiModelResponse/MultiModelResponse.stories.tsx
index aa8a426d..672b68ab 100644
--- a/ui/src/components/MultiModelResponse/MultiModelResponse.stories.tsx
+++ b/ui/src/components/MultiModelResponse/MultiModelResponse.stories.tsx
@@ -19,6 +19,7 @@ const meta: Meta<typeof MultiModelResponse> = {
         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({
@@ -145,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"]');
@@ -544,7 +545,7 @@ export const WithToolCallSearching: Story = {
     const canvas = within(canvasElement);
 
     // While streaming with no content, shows Thinking indicator
-    await expect(canvas.getByText("Thinking...")).toBeInTheDocument();
+    await expect(canvas.getByText("Thinking")).toBeInTheDocument();
   },
 };
 

From 4cdd07c2e6d2e13c1b102ecd2cef1ca2931b286a Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Sun, 22 Mar 2026 21:00:18 +1000
Subject: [PATCH 22/23] Review fixes

---
 .../ChatMessageList/ChatMessageList.tsx         | 17 ++++++++++++++---
 ui/src/pages/chat/useChat.ts                    |  2 +-
 2 files changed, 15 insertions(+), 4 deletions(-)

diff --git a/ui/src/components/ChatMessageList/ChatMessageList.tsx b/ui/src/components/ChatMessageList/ChatMessageList.tsx
index 659402e1..c8777996 100644
--- a/ui/src/components/ChatMessageList/ChatMessageList.tsx
+++ b/ui/src/components/ChatMessageList/ChatMessageList.tsx
@@ -358,8 +358,14 @@ export function ChatMessageList({
               {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.map((r) => r.instanceId ?? r.model ?? "")
+                  group.assistantResponses
+                    .filter((r) => !activeStreamingIds?.has(r.instanceId ?? r.model ?? ""))
+                    .map((r) => r.instanceId ?? r.model ?? "")
                 );
                 const showStreaming =
                   isLastGroup &&
@@ -367,6 +373,11 @@ export function ChatMessageList({
                   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 (
                   <div
                     key={group.id}
@@ -432,7 +443,7 @@ export function ChatMessageList({
                         </div>
                       </>
                     )}
-                    {group.assistantResponses.length > 0 && (
+                    {committedResponses.length > 0 && (
                       <>
                         {/* Show persisted mode indicators for chained/routed messages */}
                         {group.assistantResponses[0].modeMetadata?.mode === "routed" && (
@@ -605,7 +616,7 @@ export function ChatMessageList({
                           </div>
                         )}
                         <MultiModelResponse
-                          responses={group.assistantResponses.map((m) => {
+                          responses={committedResponses.map((m) => {
                             // Use instanceId if set, otherwise fall back to model for backwards compat
                             const instanceId = m.instanceId ?? m.model ?? "unknown";
                             return {
diff --git a/ui/src/pages/chat/useChat.ts b/ui/src/pages/chat/useChat.ts
index d0060b12..d6bfba2d 100644
--- a/ui/src/pages/chat/useChat.ts
+++ b/ui/src/pages/chat/useChat.ts
@@ -1595,7 +1595,7 @@ export function useChat({
         hasOutputText: true,
         usage: accumulatedUsage,
         reasoningContent: lastReasoningContent,
-        completedRounds: allCompletedRounds.length > 1 ? allCompletedRounds : undefined,
+        completedRounds: allCompletedRounds.length > 0 ? allCompletedRounds : undefined,
         toolExecutionRounds: executionRounds.length > 0 ? executionRounds : undefined,
       };
     },

From 0e54c673700dcc4364cd504db6c1068423ca0530 Mon Sep 17 00:00:00 2001
From: ScriptSmith <ScriptSmith@users.noreply.github.com>
Date: Sun, 22 Mar 2026 21:20:59 +1000
Subject: [PATCH 23/23] Add ContentRound story

---
 .../ContentRound.stories.tsx                  | 262 ++++++++++++++++++
 1 file changed, 262 insertions(+)
 create mode 100644 ui/src/components/MultiModelResponse/ContentRound.stories.tsx

diff --git a/ui/src/components/MultiModelResponse/ContentRound.stories.tsx b/ui/src/components/MultiModelResponse/ContentRound.stories.tsx
new file mode 100644
index 00000000..f035ed45
--- /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<typeof ContentRound> = {
+  title: "Chat/MultiModelResponse/ContentRound",
+  component: ContentRound,
+  parameters: {
+    layout: "padded",
+  },
+  decorators: [
+    (Story) => {
+      useChatUIStore.setState({
+        compactMode: false,
+        viewMode: "grid",
+        expandedModel: null,
+        editingKey: null,
+      });
+      return (
+        <PreferencesProvider>
+          <div style={{ maxWidth: 700 }}>
+            <Story />
+          </div>
+        </PreferencesProvider>
+      );
+    },
+  ],
+};
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+/** 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 (
+        <PreferencesProvider>
+          <div style={{ maxWidth: 700 }}>
+            <Story />
+          </div>
+        </PreferencesProvider>
+      );
+    },
+  ],
+  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 (
+        <PreferencesProvider>
+          <div style={{ maxWidth: 700 }} data-testid="wrapper">
+            <Story />
+          </div>
+        </PreferencesProvider>
+      );
+    },
+  ],
+  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")],
+  },
+};
