streaming round: thinking collapses by default, live tool-input cards, one glimmer grammar
This commit is contained in:
@@ -7,7 +7,8 @@ import {
|
|||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
import { ChevronDownIcon } from "lucide-react";
|
||||||
|
import { BrailleSpinner } from "@/components/braille-spinner";
|
||||||
import type { ComponentProps, ReactNode } from "react";
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
@@ -65,9 +66,13 @@ export const Reasoning = memo(
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: ReasoningProps) => {
|
}: ReasoningProps) => {
|
||||||
const resolvedDefaultOpen = defaultOpen ?? isStreaming;
|
// COLLAPSED BY DEFAULT, ALWAYS (owner, streaming round 2026-10-01):
|
||||||
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
|
// the old default opened on streaming and an auto-open effect
|
||||||
const isExplicitlyClosed = defaultOpen === false;
|
// re-fought a manual collapse — the block "wasn't collapsible until
|
||||||
|
// it finished". Collapsed is the default; opening is always the
|
||||||
|
// learner's click; the auto-close after the stream still tidies a
|
||||||
|
// row the learner had opened.
|
||||||
|
const resolvedDefaultOpen = defaultOpen ?? false;
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useControllableState<boolean>({
|
const [isOpen, setIsOpen] = useControllableState<boolean>({
|
||||||
defaultProp: resolvedDefaultOpen,
|
defaultProp: resolvedDefaultOpen,
|
||||||
@@ -96,13 +101,6 @@ export const Reasoning = memo(
|
|||||||
}
|
}
|
||||||
}, [isStreaming, setDuration]);
|
}, [isStreaming, setDuration]);
|
||||||
|
|
||||||
// Auto-open when streaming starts (unless explicitly closed)
|
|
||||||
useEffect(() => {
|
|
||||||
if (isStreaming && !isOpen && !isExplicitlyClosed) {
|
|
||||||
setIsOpen(true);
|
|
||||||
}
|
|
||||||
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
|
|
||||||
|
|
||||||
// Auto-close when streaming ends (once only, and only if it ever streamed)
|
// Auto-close when streaming ends (once only, and only if it ever streamed)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@@ -182,7 +180,11 @@ export const ReasoningTrigger = memo(
|
|||||||
>
|
>
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<>
|
<>
|
||||||
<BrainIcon className="size-4" />
|
{/* The working-state grammar (owner, streaming round
|
||||||
|
2026-10-01): the Braille spinner replaces the brain icon
|
||||||
|
WHILE STREAMING ONLY — motion is the signal, so a finished
|
||||||
|
row carries no icon at all, just text + chevron. */}
|
||||||
|
{isStreaming && <BrailleSpinner label="Thinking" className="size-4" />}
|
||||||
{getThinkingMessage(isStreaming, duration)}
|
{getThinkingMessage(isStreaming, duration)}
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { CheckIcon, SettingsIcon, XIcon } from "lucide-react";
|
|||||||
import { JobsContext } from "@/components/jobs-context";
|
import { JobsContext } from "@/components/jobs-context";
|
||||||
import { ChatActionsContext } from "@/components/chat";
|
import { ChatActionsContext } from "@/components/chat";
|
||||||
import { isModelsRefusal, isProviderKeyRefusal, type JobKind, type JobRecord } from "@/src/core";
|
import { isModelsRefusal, isProviderKeyRefusal, type JobKind, type JobRecord } from "@/src/core";
|
||||||
|
import { BrailleSpinner } from "@/components/braille-spinner";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The dispatch card (contracts.ts "THE ASYNC PROTOCOL" §4): the tool-call
|
* The dispatch card (contracts.ts "THE ASYNC PROTOCOL" §4): the tool-call
|
||||||
@@ -94,7 +95,11 @@ function DispatchActivityCard({ job, label }: { job: JobRecord | null; label: st
|
|||||||
data-testid="dispatch-card"
|
data-testid="dispatch-card"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
<span className="inline-block size-2 animate-pulse rounded-full bg-primary" />
|
{/* The working-state grammar (owner, streaming round 2026-10-01):
|
||||||
|
the Braille spinner replaces the pulsing dot — one indicator
|
||||||
|
everywhere. This is the ONLY dispatch-card change; the card's
|
||||||
|
shape is untouched. */}
|
||||||
|
<BrailleSpinner label={`${label} running`} />
|
||||||
{label}
|
{label}
|
||||||
{elapsed !== null && <span className="font-normal">· {elapsed}s</span>}
|
{elapsed !== null && <span className="font-normal">· {elapsed}s</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import { BrailleSpinner } from "@/components/braille-spinner";
|
||||||
|
import { Shimmer } from "@/components/ai-elements/shimmer";
|
||||||
|
import { toolActivityLabel } from "@/src/core";
|
||||||
|
import type { ArtifactPartProps } from "@/src/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The LIVE tool-input card (owner, streaming round 2026-10-01): what a
|
||||||
|
* tool call looks like while the agent WRITES it. A tool part sits in
|
||||||
|
* `input-streaming` for its whole execution (the bridge defers
|
||||||
|
* input-available to tool_execution_end), so this card IS the working
|
||||||
|
* state — spinner + shimmered activity label in the header (the retired
|
||||||
|
* transcript bottom line's text, same recipe as the thinking row), and
|
||||||
|
* the streaming input itself watchable on expand. The moment the call
|
||||||
|
* settles, the part re-renders through the artifact registry and the
|
||||||
|
* quiet settled card replaces this one.
|
||||||
|
*
|
||||||
|
* The one working-state grammar everywhere (owner): Braille spinner +
|
||||||
|
* Shimmer(duration 1) — the thinking row, this card, the sidebar row.
|
||||||
|
*/
|
||||||
|
interface StreamingToolPart {
|
||||||
|
type: string;
|
||||||
|
state: string;
|
||||||
|
toolCallId: string;
|
||||||
|
input?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LiveToolCard({ part }: ArtifactPartProps) {
|
||||||
|
const p = part as StreamingToolPart;
|
||||||
|
const tool = p.type.replace(/^tool-/, "");
|
||||||
|
const inp = (p.input ?? {}) as Record<string, unknown>;
|
||||||
|
// The headline argument (ToolCallCard's rule, shown as given): path for
|
||||||
|
// read/ls, pattern for find/grep, session for list_propositions.
|
||||||
|
let arg = "";
|
||||||
|
if (typeof inp.path === "string" && inp.path) arg = inp.path;
|
||||||
|
else if (typeof inp.pattern === "string" && inp.pattern) arg = inp.pattern;
|
||||||
|
else if (typeof inp.session === "string" && inp.session) arg = inp.session;
|
||||||
|
|
||||||
|
const hasInput = p.input != null && Object.keys(inp).length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details
|
||||||
|
data-testid="live-tool-card"
|
||||||
|
data-tool={tool}
|
||||||
|
className="group my-1 w-full max-w-2xl select-none"
|
||||||
|
>
|
||||||
|
<summary className="flex cursor-pointer list-none items-center gap-2 text-xs text-muted-foreground [&::-webkit-details-marker]:hidden">
|
||||||
|
<BrailleSpinner label={`${toolActivityLabel(tool)} — live`} />
|
||||||
|
<Shimmer duration={1} className="text-xs">
|
||||||
|
{toolActivityLabel(tool)}
|
||||||
|
</Shimmer>
|
||||||
|
{arg && <span className="truncate">{arg}</span>}
|
||||||
|
<ChevronRight className="size-3 shrink-0 opacity-50 transition-transform group-open:rotate-90" />
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 rounded-xl border bg-card p-3 text-xs">
|
||||||
|
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">Input (streaming)</div>
|
||||||
|
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed">
|
||||||
|
{hasInput ? JSON.stringify(p.input, null, 2) : "…"}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
+20
-10
@@ -15,6 +15,7 @@ import {
|
|||||||
import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message";
|
import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message";
|
||||||
import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-elements/reasoning";
|
import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-elements/reasoning";
|
||||||
import { Shimmer } from "@/components/ai-elements/shimmer";
|
import { Shimmer } from "@/components/ai-elements/shimmer";
|
||||||
|
import { LiveToolCard } from "@/components/artifacts/live-tool-card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { AnsweredCard } from "@/components/answered-card";
|
import { AnsweredCard } from "@/components/answered-card";
|
||||||
import { artifactRenderers } from "@/components/artifacts";
|
import { artifactRenderers } from "@/components/artifacts";
|
||||||
@@ -36,7 +37,7 @@ import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsag
|
|||||||
import type { LhViewMessage } from "@/src/core";
|
import type { LhViewMessage } from "@/src/core";
|
||||||
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
|
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
|
||||||
import { CoverageRing } from "@/components/extraction-coverage";
|
import { CoverageRing } from "@/components/extraction-coverage";
|
||||||
import { findPendingToolCall, phaseAt3, phaseTimeline, streamingToolActivity } from "@/src/core";
|
import { findPendingToolCall, phaseAt3, phaseTimeline } from "@/src/core";
|
||||||
import { slotsConfigured, type ModelsSettings } from "@/src/core";
|
import { slotsConfigured, type ModelsSettings } from "@/src/core";
|
||||||
import { useSettingsSaved } from "@/components/settings-signal";
|
import { useSettingsSaved } from "@/components/settings-signal";
|
||||||
|
|
||||||
@@ -375,13 +376,6 @@ export function Chat({
|
|||||||
// session's current phase.
|
// session's current phase.
|
||||||
const phases = useMemo(() => phaseTimeline(messages), [messages]);
|
const phases = useMemo(() => phaseTimeline(messages), [messages]);
|
||||||
|
|
||||||
// A tool call still streaming its input: the teacher is mid-activity. The
|
|
||||||
// label names the ACTUAL tool (a long grep used to shimmer as “Writing a
|
|
||||||
// question…” for minutes — a part stays input-streaming through its whole
|
|
||||||
// execution, see streamingToolActivity); shown as the transcript shimmer,
|
|
||||||
// exactly like the thinking indicator.
|
|
||||||
const streamingActivity = !pending ? streamingToolActivity(messages) : null;
|
|
||||||
|
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
/* The composer's value MIRROR ("nothing gets lost" round, owner
|
/* The composer's value MIRROR ("nothing gets lost" round, owner
|
||||||
* 2026-10-01): a dictation flush lands via setState, which a closure
|
* 2026-10-01): a dictation flush lands via setState, which a closure
|
||||||
@@ -1170,6 +1164,14 @@ export function Chat({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
|
// A tool call being WRITTEN (owner, streaming round 2026-10-01):
|
||||||
|
// input-streaming spans the whole execution, and the live card —
|
||||||
|
// spinner + shimmered activity label, the input watchable on expand
|
||||||
|
// — replaces the retired transcript bottom line. On settling, the
|
||||||
|
// registry's card takes over below.
|
||||||
|
if (part.type.startsWith("tool-") && part.state === "input-streaming") {
|
||||||
|
return <LiveToolCard key={pi} part={part as ArtifactPartProps["part"]} />;
|
||||||
|
}
|
||||||
// Artifact extension point (contracts.ts): job
|
// Artifact extension point (contracts.ts): job
|
||||||
// parts and future artifacts render via the
|
// parts and future artifacts render via the
|
||||||
// registry — add renderers there, not here.
|
// registry — add renderers there, not here.
|
||||||
@@ -1187,8 +1189,16 @@ export function Chat({
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{(status === "submitted" || turnInFlight) && <Shimmer className="text-sm">Thinking…</Shimmer>}
|
{/* The pre-first-chunk line only (owner, streaming round
|
||||||
{streamingActivity && <Shimmer className="text-sm">{streamingActivity}</Shimmer>}
|
2026-10-01): once a tool call starts writing, its LIVE card
|
||||||
|
carries the activity inline — the old bottom tool-activity
|
||||||
|
line retired rather than double-render. Same glimmer recipe
|
||||||
|
as the thinking row: duration 1. */}
|
||||||
|
{(status === "submitted" || turnInFlight) && (
|
||||||
|
<Shimmer duration={1} className="text-sm">
|
||||||
|
Thinking…
|
||||||
|
</Shimmer>
|
||||||
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm">
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm">
|
||||||
Something went wrong: {error.message}
|
Something went wrong: {error.message}
|
||||||
|
|||||||
+11
-3
@@ -134,14 +134,22 @@ const ACTIVITY_LABELS: Record<string, string> = {
|
|||||||
probe_done: "Concluding the assessment…",
|
probe_done: "Concluding the assessment…",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The activity label for ONE running tool (owner, streaming round
|
||||||
|
* 2026-10-01): the live tool-input card carries it inline — spinner +
|
||||||
|
* shimmered label on the card itself, replacing the retired transcript
|
||||||
|
* bottom line. Question tools are excluded here: their input-streaming
|
||||||
|
* part renders in the dock, never a live card. */
|
||||||
|
export function toolActivityLabel(tool: string): string {
|
||||||
|
if ((QUESTION_TOOLS as readonly string[]).includes(tool)) return "Writing a question…";
|
||||||
|
return ACTIVITY_LABELS[tool] ?? "Working…";
|
||||||
|
}
|
||||||
|
|
||||||
export function streamingToolActivity(messages: UIMessage[]): string | null {
|
export function streamingToolActivity(messages: UIMessage[]): string | null {
|
||||||
const last = messages.at(-1);
|
const last = messages.at(-1);
|
||||||
if (!last || last.role !== "assistant") return null;
|
if (!last || last.role !== "assistant") return null;
|
||||||
for (const part of last.parts) {
|
for (const part of last.parts) {
|
||||||
if (isToolUIPart(part) && part.state === "input-streaming") {
|
if (isToolUIPart(part) && part.state === "input-streaming") {
|
||||||
const tool = part.type.slice("tool-".length);
|
return toolActivityLabel(part.type.slice("tool-".length));
|
||||||
if ((QUESTION_TOOLS as readonly string[]).includes(tool)) return "Writing a question…";
|
|
||||||
return ACTIVITY_LABELS[tool] ?? "Working…";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* The LIVE tool-input card (owner, streaming round 2026-10-01): what a
|
||||||
|
* tool call looks like while the agent writes it — Braille spinner +
|
||||||
|
* shimmered activity label in the header (the retired bottom line's
|
||||||
|
* text, one glimmer recipe), the streaming input watchable on expand.
|
||||||
|
* These pins hold the working-state grammar on the card.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { LiveToolCard } from "../components/artifacts/live-tool-card.tsx";
|
||||||
|
|
||||||
|
function part(tool: string, input: unknown) {
|
||||||
|
return { type: `tool-${tool}`, toolCallId: "c1", state: "input-streaming", input };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("LiveToolCard (the working-state grammar)", () => {
|
||||||
|
it("spinner + shimmered activity label + headline arg, input on expand", () => {
|
||||||
|
const html = renderToStaticMarkup(<LiveToolCard part={part("read", { path: "notes.md" }) as never} />);
|
||||||
|
assert.match(html, /data-testid="live-tool-card"/);
|
||||||
|
assert.match(html, /data-testid="braille-spinner"/);
|
||||||
|
// the label names the ACTUAL tool (the old bottom line's text)
|
||||||
|
assert.match(html, /Reading a file…/);
|
||||||
|
// the headline argument rides the header
|
||||||
|
assert.match(html, /notes\.md/);
|
||||||
|
// the streaming input is shown in the expandable body
|
||||||
|
assert.match(html, /Input \(streaming\)/);
|
||||||
|
assert.match(html, /notes\.md/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unknown tools stay generic; jobs get their dispatch labels", () => {
|
||||||
|
assert.match(renderToStaticMarkup(<LiveToolCard part={part("mystery_tool", {}) as never} />), /Working…/);
|
||||||
|
assert.match(
|
||||||
|
renderToStaticMarkup(<LiveToolCard part={part("research", { query: "x" }) as never} />),
|
||||||
|
/Dispatching research…/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no input yet renders a placeholder, not an empty pre", () => {
|
||||||
|
const html = renderToStaticMarkup(<LiveToolCard part={part("grep", undefined) as never} />);
|
||||||
|
assert.match(html, /<pre[^>]*>…<\/pre>/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user