Compare commits
8 commits
main
...
studio/res
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c52bd6c57e | ||
|
|
e379958b78 | ||
|
|
b9fcb1ba56 | ||
|
|
d64b4b84f6 | ||
|
|
7147f423e2 | ||
|
|
e174126049 | ||
|
|
bede230999 | ||
|
|
15d6fdbdde |
5 changed files with 185 additions and 18 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue