Chat Checkpoint
A conversation save point that guards its own restore — named marker, live age, auto vs manual origin, an "N messages since" cost line, and a one-shot rollback that explains the fork before it happens.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chat-checkpoint.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ChatCheckpoint" component: the save-point
marker row that sits between two messages in an AI transcript and offers exactly
one action — go back to here. Uses lucide-react and the shadcn Button and Badge
primitives. No popover library: the confirmation is hand-rolled, because whether
it appears at all depends on how much the restore would cost.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement> minus
children; unknown props spread onto the root.
- label: string — the save point's name. Also the accessible name of the row,
which is role="group" with aria-label "Checkpoint: <label>".
- description?: ReactNode — one muted line about the workspace at this point.
- origin?: "manual" | "auto" (default "manual") — a person pressed save, or the
runtime took the snapshot for them. Drives the glyph and the badge only.
- createdAt?: Date | number | string — rendered as a live age ("2h ago") plus a
machine-readable <time dateTime={iso}>.
- timeLabel?: string — the absolute stamp, formatted by the CALLER in the
reader's locale and timezone. This component never calls toLocaleString.
- messagesSince?: number (default 0) — messages sent after this point; exactly
what a restore forks away.
- current?: boolean — the thread is sitting on this checkpoint.
- onRestore?: () => void | Promise<void> — omit it and no action is rendered.
- confirm?: boolean (default true) and confirmThreshold?: number (default 1).
- branchLabel?: string, side?: "top" | "bottom" (default "top"),
confirmWidth?: number (default 320).
- restoreLabel / restoringLabel / restoredLabel / confirmTitle / confirmLabel /
cancelLabel / restoreErrorLabel: strings, all with defaults.
- renderConfirmBody?: (ctx: { label, origin, messagesSince, branchLabel }) =>
ReactNode — replaces the sentence that explains the cost, not the card.
- onConfirmOpenChange?: (open: boolean) => void; announce?: boolean (default true).
- Sanitise numbers at the boundary: messagesSince and confirmThreshold arrive
from a caller, so NaN falls back to the default and negatives clamp to 0.
"NaN messages since" must be impossible and a negative threshold must not
silently invert the guard.
Behavior — a guard proportional to the damage
- Restoring is destructive only insofar as messages exist after the checkpoint.
Open the confirmation only when `confirm && messagesSince >= confirmThreshold`;
otherwise the first click restores immediately. A rollback that costs nothing
must not cost two clicks — that is how confirmations get trained away.
- Set aria-haspopup="dialog" and aria-expanded on the trigger ONLY in the branch
that will actually open a card. Advertising a popup that never appears is a
worse lie than having no attribute.
- Default copy states the cost in the caller's units: "The 12 messages sent after
“<label>” leave the main thread and continue into <branchLabel ?? a side
branch>. You can still open them from there." With messagesSince === 0 it says
nothing moves. Never claim a number the props did not give you.
Behavior — restore is a latch, not a button
- State machine: idle → pending → done, one-way. Only a rejection walks back,
and it walks back to `failed`, which is armed again.
- Guard with a ref, not with state: the second click of a double click lands in
the same event-loop turn, before React has committed anything.
- A synchronous onRestore latches immediately; a thenable drives the pending
spinner. On resolve: latch shut, show "Restored", announce once. On reject:
release the ref, render one text-destructive line with role="alert", and let
the reader try again. Never strand a spinner on a failed write.
- Track mounted-ness in a ref and drop late resolutions, so a transcript that
unmounts the row mid-flight does not set state on a dead component.
- Disable the trigger with aria-disabled, NOT the disabled attribute, and refuse
the click in the handler. A disabled element is not focusable: the moment a
keyboard user confirms, focus would be dumped on <body> and their place in the
transcript would be gone.
- `current` retires the action entirely (the thread is already here) and closes
an open card in the same render, so the card can never point at a button that
is no longer in the DOM.
Behavior — the confirmation card
- role="alertdialog" aria-modal="false", aria-labelledby the title,
aria-describedby the cost sentence. Mount it only while open, so "mount" and
"open" are the same event and every effect runs once per opening.
- Portal it to document.body as position: fixed. The row usually lives inside a
bubble or transcript card with overflow: hidden, which would clip an in-flow
card into invisibility.
- Position in one synchronous pass: measure the trigger rect, measure the panel's
NATURAL height (lift your own max-height first, or an already-capped panel
makes every side look like it fits and the card ping-pongs), flip to the other
side only when it is genuinely roomier, cap the height, right-align the card to
the trigger and slide it back inside the boundary.
- The boundary is the viewport intersected with every overflow:auto|scroll
ancestor — never the overflow:hidden ones, which are what the portal exists to
escape.
- Re-measure from a ResizeObserver on both panel and trigger (its first callback
IS the initial measurement, post-layout and pre-paint, so nothing paints at
stale coordinates) plus scroll (capture: true, since scroll does not bubble)
and resize, all rAF-coalesced. Bail out when the computed layout is identical,
or applying your own max-height re-triggers the observer forever. Disconnect
and cancel the frame on unmount.
- Focus the CANCEL button on open: a destructive action must never be one Enter
away from someone who opened the card with the keyboard. Paint the unmeasured
first frame at opacity 0 rather than visibility: hidden — an opacity-0 element
is still focusable.
- Cycle Tab inside the panel by hand. A portalled card is nowhere near its
trigger in DOM order, so without a cycle the next Tab jumps to the end of the
document.
- Escape (document listener, capture phase, stopPropagation so a surrounding
dialog does not close on the same press) and Cancel both close AND return focus
to the trigger. An outside pointerdown closes and leaves focus where the person
pressed. Exclude the trigger from the outside test, or its own click would
reopen what this listener just closed.
Behavior — time without a hydration mismatch
- The absolute stamp is the caller's job. A component that formats dates itself
renders one string in Node and another in the browser, which is a guaranteed
mismatch on a row that ships inside a server-rendered transcript.
- The relative age is derived client-side only: null on the server and on the
hydrating frame, filled in afterwards. Wrap whichever text is showing — the
caller's timeLabel before hydration, the live age after it — in
<time dateTime={iso}>, so assistive tech and crawlers get the real instant
instead of a rendered phrase. A row given only createdAt therefore carries no
stamp until it mounts: pass timeLabel as well whenever the server-rendered
markup has to state the time.
- One self-rescheduling timer per row, waking at the next unit boundary
(remaining slice of the minute / hour) instead of every 60s, so a row mounted
at :59 flips a second later rather than a minute stale. Clear it on unmount and
on every createdAt change.
- Clamp future timestamps to "just now": a marker that sits ABOVE the reader must
never speak in the future tense about ordering.
Rendering & styling
- Semantic tokens only: border / border-border / border-dashed for the seam,
bg-muted + text-muted-foreground for the resting glyph, bg-primary/5 +
border-primary/40 + text-primary when current, bg-popover /
text-popover-foreground for the card, text-destructive for the fork glyph and
the error line. No hex, no rgb(), no oklch().
- The row reads as a seam, not a card: full width, border-y border-dashed, no
background of its own except when current. It must not compete with the
messages above and below it.
- Layout: 24px glyph tile · title + origin badge · optional description · a meta
line of "age · absolute stamp · N messages since", each part whitespace-nowrap
and separated by an aria-hidden middot. The row is a @container, and the
absolute stamp is the first thing dropped on a narrow one — the age and the
cost both matter more than the exact minute.
- The card's entrance keyframes ship in a React 19 hoisted <style href
precedence>, one keyframe per side so it always slides away from its trigger,
with motion-reduce:[animation:none]; the pending spinner is
motion-reduce:animate-none. Nothing about the flow depends on animation.
- One sr-only role="status" aria-live="polite" region carries a single sentence
when a restore lands ("Restored checkpoint X. 12 messages moved to a branch.").
Clear it before a retry so an identical sentence is a real DOM mutation and
gets spoken again.
Customization levers
- confirmThreshold is the main dial: 1 (default) warns whenever anything would
fork, 0 always warns, a large number turns the card into a courtesy for big
rollbacks only. confirm={false} hands the whole guard to your own dialog and
makes the trigger a plain button.
- renderConfirmBody rewrites the cost sentence in your product's vocabulary
(turns, drafts, runs) while keeping the card, the focus order and the latch;
confirmTitle / confirmLabel / cancelLabel cover the rest of the copy.
- side and confirmWidth place the card; drop the flip/shift pass entirely and
render the card inline if your transcript never clips.
- Density: the row is py-2.5 / text-sm with a size-6 glyph — shrink to py-1.5 and
drop the description for a log-style transcript, or promote the whole row to a
bordered card by swapping border-y for border + rounded-lg + bg-card.
- Which sub-blocks exist: the origin badge, the description and the meta line are
independent — a minimal build is glyph + label + Restore.
- Accent: `current` uses the primary token family; point it at a chart token
(var(--chart-2)) or at muted if checkpoints should never draw the eye.
- onRestore is yours to implement: this component only sequences the guard, the
latch and the announcement. Forking the messages, snapshotting the workspace
and naming the branch all happen in your callback.Concepts
- Proportional confirmation — the guard is sized to the damage: the fork warning appears only when messages actually exist after the checkpoint (
messagesSince >= confirmThreshold), so a rollback that costs nothing never asks, and the card keeps meaning something when it does appear. - Fork warning — the card does not ask "are you sure", it states what happens to the messages that came later: they leave the main thread and continue in a branch. The consequence is the whole content of a destructive confirmation.
- One-shot latch — the restore lock lives in a ref, not in state, so the second half of a double click is refused inside the same event-loop turn; a rejected restore releases the lock and re-arms instead of stranding a spinner.
- aria-disabled keeps focus — an in-flight or spent trigger is marked
aria-disabledand refuses the click in its handler, because thedisabledattribute would make it unfocusable and drop a keyboard user out of the transcript at the exact moment they confirmed. - Return-focus asymmetry — Cancel and Escape put focus back on the trigger, an outside press leaves it where the person pressed; both are "close", but only one of them was a decision about this card.
- Hydration-safe age — the absolute stamp is caller-formatted and the relative age is computed client-side only, appearing after mount; the machine-readable
<time dateTime>is emitted from the first paint so nothing is lost before hydration.
Run Summary
The receipt for one finished agent run — outcome and reason, a duration/token/cost stat strip, and an expandable per-model table that says so when its rows don't add up to the headline totals.
Computer Use Panel
A computer-use session viewer — screenshots with the agent's click, type, scroll and drag markers drawn on them, a synced action log you step through with the arrow keys, and live follow-and-takeover.