Rebuild Piggy's interface, and give the demo book a business to describe
Piggy answered in raw markdown, threw away every tool result it streamed, and fought the reader's scroll on every token. The three surfaces that made it worth having — what it read, how it reasoned, what it cost — were all on the wire and none of them reached the screen. The transcript is now composed of five parts under components/piggy: answers render through streamdown, the container sticks to the bottom without pinning the reader there, tool steps say what they read and link to the record, and each turn carries its model and token count. Three lifecycle bugs went with them: Stop left a permanent spinner, a truncated stream was indistinguishable from thinking, and a failed send destroyed the message it failed to send. Underneath, the inference path grew timeouts, jittered retries on 429 and 5xx, tolerance of the malformed frames a 30B model emits, and an agent_runs row per turn so chat spend is observable. The system prompt now states that a field ending in Cents is cents — without it nemotron renders costPerGpuHourCents: 189 as "$189 per GPU-hour", which is a 100x error on the most scrutinised number in the room. The demo book was arithmetically incoherent: every deal's value contradicted its own allocation revenue by up to 3.6x, nothing had ever closed, no customer had any paper, and the marketplace was empty. Deal value is now derived from the allocation, the book clears 5.3% across five blocks with one deliberately underwater, and the renewal, compliance and agent-provenance machinery finally has rows to act on. A --clear that deleted every obligation, SLA term and capacity request in the database regardless of origin is scoped to the demo's own ids. Around that: accounts have a detail page, ⌘K searches the book, Settings can mint the API keys it always claimed to, and deploy.sh actually ships the agent instead of silently skipping its compose profile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Brain, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/components/ui';
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<details
|
||||
open={open}
|
||||
onToggle={(event) => setOpen(event.currentTarget.open)}
|
||||
className="mb-2 text-xs text-muted"
|
||||
/*
|
||||
* 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"
|
||||
>
|
||||
<summary
|
||||
// `list-none` and the WebKit rule between them remove the native
|
||||
// triangle, which a flex summary drops in Chrome but keeps in Firefox —
|
||||
// so without both the disclosure marker exists in one browser only.
|
||||
className="flex min-h-11 cursor-pointer list-none items-center gap-2 py-2 pr-2 font-medium transition-colors hover:text-fg [&::-webkit-details-marker]:hidden"
|
||||
onClick={() => {
|
||||
touched.current = true;
|
||||
}}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn('size-3.5 shrink-0 transition-transform', open && 'rotate-90')}
|
||||
aria-hidden
|
||||
/>
|
||||
<Brain className={cn('size-4 shrink-0', streaming && 'animate-pulse')} aria-hidden />
|
||||
{streaming ? 'Thinking' : reasoningLabel(durationMs)}
|
||||
</summary>
|
||||
{/* 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',
|
||||
// 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}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'}`;
|
||||
}
|
||||
Reference in New Issue
Block a user