diff --git a/studio/frontend/src/components/assistant-ui/reasoning.tsx b/studio/frontend/src/components/assistant-ui/reasoning.tsx index e4401cc12e..f98a10947f 100644 --- a/studio/frontend/src/components/assistant-ui/reasoning.tsx +++ b/studio/frontend/src/components/assistant-ui/reasoning.tsx @@ -327,6 +327,9 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({ 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) { if (parts[i]?.type !== "tool-call") { return false; diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 0326b90a97..98d8800563 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) ? ( @@ -985,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). @@ -1014,6 +1060,73 @@ 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. +// +// 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 +// across renders -- returning a fresh object each render would invalidate +// equality and force the component to rerender every frame. +const RunningToolIndicator: FC = () => { + const signal = useAuiState(({ message }) => { + if (message.status?.type !== "running") return ""; + const parts = message.parts; + // 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; text?: string; toolName?: string; status?: { type?: string } } + | undefined; + 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. + } + return ""; + }); + if (!signal) return null; + const toolName = signal.startsWith("tool:") ? signal.slice(5) : null; + return ( +
+ + {toolName ? ( + + Running {toolName}... + + ) : ( + Working... + )} +
+ ); +}; + 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..11a471c3b5 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -989,9 +989,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 +1049,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 +1066,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