streaming round: thinking collapses by default, live tool-input cards, one glimmer grammar

This commit is contained in:
amosblomqvist
2026-10-02 02:09:38 +03:00
parent a99cbf3d90
commit 0751fec9d8
6 changed files with 159 additions and 26 deletions
+14 -12
View File
@@ -7,7 +7,8 @@ import {
CollapsibleTrigger,
} from "@/components/ui/collapsible";
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 {
createContext,
@@ -65,9 +66,13 @@ export const Reasoning = memo(
children,
...props
}: ReasoningProps) => {
const resolvedDefaultOpen = defaultOpen ?? isStreaming;
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
const isExplicitlyClosed = defaultOpen === false;
// COLLAPSED BY DEFAULT, ALWAYS (owner, streaming round 2026-10-01):
// the old default opened on streaming and an auto-open effect
// 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>({
defaultProp: resolvedDefaultOpen,
@@ -96,13 +101,6 @@ export const Reasoning = memo(
}
}, [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)
useEffect(() => {
if (
@@ -182,7 +180,11 @@ export const ReasoningTrigger = memo(
>
{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)}
<ChevronDownIcon
className={cn(
+6 -1
View File
@@ -5,6 +5,7 @@ import { CheckIcon, SettingsIcon, XIcon } from "lucide-react";
import { JobsContext } from "@/components/jobs-context";
import { ChatActionsContext } from "@/components/chat";
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
@@ -94,7 +95,11 @@ function DispatchActivityCard({ job, label }: { job: JobRecord | null; label: st
data-testid="dispatch-card"
>
<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}
{elapsed !== null && <span className="font-normal">· {elapsed}s</span>}
</div>
+65
View File
@@ -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
View File
@@ -15,6 +15,7 @@ import {
import { Message, MessageContent, MessageResponse } from "@/components/ai-elements/message";
import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-elements/reasoning";
import { Shimmer } from "@/components/ai-elements/shimmer";
import { LiveToolCard } from "@/components/artifacts/live-tool-card";
import { Separator } from "@/components/ui/separator";
import { AnsweredCard } from "@/components/answered-card";
import { artifactRenderers } from "@/components/artifacts";
@@ -36,7 +37,7 @@ import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsag
import type { LhViewMessage } from "@/src/core";
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
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 { useSettingsSaved } from "@/components/settings-signal";
@@ -375,13 +376,6 @@ export function Chat({
// session's current phase.
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("");
/* The composer's value MIRROR ("nothing gets lost" round, owner
* 2026-10-01): a dictation flush lands via setState, which a closure
@@ -1170,6 +1164,14 @@ export function Chat({
return null;
}
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
// parts and future artifacts render via the
// registry — add renderers there, not here.
@@ -1187,8 +1189,16 @@ export function Chat({
</Fragment>
))}
{(status === "submitted" || turnInFlight) && <Shimmer className="text-sm">Thinking…</Shimmer>}
{streamingActivity && <Shimmer className="text-sm">{streamingActivity}</Shimmer>}
{/* The pre-first-chunk line only (owner, streaming round
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 && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm">
Something went wrong: {error.message}
+11 -3
View File
@@ -134,14 +134,22 @@ const ACTIVITY_LABELS: Record<string, string> = {
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 {
const last = messages.at(-1);
if (!last || last.role !== "assistant") return null;
for (const part of last.parts) {
if (isToolUIPart(part) && part.state === "input-streaming") {
const tool = part.type.slice("tool-".length);
if ((QUESTION_TOOLS as readonly string[]).includes(tool)) return "Writing a question…";
return ACTIVITY_LABELS[tool] ?? "Working…";
return toolActivityLabel(part.type.slice("tool-".length));
}
}
return null;
+43
View File
@@ -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>/);
});
});