Files
pig/apps/web/src/components/piggy/reasoning.tsx
T
claude 18d5f5bfc0
CI / verify (push) Successful in 7m10s
CI / publish (push) Has been skipped
Make Piggy part of the product rather than a guest in it
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>
2026-08-14 18:22:15 -07:00

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'}`;
}