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.
This commit is contained in:
danielhanchen 2026-05-19 16:42:00 +00:00
commit c52bd6c57e

View file

@ -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 = /<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).