streaming round: thinking collapses by default, live tool-input cards, one glimmer grammar
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 { 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}
|
||||
|
||||
Reference in New Issue
Block a user