AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, Check, ChevronRight, FileDiff, LoaderCircle, RefreshCcw, Undo2 } from "lucide-react"

import { cn } from "@/lib/utils"
import type {
  FileChangeKind,
  FileChangeListItem,
  FileChangeListStatus,
  FileChangeStatus,
} from "./file-change-list.contract"

/** Same alphabet as `git status --porcelain`, because the reader already knows it. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/file-change-list.json

Prompt

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; no children, no chevron. A control that looks pressable and does nothing costs more trust than the feature it was standing in for.

On This Page