Compare commits

...
Sign in to create a new pull request.

8 commits

Author SHA1 Message Date
danielhanchen
c52bd6c57e 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 `<tool_call>...</tool_call>` 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 `<span>` 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
   (`<tool_call>`, `<function=`, `<|tool_call>`, `<|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.
2026-05-19 16:42:00 +00:00
danielhanchen
e379958b78 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 <name>...";
  - 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.
2026-05-19 15:02:30 +00:00
danielhanchen
b9fcb1ba56 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.
2026-05-19 14:50:06 +00:00
danielhanchen
d64b4b84f6 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.
2026-05-19 14:35:36 +00:00
danielhanchen
7147f423e2 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:<name>' | 'working' | '') and parse it in the render body so
selector identity stays stable.
2026-05-19 14:28:36 +00:00
danielhanchen
e174126049 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.
2026-05-19 14:27:12 +00:00
danielhanchen
bede230999 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).
2026-05-19 14:21:25 +00:00
danielhanchen
15d6fdbdde 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 <think> 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 <think> re-injection on the local llama.cpp path
  (template-dependent; most reasoning templates strip it intentionally,
  preserve_thinking is the Qwen3.6 escape hatch)
2026-05-19 14:14:52 +00:00
5 changed files with 185 additions and 18 deletions

View file

@ -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;

View file

@ -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 = /<tool_call>|<function=|<\|tool_call>|<\|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 <span className="text-sm text-muted-foreground">Generating...</span>;
});
if (!phase) return null;
return (
<span
data-slot="generating-indicator"
data-phase={phase}
className="aui-generating-indicator inline-flex items-center gap-2 text-sm text-muted-foreground"
aria-live="polite"
>
<span
aria-hidden
className="aui-generating-indicator-dot inline-block size-2 animate-pulse rounded-full bg-muted-foreground/60"
/>
<span>{phase === "calling-tool" ? "Calling tool..." : "Generating..."}</span>
</span>
);
};
// 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:<name>" | "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 (
<div
data-slot="running-tool-indicator"
className="aui-running-tool-indicator mt-2 flex items-center gap-2 text-sm text-muted-foreground"
aria-live="polite"
>
<span
aria-hidden
className="aui-running-tool-indicator-dot inline-block size-2 animate-pulse rounded-full bg-muted-foreground/60"
/>
{toolName ? (
<span>
Running <span className="font-mono text-xs">{toolName}</span>...
</span>
) : (
<span>Working...</span>
)}
</div>
);
};
const AssistantMessage: FC = () => {
return (
<MessagePrimitive.Root
@ -1042,6 +1155,7 @@ const AssistantMessage: FC = () => {
}}
/>
<SourcesGroup />
<RunningToolIndicator />
<MessageError />
</div>

View file

@ -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 (
<ToolFallbackRoot
defaultOpen={defaultOpen}
className={cn(isCancelled && "bg-muted/30")}
>
<ToolFallbackTrigger toolName={toolName} status={status} />

View file

@ -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<boolean | null>(null);
const autoOpen = hasRunning || (messageStreaming && hasEverRunRef.current);
const isOpen = userOpen ?? autoOpen;
// Single tool call: render directly without wrapper.
if (toolCount <= 1) {
return <>{children}</>;
}
return (
<ToolGroupRoot>
<ToolGroupTrigger count={toolCount} />
<ToolGroupRoot open={isOpen} onOpenChange={setUserOpen}>
<ToolGroupTrigger count={toolCount} active={hasRunning} />
<ToolGroupContent>{children}</ToolGroupContent>
</ToolGroupRoot>
);

View file

@ -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"
}
>
<GlobeIcon className="size-3.5" />
<span>Search</span>
@ -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"
}
>
<CodeToggleIcon className="size-3.5" />
<span>Code</span>