From 15d6fdbdde8109164ad1b567a8c3419e7755c09a Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Tue, 19 May 2026 14:14:52 +0000 Subject: [PATCH 1/8] studio/frontend: keep tool activity visible during multi-call bursts Trimmed scope of the original PR 5550: drops the two behaviour-changing pieces (autoscroll heuristic rewrite + chat-adapter re-injection) and keeps only the safe UI fixes: - reasoning.tsx: thinking panel renders whenever a reasoning part exists in the group, not just when reasoning is the last part. Fixes the panel flickering away during tool-heavy responses. - thread.tsx: pre-load aria-label reads "Thinking (model not loaded)" instead of the inverted "Disable thinking" when the button is disabled. Adds RunningToolIndicator at the bottom of the assistant bubble so the inflight tool name + one pulsing dot stays visible after the tool group scrolls off-screen. - tool-group.tsx: sticky-open while the assistant message is streaming and any tool has run, so back-to-back tool calls don't flicker the group closed between them. User click still overrides. - tool-fallback.tsx: defaultOpen when status.type === "running" so the inflight tool's row is visible without manual click. - shared-composer.tsx: Think / Search / Code pills aria-labels read "Thinking (model not loaded)" / "Web search (unavailable)" / "Code execution (unavailable)" while disabled, instead of the inverted "Disable thinking" / etc. Held back for separate review: - use-intent-aware-autoscroll.tsx heuristic change (this is iteration 6 of scroll-behaviour fixes in 2 months; tricky surface) - chat-adapter.ts re-injection on the local llama.cpp path (template-dependent; most reasoning templates strip it intentionally, preserve_thinking is the Qwen3.6 escape hatch) --- .../src/components/assistant-ui/reasoning.tsx | 11 +++-- .../src/components/assistant-ui/thread.tsx | 43 +++++++++++++++++-- .../components/assistant-ui/tool-fallback.tsx | 4 ++ .../components/assistant-ui/tool-group.tsx | 38 ++++++++++++++-- .../src/features/chat/shared-composer.tsx | 38 ++++++++-------- 5 files changed, 104 insertions(+), 30 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/reasoning.tsx b/studio/frontend/src/components/assistant-ui/reasoning.tsx index e4401cc12e..1b17194082 100644 --- a/studio/frontend/src/components/assistant-ui/reasoning.tsx +++ b/studio/frontend/src/components/assistant-ui/reasoning.tsx @@ -317,6 +317,8 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({ return false; } + // Streaming = group has reasoning AND no answer text after. + // Tool calls between reasoning and text keep the panel visible. let groupHasReasoning = false; for (let i = startIndex; i <= endIndex && i < len; i += 1) { if (parts[i]?.type === "reasoning") { @@ -324,13 +326,10 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({ break; } } - if (!groupHasReasoning) { - return false; - } + if (!groupHasReasoning) return false; for (let i = endIndex + 1; i < len; i += 1) { - if (parts[i]?.type !== "tool-call") { - return false; - } + const t = parts[i]?.type; + if (t === "text") return false; } return true; }); diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 0326b90a97..57e6c6ffb9 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -699,9 +699,11 @@ const ReasoningToggle: FC = () => { aria-label={ reasoningLockedOn ? "Thinking is required for this model" - : effectiveReasoningEnabled - ? "Disable thinking" - : "Enable thinking" + : disabled + ? "Thinking (model not loaded)" + : effectiveReasoningEnabled + ? "Disable thinking" + : "Enable thinking" } > {reasoningLockedOn || (effectiveReasoningEnabled && !disabled) ? ( @@ -1014,6 +1016,40 @@ const CancelledIndicator: FC = () => { ); }; +// Pins the running tool's name to the bottom of the assistant bubble +// so activity stays visible after the tool group scrolls off-screen. +const RunningToolIndicator: FC = () => { + const running = useAuiState(({ message }) => { + if (message.status?.type !== "running") return null; + const parts = message.parts; + for (let i = parts.length - 1; i >= 0; i -= 1) { + const p = parts[i] as + | { type?: string; toolName?: string; status?: { type?: string } } + | undefined; + if (p?.type === "tool-call" && p.status?.type === "running") { + return p.toolName ?? "tool"; + } + } + return null; + }); + if (!running) return null; + return ( +
+ + + Running {running}... + +
+ ); +}; + const AssistantMessage: FC = () => { return ( { }} /> + diff --git a/studio/frontend/src/components/assistant-ui/tool-fallback.tsx b/studio/frontend/src/components/assistant-ui/tool-fallback.tsx index e407163045..9715184535 100644 --- a/studio/frontend/src/components/assistant-ui/tool-fallback.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-fallback.tsx @@ -314,8 +314,12 @@ const ToolFallbackImpl: ToolCallMessagePartComponent = ({ const isCancelled = status?.type === "incomplete" && status.reason === "cancelled"; + // Auto-open while running so args + streaming result are visible. + const defaultOpen = status?.type === "running"; + return ( diff --git a/studio/frontend/src/components/assistant-ui/tool-group.tsx b/studio/frontend/src/components/assistant-ui/tool-group.tsx index e91da4cee2..d92f42279c 100644 --- a/studio/frontend/src/components/assistant-ui/tool-group.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-group.tsx @@ -3,11 +3,13 @@ import { memo, useCallback, + useEffect, useRef, useState, type FC, type PropsWithChildren, } from "react"; +import { useAuiState } from "@assistant-ui/react"; import { ChevronDownIcon, LoaderIcon } from "lucide-react"; import { Wrench01Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; @@ -210,14 +212,44 @@ const ToolGroupImpl: FC< > = ({ children, startIndex, endIndex }) => { const toolCount = endIndex - startIndex + 1; - // Single tool call — render directly without wrapper + // Any tool in this group running. Drives auto-open + trigger spin. + const hasRunning = useAuiState(({ message }) => { + const parts = message.parts; + for (let i = startIndex; i <= endIndex && i < parts.length; i += 1) { + const p = parts[i] as { type?: string; status?: { type?: string } } | undefined; + if (p?.type === "tool-call" && p.status?.type === "running") { + return true; + } + } + return false; + }); + + // Owning message still streaming. Keeps group sticky-open across + // back-to-back tool bursts where hasRunning flickers. + const messageStreaming = useAuiState( + ({ message }) => message.status?.type === "running", + ); + + const hasEverRunRef = useRef(false); + // Mutate ref in effect, not render, for StrictMode/concurrent safety. + useEffect(() => { + if (hasRunning) hasEverRunRef.current = true; + if (!messageStreaming) hasEverRunRef.current = false; + }, [hasRunning, messageStreaming]); + + // Auto-follow sticky predicate until user clicks to override. + const [userOpen, setUserOpen] = useState(null); + const autoOpen = hasRunning || (messageStreaming && hasEverRunRef.current); + const isOpen = userOpen ?? autoOpen; + + // Single tool call: render directly without wrapper. if (toolCount <= 1) { return <>{children}; } return ( - - + + {children} ); diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index 11a73ee486..91e3561248 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -523,19 +523,7 @@ export function SharedComposer({ handlesRef.current["model1"] || handlesRef.current["model2"], ); const isGeneralizedCompare = - hasCompareHandles && Boolean(model1?.id && model2?.id); - - // Generalized compare requires both panes to have a model. A - // half-selected send either races to an empty bubble with bogus - // tok/s (#5569) or leaves the empty pane with a dangling prompt. - // hasCompareHandles is true only in GeneralCompareContent, so - // LoraCompare and single-pane chats are unaffected. - if (hasCompareHandles && !isGeneralizedCompare) { - toast.error("Pick a model in each pane to compare", { - description: "Use the model dropdown above each pane, then send your prompt.", - }); - return; - } + hasCompareHandles && Boolean(model1?.id || model2?.id); if (pendingImages.length > 0 && !isGeneralizedCompare && imageUnavailableReason) { // Single mode: the loaded model's runtime capability is known @@ -989,9 +977,11 @@ export function SharedComposer({ aria-label={ reasoningLockedOn ? "Thinking is required for this model" - : effectiveReasoningEnabled - ? "Disable thinking" - : "Enable thinking" + : reasoningDisabled + ? "Thinking (model not loaded)" + : effectiveReasoningEnabled + ? "Disable thinking" + : "Enable thinking" } > {reasoningLockedOn || @@ -1047,7 +1037,13 @@ export function SharedComposer({ }} className="composer-pill-btn" data-active={toolsEnabled && !searchDisabled ? "true" : "false"} - aria-label={toolsEnabled ? "Disable web search" : "Enable web search"} + aria-label={ + searchDisabled + ? "Web search (unavailable)" + : toolsEnabled + ? "Disable web search" + : "Enable web search" + } > Search @@ -1058,7 +1054,13 @@ export function SharedComposer({ onClick={() => setCodeToolsEnabled(!codeToolsEnabled)} className="composer-pill-btn" data-active={codeToolsEnabled && !codeDisabled ? "true" : "false"} - aria-label={codeToolsEnabled ? "Disable code execution" : "Enable code execution"} + aria-label={ + codeDisabled + ? "Code execution (unavailable)" + : codeToolsEnabled + ? "Disable code execution" + : "Enable code execution" + } > Code From bede2309997d9300b9dc07cb98dfe2b904c317e6 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Tue, 19 May 2026 14:21:25 +0000 Subject: [PATCH 2/8] studio/frontend: scope shared-composer changes to aria-label only Previous force-push checked out the original PR 5550 version of shared-composer.tsx, which was based on a commit before PR 5574 landed. That re-introduced the buggy compare-mode guard (model1 || model2 instead of model1 && model2) and removed PR 5574's toast that blocks half- configured compare sends. Fix: reset shared-composer.tsx to current main and re-apply only the three aria-label additions (Think / Search / Code pills get correct labels when their backing capability is unavailable). PR 5574's compare guard stays intact. Probe diff now: +19/-5 (was +20/-18 with the regression). --- .../frontend/src/features/chat/shared-composer.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index 91e3561248..11a471c3b5 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -523,7 +523,19 @@ export function SharedComposer({ handlesRef.current["model1"] || handlesRef.current["model2"], ); const isGeneralizedCompare = - hasCompareHandles && Boolean(model1?.id || model2?.id); + hasCompareHandles && Boolean(model1?.id && model2?.id); + + // Generalized compare requires both panes to have a model. A + // half-selected send either races to an empty bubble with bogus + // tok/s (#5569) or leaves the empty pane with a dangling prompt. + // hasCompareHandles is true only in GeneralCompareContent, so + // LoraCompare and single-pane chats are unaffected. + if (hasCompareHandles && !isGeneralizedCompare) { + toast.error("Pick a model in each pane to compare", { + description: "Use the model dropdown above each pane, then send your prompt.", + }); + return; + } if (pendingImages.length > 0 && !isGeneralizedCompare && imageUnavailableReason) { // Single mode: the loaded model's runtime capability is known From e174126049c0d695ed42f216a986e88ed4ffca8a Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Tue, 19 May 2026 14:27:12 +0000 Subject: [PATCH 3/8] studio/frontend: surface activity during between-tool gap A probe with Qwen3.6-27B UD-Q4_K_XL + Think+Search+Code on "Create a Python game" reproduced the user-reported "chat looks frozen" symptom: after the model emitted a one-line intent ("I'll create... Let me build it:"), neither GeneratingIndicator nor the existing RunningToolIndicator showed for ~10+ seconds while the model was choosing its first tool. GeneratingIndicator hides once content.length > 0 and the prior RunningToolIndicator only fires when a specific tool-call part has status == 'running'. Between those two phases (model writing tool-call decision tokens) the UI went silent and the only sign that work was in flight was the Stop button. Extend RunningToolIndicator to also fall back to a generic 'Working...' when the message status is still 'running' and at least one part has been emitted but no tool-call is currently running. The pulsing dot and aria-live region are unchanged; only the inner text differs. --- .../src/components/assistant-ui/thread.tsx | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 57e6c6ffb9..a7425df114 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -1016,23 +1016,37 @@ const CancelledIndicator: FC = () => { ); }; -// Pins the running tool's name to the bottom of the assistant bubble -// so activity stays visible after the tool group scrolls off-screen. +// Pins activity to the bottom of the assistant bubble so the chat +// doesn't look frozen during the gap between "model wrote intent" and +// "tool actually invoked", or between back-to-back tool calls. +// - When a tool-call part is currently `running`, shows the tool name. +// - When the message is still streaming but no tool is mid-execution +// (e.g. model is generating the tool-call decision tokens, or has +// just rendered the lead-in text "Let me build it:"), falls back to +// a neutral "Working..." so the user sees that work is still happening. const RunningToolIndicator: FC = () => { - const running = useAuiState(({ message }) => { + const state = useAuiState(({ message }) => { if (message.status?.type !== "running") return null; const parts = message.parts; + // Most recent running tool wins, if any. for (let i = parts.length - 1; i >= 0; i -= 1) { const p = parts[i] as | { type?: string; toolName?: string; status?: { type?: string } } | undefined; if (p?.type === "tool-call" && p.status?.type === "running") { - return p.toolName ?? "tool"; + return { kind: "tool" as const, name: p.toolName ?? "tool" }; } } + // No tool currently running, but message itself is still streaming. + // GeneratingIndicator only shows while content.length === 0, so once + // any text has rendered we lose its dots. Surface a generic "Working..." + // so the chat doesn't go silent between phases. + if (parts.length > 0) { + return { kind: "working" as const }; + } return null; }); - if (!running) return null; + if (!state) return null; return (
{ aria-hidden className="aui-running-tool-indicator-dot inline-block size-2 animate-pulse rounded-full bg-muted-foreground/60" /> - - Running {running}... - + {state.kind === "tool" ? ( + + Running {state.name}... + + ) : ( + Working... + )}
); }; From 7147f423e25d344461cd18d6ef2434571c09a696 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Tue, 19 May 2026 14:28:36 +0000 Subject: [PATCH 4/8] studio/frontend: return string from RunningToolIndicator selector Returning a fresh object literal from useAuiState's selector each render breaks assistant-ui's referential-equality memo and causes infinite re-render loops (verified: the page crashed to React's 'Something went wrong' boundary). Return a sentinel string instead ('tool:' | 'working' | '') and parse it in the render body so selector identity stays stable. --- .../src/components/assistant-ui/thread.tsx | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index a7425df114..70fb8510f4 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -1019,34 +1019,32 @@ const CancelledIndicator: FC = () => { // Pins activity to the bottom of the assistant bubble so the chat // doesn't look frozen during the gap between "model wrote intent" and // "tool actually invoked", or between back-to-back tool calls. -// - When a tool-call part is currently `running`, shows the tool name. -// - When the message is still streaming but no tool is mid-execution -// (e.g. model is generating the tool-call decision tokens, or has -// just rendered the lead-in text "Let me build it:"), falls back to -// a neutral "Working..." so the user sees that work is still happening. +// +// Return value is a string ("tool:" | "working" | "") rather than +// an object so useAuiState's referential-equality memoization holds +// across renders -- returning a fresh object each render would invalidate +// equality and force the component to rerender every frame. const RunningToolIndicator: FC = () => { - const state = useAuiState(({ message }) => { - if (message.status?.type !== "running") return null; + const signal = useAuiState(({ message }) => { + if (message.status?.type !== "running") return ""; const parts = message.parts; - // Most recent running tool wins, if any. for (let i = parts.length - 1; i >= 0; i -= 1) { const p = parts[i] as | { type?: string; toolName?: string; status?: { type?: string } } | undefined; if (p?.type === "tool-call" && p.status?.type === "running") { - return { kind: "tool" as const, name: p.toolName ?? "tool" }; + return `tool:${p.toolName ?? "tool"}`; } } - // No tool currently running, but message itself is still streaming. - // GeneratingIndicator only shows while content.length === 0, so once - // any text has rendered we lose its dots. Surface a generic "Working..." + // No tool currently running, but message is still streaming. + // GeneratingIndicator hides once content.length > 0, so once any + // text has rendered we lose its dots. Surface a generic "Working..." // so the chat doesn't go silent between phases. - if (parts.length > 0) { - return { kind: "working" as const }; - } - return null; + if (parts.length > 0) return "working"; + return ""; }); - if (!state) return null; + if (!signal) return null; + const toolName = signal.startsWith("tool:") ? signal.slice(5) : null; return (
{ aria-hidden className="aui-running-tool-indicator-dot inline-block size-2 animate-pulse rounded-full bg-muted-foreground/60" /> - {state.kind === "tool" ? ( + {toolName ? ( - Running {state.name}... + Running {toolName}... ) : ( Working... From d64b4b84f634683ee1bc7cc5ffec76d77af5635a Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Tue, 19 May 2026 14:35:36 +0000 Subject: [PATCH 5/8] studio/frontend: scope Working fallback to tool-using responses only Codex P2 flagged that the 'Working...' fallback fired for every streaming text-only response. For a plain 'What is 2+2?' answer the indicator briefly showed alongside the visible streaming text, which was misleading. Fix: track whether any tool-call part exists in the message (running OR completed) and only emit the 'Working...' fallback when tools have been in play. Pure text-only responses skip the fallback entirely; tool gaps and back-to-back tool calls still get the indicator. --- .../src/components/assistant-ui/thread.tsx | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 70fb8510f4..bd070fa576 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -1020,6 +1020,11 @@ const CancelledIndicator: FC = () => { // doesn't look frozen during the gap between "model wrote intent" and // "tool actually invoked", or between back-to-back tool calls. // +// Scope: only fires for tool-using responses. Plain text-only answers +// keep using GeneratingIndicator (while content is empty) and then +// nothing -- otherwise every text streaming response would get a +// misleading "Working..." row. +// // Return value is a string ("tool:" | "working" | "") rather than // an object so useAuiState's referential-equality memoization holds // across renders -- returning a fresh object each render would invalidate @@ -1028,19 +1033,24 @@ const RunningToolIndicator: FC = () => { const signal = useAuiState(({ message }) => { if (message.status?.type !== "running") return ""; const parts = message.parts; + let sawToolCall = false; + let runningTool: string | null = null; + // Single pass: find any tool-call part (signals tools-are-in-play) + // and the most-recent running one (drives the specific indicator). for (let i = parts.length - 1; i >= 0; i -= 1) { const p = parts[i] as | { type?: string; toolName?: string; status?: { type?: string } } | undefined; - if (p?.type === "tool-call" && p.status?.type === "running") { - return `tool:${p.toolName ?? "tool"}`; + if (p?.type !== "tool-call") continue; + sawToolCall = true; + if (!runningTool && p.status?.type === "running") { + runningTool = p.toolName ?? "tool"; } } - // No tool currently running, but message is still streaming. - // GeneratingIndicator hides once content.length > 0, so once any - // text has rendered we lose its dots. Surface a generic "Working..." - // so the chat doesn't go silent between phases. - if (parts.length > 0) return "working"; + if (runningTool) return `tool:${runningTool}`; + // Only fall back to "Working..." if tools have been in play. A pure + // text-only response (no tool-call part ever) skips the fallback. + if (sawToolCall) return "working"; return ""; }); if (!signal) return null; From b9fcb1ba5678446312b0d1b9b46c64f73a8eece1 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Tue, 19 May 2026 14:50:06 +0000 Subject: [PATCH 6/8] studio/frontend: restore reasoning-group streaming termination on non-tool parts Codex review on 7147f423e2 caught a regression in the streaming gate loop. The previous change stopped streaming only when a 'text' part appeared after endIndex, which left earlier reasoning groups stuck in their streaming state when a later reasoning group started in the same message (reasoning -> tool-call -> reasoning -> answer). Revert that loop to the original assistant-ui semantics: any non-tool-call part after this group's end means a fresh segment has started, so this group is no longer the active stream. The 'tool calls between reasoning and text keep the panel visible' goal of #5550 is still satisfied because tool-call parts within the group's own startIndex..endIndex range and immediately after it still keep the group open. --- .../src/components/assistant-ui/reasoning.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/reasoning.tsx b/studio/frontend/src/components/assistant-ui/reasoning.tsx index 1b17194082..f98a10947f 100644 --- a/studio/frontend/src/components/assistant-ui/reasoning.tsx +++ b/studio/frontend/src/components/assistant-ui/reasoning.tsx @@ -317,8 +317,6 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({ return false; } - // Streaming = group has reasoning AND no answer text after. - // Tool calls between reasoning and text keep the panel visible. let groupHasReasoning = false; for (let i = startIndex; i <= endIndex && i < len; i += 1) { if (parts[i]?.type === "reasoning") { @@ -326,10 +324,16 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({ break; } } - if (!groupHasReasoning) return false; + if (!groupHasReasoning) { + return false; + } + // After this group ends, any non-tool-call part means a fresh + // segment has started (text answer or a later reasoning group), + // so this group is no longer the active stream. for (let i = endIndex + 1; i < len; i += 1) { - const t = parts[i]?.type; - if (t === "text") return false; + if (parts[i]?.type !== "tool-call") { + return false; + } } return true; }); From e379958b783aa66956beb92ff54e9f5e5476ba64 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Tue, 19 May 2026 15:02:30 +0000 Subject: [PATCH 7/8] studio/frontend: hide RunningToolIndicator once final answer streams Codex review at 14:52Z on commit d64b4b84 caught a follow-on UX bug. The fallback returned "working" whenever ANY tool-call part existed in the message, including AFTER the last tool finished and the model started streaming its text answer. That left a misleading pulsing "Working..." row under visibly-streaming prose for the entire final answer. Tighten the selector to walk parts from the tail and decide based on the last meaningful part type: - text part with non-empty content -> indicator hidden (answer is currently streaming, so any pulse is misleading); - reasoning part -> indicator hidden (the reasoning panel already shows progress); - tool-call part with status=running -> "Running ..."; - tool-call part otherwise (completed, awaiting next answer) -> "Working..."; - empty text placeholder or unknown part type -> keep walking. In the common reasoning -> tool -> tool-result -> text path, the indicator now appears only while a tool is actually executing or during the tool->text boundary, never under the streaming answer. --- .../src/components/assistant-ui/thread.tsx | 41 +++++++++++-------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index bd070fa576..d35f223142 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -1020,10 +1020,10 @@ const CancelledIndicator: FC = () => { // doesn't look frozen during the gap between "model wrote intent" and // "tool actually invoked", or between back-to-back tool calls. // -// Scope: only fires for tool-using responses. Plain text-only answers -// keep using GeneratingIndicator (while content is empty) and then -// nothing -- otherwise every text streaming response would get a -// misleading "Working..." row. +// Scope: only fires when the tail of the message has no streaming +// answer content yet. Once the model has emitted reasoning or non-empty +// text after the last tool, those parts already visibly show progress +// and the "Working..." row would be misleading. // // Return value is a string ("tool:" | "working" | "") rather than // an object so useAuiState's referential-equality memoization holds @@ -1033,24 +1033,31 @@ const RunningToolIndicator: FC = () => { const signal = useAuiState(({ message }) => { if (message.status?.type !== "running") return ""; const parts = message.parts; - let sawToolCall = false; - let runningTool: string | null = null; - // Single pass: find any tool-call part (signals tools-are-in-play) - // and the most-recent running one (drives the specific indicator). + // Walk from the tail. The first meaningful part decides what's + // currently visible to the user: a streaming text/reasoning part + // means the answer is already moving and the indicator must stay + // hidden; a tool-call part means we're either mid-tool (specific + // "Running ..." label) or in a pending gap before the next answer + // segment ("Working..."). for (let i = parts.length - 1; i >= 0; i -= 1) { const p = parts[i] as - | { type?: string; toolName?: string; status?: { type?: string } } + | { type?: string; text?: string; toolName?: string; status?: { type?: string } } | undefined; - if (p?.type !== "tool-call") continue; - sawToolCall = true; - if (!runningTool && p.status?.type === "running") { - runningTool = p.toolName ?? "tool"; + const t = p?.type; + if (t === "text") { + // Non-empty text after the last tool -> model is streaming the + // final answer. Skip empty text placeholders that some adapters + // emit before the first delta. + if (typeof p?.text === "string" && p.text.length > 0) return ""; + continue; } + if (t === "reasoning") return ""; + if (t === "tool-call") { + if (p?.status?.type === "running") return `tool:${p.toolName ?? "tool"}`; + return "working"; + } + // Other part types (source attachments, etc.) -- keep walking. } - if (runningTool) return `tool:${runningTool}`; - // Only fall back to "Working..." if tools have been in play. A pure - // text-only response (no tool-call part ever) skips the fallback. - if (sawToolCall) return "working"; return ""; }); if (!signal) return null; From c52bd6c57e7efd332e23a65d3cfb03431f59ed49 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Tue, 19 May 2026 16:42:00 +0000 Subject: [PATCH 8/8] studio/frontend: surface tool-call activity during Generating phase Until now, the assistant message rendered a static `Generating...` label whenever `message.content.length === 0 && status === "running"`. That covers the moment between Send and the first chunk landing, but also covers the much-longer window where the model is mid-way through emitting a `...` block: the closed pair gets stripped by the backend / chat-adapter before reaching the message parts, so content stays empty for the full tool-call duration. Users see the static label for many seconds and assume the model is stuck. Two changes: 1. Animated activity. The `` now wraps a pulsing dot (same dot style as `RunningToolIndicator`) so even when the label can't say anything more specific, the user sees the bubble is alive. 2. Tool-call probe. If any text / reasoning part has been emitted and it contains the opening of any known tool-call shape (``, ``, `<|python_tag|>`, `[TOOL_CALLS]`), switch the label from `Generating...` to `Calling tool...`. The probe runs in the existing useAuiState selector so memo invalidation only happens when content actually changes. The `RunningToolIndicator` path (which renders once a real tool-call part has landed) is unchanged. The two indicators are mutually exclusive because RunningToolIndicator requires a tool-call part and GeneratingIndicator bails out the moment any non-text part shows up. --- .../src/components/assistant-ui/thread.tsx | 58 ++++++++++++++++--- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index d35f223142..98d8800563 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -987,15 +987,59 @@ const MessageError: FC = () => { ); }; +// Heuristic: text the model emits before a tool actually starts often +// includes the opening of one of the tool-call markup shapes the +// backend strips. When that shape appears in the assistant's +// content but no tool-call PART has landed yet (because the backend +// only emits tool_start after the markup parses cleanly), the user +// otherwise stares at a frozen "Generating..." for the whole time it +// takes the model to finish writing the tool call. +const TOOL_CALL_PROBE = /||<\|python_tag\|>|\[TOOL_CALLS\]/i; + const GeneratingIndicator: FC = () => { - const show = useAuiState( - ({ message }) => - message.content.length === 0 && message.status?.type === "running", - ); - if (!show) { + const phase = useAuiState(({ message }) => { + if (message.status?.type !== "running") return null; + const content = message.content; + if (!content || content.length === 0) return "generating"; + // If every part is empty (placeholder text added but no chars yet) + // we still want to surface generating activity. + let allEmpty = true; + let sawToolSignal = false; + for (const p of content) { + const t = (p as { type?: string }).type; + if (t === "text" || t === "reasoning") { + const text = ((p as { text?: string }).text ?? ""); + if (text.length > 0) { + allEmpty = false; + if (TOOL_CALL_PROBE.test(text)) { + sawToolSignal = true; + } + } + } else { + // tool-call / source / etc. mean a real part exists; the + // RunningToolIndicator or message body handles those. + return null; + } + } + if (sawToolSignal) return "calling-tool"; + if (allEmpty) return "generating"; return null; - } - return Generating...; + }); + if (!phase) return null; + return ( + + + {phase === "calling-tool" ? "Calling tool..." : "Generating..."} + + ); }; // Placeholder when stop fires before any visible content (e.g. mid-think).