import { useEffect, useRef, useState } from 'react'; import { Brain } from 'lucide-react'; import { cn } from '@/components/ui'; import { Disclosure } from '@/components/ui/disclosure'; /** * How long after the last reasoning token the panel folds itself away. * * Long enough that the collapse reads as a consequence of the thinking ending * rather than as a flicker, short enough that the answer is not still fighting * a wall of scratch text for the eye by the time it starts streaming. */ const COLLAPSE_DELAY_MS = 1_000; /** * Piggy's scratch work, shown while it happens and folded away afterwards. * * Reachable only when `PIGGY_REASONING_EFFORT` is turned up from its default of * `none`; with the default, `reasoning_delta` never fires and the integrator * never renders this. That is deliberate — see the note on the setting in * apps/piggy/src/config.ts — so treat this panel as the operator's debugging * surface first and a chat flourish second. * * Three behaviours, in the order they matter: * - it opens itself while the thinking streams, because unexplained latency is * the thing a reasoning model is worst at; * - it closes itself a beat after the thinking stops, because the answer is * what the user came for and scratch work left open buries it; * - it stops doing either the moment the user touches the disclosure, because * a panel that re-closes itself under someone who opened it to read is worse * than one that never opened at all. */ export function PiggyReasoning({ text, streaming }: { text: string; streaming: boolean }) { const [open, setOpen] = useState(streaming); const [durationMs, setDurationMs] = useState(null); const bodyRef = useRef(null); /** * `performance.now()` at the first reasoning token, not at mount: the * integrator may render this panel from the moment the turn starts, and the * wait for the first byte belongs to the request, not to the thinking. */ const startedAt = useRef(null); /** * Set by the only gesture that can toggle a `
` — a click or an * Enter/Space on the summary, which the browser reports as a click too. Once * it is set, neither automatic rule fires again for this turn. */ const touched = useRef(false); const started = Boolean(text.trim()); useEffect(() => { if (streaming && started && startedAt.current === null) startedAt.current = performance.now(); if (!streaming && startedAt.current !== null) { setDurationMs(performance.now() - startedAt.current); startedAt.current = null; } }, [streaming, started]); useEffect(() => { if (touched.current) return; if (streaming) { setOpen(true); return; } const timer = setTimeout(() => { // Re-checked at fire time as well as at schedule time: the user may have // opened the panel during the delay, and this closure would otherwise // shut it under them a second later. if (!touched.current) setOpen(false); }, COLLAPSE_DELAY_MS); return () => clearTimeout(timer); }, [streaming]); useEffect(() => { // Follow the tail while it writes. Without this the capped box shows the // opening sentence for the whole turn, which looks like a stalled stream. if (streaming && bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight; }, [text, streaming]); // Nothing was thought and nothing is being thought: render no chrome at all, // rather than an empty box the user can open to find nothing in. if (!started && !streaming) return null; return ( ` rather than on the summary, because that is where * the primitive's own props land — and a click anywhere in this panel, * summary or body, is the user attending to it, which is exactly the * signal the automatic open and close must stand down for. */ onClick={() => { touched.current = true; }} summary={ {streaming ? 'Thinking' : reasoningLabel(durationMs)} } > {/* Withheld until the first token so the gap between "Thinking" and anything to read is empty space rather than an empty rail. */} {started ? (

{text}

) : null}
); } /** * Deliberately silent about duration when there is none to report. * * A panel mounted against an already-finished turn — a restored transcript, a * remount behind a closed sheet — never saw the clock start, and "Thought for 0 * seconds" would be a measurement we did not take. */ function reasoningLabel(durationMs: number | null): string { if (durationMs === null) return 'Reasoning'; const seconds = Math.max(1, Math.round(durationMs / 1_000)); return `Thought for ${seconds} ${seconds === 1 ? 'second' : 'seconds'}`; }