Request History
A playground's run rail — every request with the model and knobs it was sent with, pinned favourites kept above the churn, one click to load a run back into the editor, and a confirmation when that would overwrite your draft.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/request-history.jsonPrompt
Build a React + TypeScript + Tailwind "RequestHistory" component with zod and
lucide-react, reusing an existing shadcn Badge for the model chip.
Contract
- A zod schema (`requestHistoryEntrySchema` in a sibling contract file) is the
single source of truth for ONE run: { id, at: string|number|Date, model,
params, preview, status, pinned, label?, error? }.
- `params` is a named object, not a bag: { temperature?, maxTokens?, topP?,
topK?, seed?, stop?: string[], tools?: number, stream?: boolean, extra?:
Record<string, string|number|boolean> }. The named knobs are the ones every
provider agrees on, which is what lets the rail format them identically for
every row; `extra` carries provider-specific ones (presence_penalty,
thinking_budget) verbatim so a new knob never needs a new component version.
- Entry status is FOUR values: "ok" | "error" | "cancelled" | "running".
`cancelled` is not folded into `error` — a run you stopped on purpose is a
normal outcome of a playground, and merging the two paints half a session red.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
RAIL's own render state. `status="error"` means the history failed to load;
a request having failed is `entry.status === "error"`, and the two read
differently on screen.
- Props: items: Entry[]; status; now? (injected clock); activeId?: string|null;
dirty?: boolean; onRestore?(entry); onTogglePin?(id, nextPinned);
onClear?(ids); onRetry?; clearScope? ("unpinned" | "all", default
"unpinned"); pinnedFirst? (true); sort? ("newest" | "given");
maxParamChips? (4); previewLines? (2); confirmTimeoutMs? (6000);
formatTime?; heading? ("History"); label? ("Request history"); emptyState?;
errorMessage?; className plus the rest of the element props, ref forwarded to
the <section>.
- Numeric props are clamped (maxParamChips >= 1, previewLines >= 1) so a 0 or a
NaN can never render an empty row; confirmTimeoutMs of 0 or Infinity means
"armed until answered" rather than "expires instantly".
Behavior
- RESTORE IS THE ROW. The whole headline (model chip, optional label, relative
time, prompt preview) is one button whose accessible name starts with the verb
("Restore request: …"); there is no separate Restore button, because that
would double every item in the list for a keyboard user. `onRestore` receives
the WHOLE entry — model, params and prompt — since filling only the text back
in is the bug this component exists to prevent.
- The confirmation is CONDITIONAL, not ceremonial. With `dirty` false, one click
restores: it is an idempotent write of the same values into the editor, so it
needs neither a dialog nor a duplicate-click lock. With `dirty` true, the
first click arms that row ("Replace your current draft? Click again to
confirm.") and the second one restores. Arming a second row disarms the first;
Escape disarms; the armed state expires by itself after confirmTimeoutMs.
- A DOUBLE-CLICK CANNOT WALK THE GUARD. An armed confirmation ignores clicks for
the first ~350ms, otherwise a double-click would arm the guard and step
straight through it inside the same 120ms — precisely the accident the guard
exists to prevent. After the grace window, a ref-held one-shot lock makes sure
five fast clicks produce exactly one `onRestore`: a state-only guard would let
four through, because clicks dispatched inside one task all read the same
stale state.
- CLEAR NAMES ITS BLAST RADIUS. The Clear control opens an inline strip (not a
modal — a side rail should not take over the screen for a scratch list) that
states the numbers: "Clear 6 requests? 3 pinned stay." Pinned entries are
excluded by default, because a pin is a promise that the entry survives a
cleanup; `clearScope="all"` opts out and the sentence changes to match.
`onClear` gets exactly the ids the sentence named, once, behind the same ref
lock. Confirming moves focus to the confirm button; cancelling, expiring or
confirming gives focus back to the Clear button — or to the region itself when
Clear disappears along with the list, so focus is never dropped on <body>.
- PINNING IS YOURS. `onTogglePin(id, nextValue)` reports the intent; the parent
owns the flag. When the parent re-groups, the row unmounts from one list and
remounts in the other, so the component remembers which id was toggled and
re-focuses its pin button in a layout effect — the user keeps their place
across a jump they caused, and the ring never blinks off between paints.
- ONE TAB STOP FOR THE WHOLE RAIL (composite widget). ArrowUp / ArrowDown move
between rows, Home / End jump to the ends, and there is deliberately no
wrap-around — jumping from the oldest run back to the newest reads as a
glitch. Only the current row's controls (row button, "+N" parameter toggle,
pin) are tabbable; every other row is tabIndex=-1. The current row is DERIVED
from focus/activeId/first-row, never stored as an index, so deleting or
re-sorting can't leave the tab order pointing at a row that is gone.
- Parameter chips render in a FIXED order (temperature, max tokens, top_p,
top_k, seed, stop, tools, stream, then extras), never in object key order, so
two neighbouring runs can be diffed by eye. Only `maxParamChips` are drawn;
the rest collapse into a "+9" button that expands in place — a tooltip would
be unreachable on a touch screen, and these are the numbers you came to
compare. Each chip is written twice: a dense visible form ("temp 0.7") and an
sr-only spelt-out twin ("temperature 0.7"). An entry with no params says
"defaults" instead of leaving a ragged empty row.
- THE RAIL OWNS NO CLOCK. Times are formatted from `at` against an injected
`now` ("just now", "4m ago", "3h ago", "6d ago", then an absolute stamp);
without `now` every row prints its absolute instant instead. A negative delta
means clock skew, not a time machine — it falls back to the absolute reading
rather than printing "in -3m". Every row also carries a machine-readable
<time dateTime>. Calling Date.now() during render would desync server and
client output.
- Sorting is newest-first by default and NaN-safe: an unparseable instant sinks
to the bottom instead of shuffling the list into a random order. `sort="given"`
hands ordering back to your data layer.
- `status="ready"` with an empty array renders the EMPTY body, not an empty box
with a header promising rows.
- Without `onRestore`, rows are not buttons at all: a row styled like a control
that does nothing is a lie, and it would plant a dead tab stop on every entry.
The same rule applies to the pin (no handler → a static pinned marker) and to
Clear (no handler → no control).
Rendering & styling
- Semantic tokens only: bg-card / border for the rail, bg-muted/40 for group
headers and the active row, text-muted-foreground for metadata, bg-primary for
the active row's left edge and the "ok" dot, text-destructive with
bg-destructive/5 for a failed row and for both confirmations, bg-destructive +
text-background for the destructive confirm button, ring-ring for every
focus-visible ring. No hard-coded colours anywhere.
- Four outcomes are four SHAPES — filled dot, hollow slash, warning triangle,
spinner — each with an sr-only word, so they survive a monochrome theme and a
colour-blind reader. The spinner stops under prefers-reduced-motion and still
reads as "Running"; every transition is motion-reduce-guarded.
- The root is a <section aria-label="Request history"> holding ONE persistent
sr-only role="status" line ("9 requests in history, 3 pinned", or the armed
confirmation's wording). It is mounted in every branch, so a CHANGE is
announced from an element the screen reader is already watching.
- Groups are a <p id> label plus a <ul aria-labelledby> — not headings, because a
component cannot know which heading level its host page is at.
- Long model ids truncate inside the chip; the preview is clamped to
`previewLines` with `wrap-anywhere` + `min-w-0`, NOT `break-words`: only
`overflow-wrap: anywhere` lowers an element's min-content width, which is what
actually stops a 200-character pasted URL from widening the rail.
- The rail sets no height of its own. Put it in your scroll container so the
page decides what "too long" means.
- cn() merges the consumer's className into the root, which also spreads the
remaining element props and forwards its ref.
Customization levers
- Cleanup policy: `clearScope` decides whether a pin protects an entry, and the
confirmation sentence follows it automatically. Drop `onClear` entirely for a
history that is only ever trimmed server-side.
- Guard strength: `dirty` is the only switch between one-click and two-click
restore — wire it to your editor's own dirty flag (or to `prompt !== loaded`).
`confirmTimeoutMs` sets how long an armed row waits; 0 keeps it armed until
answered.
- Density: `maxParamChips` and `previewLines` trade rail height for context —
1 line and 2 chips for a cramped sidebar, 4 lines and 8 chips for a
full-width run log. `pinnedFirst={false}` collapses the two groups into one
flat list.
- Grouping and order: `sort="given"` when your data layer already ranks the
list (by relevance, by score, by cost).
- Wording: `formatTime` swaps the time phrasing (a locale, an absolute stamp, a
run number); `heading` names the rail; `label` names the region;
`emptyState` replaces the empty body; `errorMessage` + `onRetry` own the
envelope failure.
- Live time: pass a `now` you tick once a minute at the top of your app — one
interval for the whole page instead of one per row.Concepts
- Restore is the row, and it hands over everything — the click target is the whole headline, and the callback carries the model, the sampling params and the prompt together. Filling only the text back into the editor is the bug this rail exists to prevent: you re-run "the same request" at a temperature you changed twenty minutes ago and spend an hour explaining the difference to yourself.
- A confirmation only where there is something to lose — with a clean draft, restoring is an idempotent write and asks nothing; with unsaved edits it arms and states what would be replaced. Confirming everything trains people to click through confirmations, which is how the one that mattered gets clicked through too.
- A double-click cannot walk the guard — an armed confirmation ignores clicks for the first ~350ms, then a ref-held one-shot lock makes five fast clicks produce exactly one call. A state-only guard would let four through, because clicks dispatched inside a single task all observe the same stale state.
- A pin is a promise the entry survives a cleanup — Clear sweeps unpinned entries by default and says so in numbers ("clear 6, 3 pinned stay") instead of asking "are you sure?". The confirmation names its blast radius, and the callback receives exactly the ids it named.
- Focus follows the row across the group boundary — pinning re-groups the list, which unmounts the row here and mounts it there; the rail remembers the id it just toggled and re-focuses its button before paint, so a keyboard user is not dumped back at the top of the page for using a feature.
- One tab stop, derived not stored — the rail is a composite widget: arrows move between rows, Tab enters and leaves. The current row is computed from focus / activeId / first row on every render, so deleting the focused entry or re-sorting the list can never leave the tab order pointing at a row that no longer exists.
- The rail owns no clock — "4m ago" is derived from
atagainst an injectednow, so SSR, a replay and a screenshot all render the same string, and a page with two hundred rows still needs only the one interval you tick yourself.
Usage Alerts
A budget alert-rule manager — threshold tripwires on a scale that runs past 100%, per-rule channels, a one-shot test send with a real in-flight state, and a fired-alert history waiting to be acknowledged, in four contract-driven states.
Open In Chat
A provider menu that turns the content on the page into a written prompt and hands it to ChatGPT, Claude, Cursor or the clipboard — deeplink when it fits the URL, copy when it does not.