From 0751fec9d8dfdbd60a7802c088065dd710041329 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Fri, 2 Oct 2026 02:09:38 +0300 Subject: [PATCH] streaming round: thinking collapses by default, live tool-input cards, one glimmer grammar --- components/ai-elements/reasoning.tsx | 26 +++++----- components/artifacts/dispatch-card.tsx | 7 ++- components/artifacts/live-tool-card.tsx | 65 +++++++++++++++++++++++++ components/chat.tsx | 30 ++++++++---- src/core/pending.ts | 14 ++++-- tests/render-live-tool-card.test.tsx | 43 ++++++++++++++++ 6 files changed, 159 insertions(+), 26 deletions(-) create mode 100644 components/artifacts/live-tool-card.tsx create mode 100644 tests/render-live-tool-card.test.tsx diff --git a/components/ai-elements/reasoning.tsx b/components/ai-elements/reasoning.tsx index 68b1315..2935d88 100644 --- a/components/ai-elements/reasoning.tsx +++ b/components/ai-elements/reasoning.tsx @@ -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({ 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 ?? ( <> - + {/* 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 && } {getThinkingMessage(isStreaming, duration)}
- + {/* 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. */} + {label} {elapsed !== null && · {elapsed}s}
diff --git a/components/artifacts/live-tool-card.tsx b/components/artifacts/live-tool-card.tsx new file mode 100644 index 0000000..d0771f8 --- /dev/null +++ b/components/artifacts/live-tool-card.tsx @@ -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; + // 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 ( +
+ + + + {toolActivityLabel(tool)} + + {arg && {arg}} + + +
+
Input (streaming)
+
+					{hasInput ? JSON.stringify(p.input, null, 2) : "…"}
+				
+
+
+ ); +} diff --git a/components/chat.tsx b/components/chat.tsx index 1e3d9b5..cf74ec1 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -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 ; + } // 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({ ))} - {(status === "submitted" || turnInFlight) && Thinking…} - {streamingActivity && {streamingActivity}} + {/* 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) && ( + + Thinking… + + )} {error && (
Something went wrong: {error.message} diff --git a/src/core/pending.ts b/src/core/pending.ts index 3d05aa8..8b8185b 100644 --- a/src/core/pending.ts +++ b/src/core/pending.ts @@ -134,14 +134,22 @@ const ACTIVITY_LABELS: Record = { 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; diff --git a/tests/render-live-tool-card.test.tsx b/tests/render-live-tool-card.test.tsx new file mode 100644 index 0000000..81a09b1 --- /dev/null +++ b/tests/render-live-tool-card.test.tsx @@ -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(); + 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(), /Working…/); + assert.match( + renderToStaticMarkup(), + /Dispatching research…/, + ); + }); + + it("no input yet renders a placeholder, not an empty pre", () => { + const html = renderToStaticMarkup(); + assert.match(html, /]*>…<\/pre>/); + }); +});