18d5f5bfc0
Piggy arrived as a chat panel bolted onto a CRM and then grew a workspace around it. The layout was already right — the audit found the approval card to be the best-designed object in the repo, and the account page's empty panels less finished than anything in the workspace. What was wrong was vocabulary: nobody had written the small things down, so both halves kept inventing them. Piggy was drawn with five different marks — a pig in the dock, a sparkle in the sidebar and again on the model picker, a speech bubble on the Ask buttons, and a stock robot glyph on every assistant message, which is the one people look at most. There is now one mark. The composer, which is the first control in the product since sign-in lands on /piggy, was the only un-adapted shadcn field left: 6px radius against a 12px Send button it sat 8px from. A stat tile had been reinvented six times at three numeral scales, and the same uppercase micro-label existed in five variants, two of them one tab apart in the same rail. There were 63 hand-written font sizes: not a scale, sixty-three opinions. Underneath that, the focus ring was invisible. The global rule used ring-accent, which Tailwind deliberately aliases onto the hover tint, so the ring measured 1.01:1 against the light canvas — no visible focus indicator anywhere in the product, for any accent, in either theme. It is ring-brand now and measures 17:1. The warning, positive and info tones were darkened until each clears 4.5:1 on a card, on inset and on its own chip, and the light canvas moved to 98% so a card lifts without leaning on its shadow. The mobile work is the part worth reading. A landscape phone gave the transcript 28% of the viewport and a keyboard-up phone 16%, against a 45% floor — and the fixed tab bar painted over the composer, covering the safety sentence and half the Send button, because two source comments asserted the bar stood down on short viewports and it never had. Both fixed and measured by hit-testing rather than by screenshot. The composer itself was 64px tall for a blank second line nobody typed, because the auto-resize effect sizes to scrollHeight and scrollHeight counts rows — a CSS height could not win against an inline style, so the attribute was the honest lever. Verified across both themes driven through the app's own control: no horizontal overflow on 15 routes at four viewports, 672 stat values that fit, 297 labels at exactly 11px/500, Escape returning focus to its opener rather than the body on every overlay, and a rejected write no longer reporting "Succeeded" with a green check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
146 lines
6.0 KiB
TypeScript
146 lines
6.0 KiB
TypeScript
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<number | null>(null);
|
|
const bodyRef = useRef<HTMLDivElement | null>(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<number | null>(null);
|
|
/**
|
|
* Set by the only gesture that can toggle a `<details>` — 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 (
|
|
<Disclosure
|
|
open={open}
|
|
onOpenChange={setOpen}
|
|
className="mb-2 text-xs text-muted"
|
|
summaryClassName="pr-2 text-xs"
|
|
/*
|
|
* The transcript around this is `role="log" aria-live="polite"`, and a
|
|
* live region announces its whole subtree. Auto-opening the panel
|
|
* therefore put the model's scratch work into a screen reader's ear,
|
|
* token by token, ahead of the answer it was scratch work for. `off`
|
|
* overrides the inherited politeness for this subtree only.
|
|
*/
|
|
aria-live="off"
|
|
/*
|
|
* On the `<details>` 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={
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
<Brain className={cn('size-4 shrink-0', streaming && 'animate-pulse')} aria-hidden />
|
|
{streaming ? 'Thinking' : reasoningLabel(durationMs)}
|
|
</span>
|
|
}
|
|
>
|
|
{/* Withheld until the first token so the gap between "Thinking" and
|
|
anything to read is empty space rather than an empty rail. */}
|
|
{started ? (
|
|
<div
|
|
ref={bodyRef}
|
|
className={cn(
|
|
'ml-1 animate-in fade-in border-l border-border py-1 pl-3 duration-2 ease-enter',
|
|
// Capped only while it writes. An auto-opened panel is one the user
|
|
// did not ask for, so it must not push the answer off screen; a
|
|
// panel they opened themselves is one they mean to read to the end.
|
|
streaming && 'max-h-40 overflow-y-auto',
|
|
)}
|
|
>
|
|
<p className="whitespace-pre-wrap leading-5">{text}</p>
|
|
</div>
|
|
) : null}
|
|
</Disclosure>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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'}`;
|
|
}
|