File Change List
The files an agent touched — middle-truncated paths, A/M/D/R marks, churn bars scaled across the whole run, one-shot Accept/Revert per file plus a bulk bar, and a slot for the inline diff.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/file-change-list.jsonPrompt
Build a React + TypeScript + Tailwind "FileChangeList" component with zod and
lucide-react. It is the review surface for the files an agent just wrote: one
row per file, a decision per file, and a slot for the diff.
Contract
- A zod schema (`fileChangeListItemSchema` in a sibling contract file) is the
single source of truth for ONE file: { id, path, kind, additions, deletions,
status, previousPath?, binary?, note? }.
- `kind` is "added" | "modified" | "deleted" | "renamed" — rendered as the
A/M/D/R letters `git status --porcelain` already taught the reader.
- `status` is the HUMAN's position on the file: "pending" | "accepted" |
"reverted". Deliberately no "applying"/"busy" member: an in-flight round trip
is a property of YOUR request, not of the change, and baking it into the data
forces every failed accept to invent a fourth state to crawl back out of.
- `additions` / `deletions` are line counts, not a diff. The hunks never enter
the contract — that is what keeps a 400-file changeset from shipping 400
patches to the browser.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
list's own render state, independent of any row's review status. `ready` with
zero rows falls through to the empty branch.
- Props: items: Item[]; status; children?: (item) => ReactNode (the inline diff
slot); busyIds?: readonly string[]; onAccept?(id); onRevert?(id);
onAcceptAll?(ids); onRevertAll?(ids); onRetry?; onToggle?(id, open);
defaultExpandedIds?; expandMode? ("multiple" | "single"); maxVisible? (0 = no
cap); skeletonRows? (4, clamped 1–12); label? ("Changed files"); emptyState?;
errorMessage?; className plus the rest of the element props, ref forwarded to
the <section>.
Behavior
- THE COMPONENT OWNS NO IN-FLIGHT STATE. Which rows are round-tripping is told
to it through `busyIds`, because only the caller knows when a request ends and
whether it ended well. A busy row spins, ignores clicks, and becomes
answerable again the moment you take its id out of the array — which is what
makes a REJECTED accept retryable.
- DECISIONS ARE ONE-SHOT. Each Accept/Revert fires at most once per decision,
because the guard is a Set in a ref, read and written synchronously inside the
click handler: several clicks dispatched in one task all read the same stale
state, so a state-only guard would let the extras through and accept the same
file three times. The key is `id + action + current status` (and, for the bulk
buttons, the whole list's `id:status` signature).
- The lock EXPIRES BY ITSELF: an effect keyed on the list signature and on the
busy signature clears the whole Set, so any commit where the data actually
moved — a status flip, or an id leaving `busyIds` — releases every lock taken
before it. A permanent lock would leave a dead button behind after each server
error; a lock that never held would double-apply a patch.
- Per-row actions follow one rule with no exceptions: Accept appears unless the
row is already accepted (labelled "Re-apply" when it is reverted), Revert
appears unless it is already reverted. The bulk buttons act on exactly the
same sets — every id that is not already accepted / not already reverted — and
hide themselves when their set is empty. No handler, no button: an affordance
only exists when the thing behind it was passed in.
- PATHS ARE MIDDLE-TRUNCATED, with no measuring. `text-overflow: ellipsis`
clips the tail, which on a path is the only part you need. Split the string at
the last separator and render two spans in a flex row: the directory shrinks
(min-w-0 + truncate), the file name does not (shrink-0 + max-w-full +
truncate, so a 90-character name truncates on its own instead of widening the
card). No ResizeObserver, no re-render on resize, correct at every width.
- The churn bar is scaled ACROSS THE RUN, not within the row: each bar's total
span is the row's `additions + deletions` over the largest churn in the list,
split by its own added/removed ratio. Two floors keep it honest — a row with
any churn always paints (a 3-line fix next to a 900-line regeneration would
otherwise read as "unchanged"), and a side that exists always paints — then
the two segments are renormalised so the bar can never overflow its track.
- `binary: true` replaces the numbers and the bar with the word "binary"; line
counts for a PNG are a lie told in tabular figures. Counts are also clamped at
render (negative, fractional or NaN becomes 0) because a live API will
eventually send one.
- The diff slot is a render prop. Pass `children` and every row becomes a real
<button aria-expanded> whose panel is UNMOUNTED when closed — a zero-height
collapse leaves every link and button inside the diff reachable by Tab, an
invisible keyboard trap — and `aria-controls` is only set while open so it
never points at an id that is not in the document. Omit `children` and rows
are plain: no chevron, nothing to press. `onToggle(id, open)` is the hook for
fetching that file's patch lazily.
- Rows are never re-sorted: the agent's order is information. Duplicate ids are
dropped (first occurrence wins) because they collide on React keys and on the
aria-controls wiring — one disclosure would toggle its twin.
- `maxVisible` caps the rows behind a control that names the exact number
withheld ("Show 18 more files") and toggles back. A cap you can see is a cap
you can trust; a fixed height with overflow hidden is not.
- The component starts no timers, no intervals and no observers, so there is
nothing to clean up and a screenshot of it is identical on the server, in a
replay and in a test.
Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
text-primary (+ bg-primary for the additions segment) for anything added or
accepted, text-destructive (+ bg-destructive) for anything removed, bg-muted
for the bar track and for the reviewed-row tint. No hard-coded colours.
- Four kinds are four LETTERS in a bordered square (A/M/D/R), each with an
sr-only word; the tint only reinforces the letter, so a monochrome theme, a
printed screenshot and a colour-blind reader all keep the meaning. The review
status is likewise an icon plus a word, and the word is sr-only when the
breakpoint hides it.
- The root is a <section aria-label> holding one persistent sr-only
role="status" line ("3 of 8 changed files reviewed. 2 accepted, 1 reverted."),
mounted for the whole ready branch so a CHANGE is announced from an element
the screen reader is already watching. Line counts are announced as prose
("48 lines added, 12 lines removed"), never as "+48 −12".
- Action buttons use aria-disabled, never the native `disabled` attribute:
`disabled` blurs the button the instant a round trip starts, dropping focus to
<body> mid-action and removing the control from the tab order.
- Rows wrap on narrow widths (the actions drop to their own line) instead of
squeezing the path; the chevron rotation and every hover transition are
motion-reduce-guarded, and the loading skeleton's pulse stops there too.
Skeleton widths are derived from the row index, never Math.random(), so the
server and the client render the same markup.
- cn() merges the consumer's className into the root, which also spreads the
remaining element props and forwards its ref.
Customization levers
- Decision policy: pass only `onAccept` for a keep-only flow, only `onRevert`
for an undo-only flow, or neither for a read-only summary — the buttons follow
the handlers. Same for `onAcceptAll` / `onRevertAll` and the bulk bar.
- Round trips: adopt `busyIds` when your accept can fail or take time; skip it
entirely when you flip status synchronously and want an optimistic list.
- Density: `maxVisible` decides how many rows are drawn before the announced
reveal; `expandMode="single"` turns the diff slot into an accordion for narrow
panels; drop the `note` field from your data for a one-line-per-file list.
- Slot content: `children` can render your diff viewer, a syntax-highlighted
patch, a "fetch patch" button, or a plain summary — the list stays cheap
because it never owns the hunks. Use `onToggle` to fetch on first open.
- Copy: `label` names the region, `emptyState` replaces the empty body,
`errorMessage` + `onRetry` own the envelope failure, and the A/M/D/R letters
are a single map to translate if your users read another vocabulary.
- Tone: the added/removed pair is `text-primary` / `text-destructive` by
default; point them at `var(--chart-2)` / `var(--chart-4)` if your product
already spends primary on something else.Concepts
- Consumer-owned busy state — the list never invents an "applying" state of its own, because only the caller knows when the request came back and whether it succeeded. You hand it
busyIds; a row in that array spins and refuses clicks, and taking the id back out is the single signal that ends the interaction, in either direction. - One-shot decision lock that expires — the guard is a Set in a ref, read and written synchronously in the click handler, so a burst of clicks in one task can't apply the same patch twice. It is cleared by any commit where the data actually moved, which is what keeps a rejected accept clickable instead of dead.
- Middle truncation without measuring — a path clipped at the tail (
src/components/…/inde…) tells you nothing. Splitting at the last separator and letting only the directory half shrink puts the ellipsis in the middle with pure CSS: no ResizeObserver, no re-render on resize, and the file name survives every width. - Churn scaled across the run — a proportion bar that only splits one row's own additions and deletions says nothing about which file is the big one. Scaling every bar against the loudest row in the changeset makes the regenerated lockfile look like a regenerated lockfile — with a floor, so a three-line fix still paints instead of vanishing.
- The diff is a slot, not a feature — the list owns rows, counts and decisions; the hunks arrive through a render prop and only for rows that are open. That is what lets 400 files render without 400 patches, and what lets you plug in whichever diff renderer you already ship.
- Affordances follow handlers — no
onAccept, no Accept button; nochildren, no chevron. A control that looks pressable and does nothing costs more trust than the feature it was standing in for.
Inline Completion
Ghost text for a textarea — a caret-aligned mirror paints the model's continuation in muted type, Tab accepts it, Cmd/Ctrl+Right takes one word, Esc dismisses.
Interpreter Output
The result of one code-interpreter run — source collapsed by default, stdout / stderr / rich results behind tabs derived from what actually arrived, the exception pinned above them, tail-capped output that sticks while it streams, and a one-shot Re-run.