{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-changes",
  "type": "registry:ui",
  "title": "File changes",
  "description": "What an agent changed in a codebase, to review before keeping it: the files added, modified, deleted and renamed, each with its lines added and removed said in words and figures, each opening to its diff, each kept or undone on its row, and Keep all and Undo all for the rest. Send keeps the view's accent fill: Keep all is a raised block unless accent is set on a screen with no composer.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/button.json",
    "https://littmd.vercel.app/r/code-block.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/motion.json",
    "https://littmd.vercel.app/r/rolling-number.json",
    "https://littmd.vercel.app/r/tooltip.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/file-changes.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/file-changes 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * File changes: what an agent changed in a codebase, to review before keeping it (components.md, \"File changes\";\n * proposals/agents-2.md). The same component is an edit call's details (one file) and the end of a turn (every file).\n *\n * - Anatomy: bare in the turn, as a Plan is (R3: a review is not an approval, so it is no box). A header, when\n *   `summary`, at least 32px tall (44px on a finger): \"4 files changed\" (small, the title weight, --litt-fg) over a caption\n *   in --litt-fg-subtle, \"15 lines added, 9 removed · 4 left to review\", the count a Rolling number. At its end Keep all (a\n *   secondary sm button, the raised block; primary with `accent`) and Undo all (ghost sm). Once any file is decided\n *   they say \"Keep the rest\" and \"Undo the rest\", and both act on pending files only, so a press never re-decides a\n *   file the person chose. With nothing pending the buttons go, the header keeps its height, and the caption is a\n *   receipt: \"All reviewed: 3 kept, 1 undone\". Below 28rem (a container query) the buttons sit under the words.\n * - Flat: with a `summary` the review is a grey card at the surface radius with no frame, its rows concentric inside it.\n * - Row: a list item at least 32px tall (44px on a finger) at the control radius. A disclosure button holds the\n *   chevron, a 16px FileIcon in --litt-fg-subtle and the path in mono at the small size, the directory in --litt-fg-subtle and\n *   cut first, from its start so the nearest folder stays (\"…styles/fonts.css\"), the file name in --litt-fg and never\n *   cut. After it, in the caption size: the kind in words for added, deleted and renamed in --litt-fg-muted (modified\n *   shows none), the counts \"+24 −6\" (tabular, the sign in --litt-success or --litt-danger, the figures in --litt-fg-muted, the minus\n *   U+2212), and a decided file's word: \"Kept\" with the check in --litt-fg-muted, or \"Undone\" with the undo mark in\n *   --litt-fg-subtle. A rename's old\n *   path is a line of its own under it (\"from src/styles/typography.css\"). Below 28rem the kind, the counts and the\n *   word drop to a second line. A row that opens takes fg 6% under the pointer, and the fill reaches 8px past the\n *   words, so the row's chevron stands in the column where a tool call shows its mark.\n * - Verbs: beside the button, outside it, Keep (a check) and Undo (the undo mark), ghost sm icon-only buttons in\n *   Tooltips, named with the file (\"Keep src/app/layout.tsx\"). A row shows the verbs that differ from its state:\n *   pending shows both, kept shows Undo, undone shows Keep. Each has a fixed slot, so a verb never moves and nothing\n *   beside a decided row shifts. No strikethrough and no tinted rows (R7): the path steps back to --litt-fg-muted (kept) or\n *   --litt-fg-subtle (undone), and the word says it (R5).\n * - Diff: a CodeBlock with lang \"diff\", no filename (the row says it) and a 20rem cap, indented 24px under the row in\n *   a copy of Tool call's Reveal, so this item installs without tool-call. One change starts open, many start closed,\n *   and `defaultOpen` overrides. A change with no diff has no chevron, unless `onOpenChange` is given: then it opens\n *   and says \"Loading the diff\" until the caller passes one.\n * - Counts: `added` and `removed` when given, else counted from the diff's hunks as CodeBlock reads them (`+` and `-`\n *   lines, never the `+++` and `---` header lines).\n * - Decisions: `onDecide(ids, decision)` is the only write path, and the component holds no decision: a change's\n *   `state` is the caller's. Keep all passes every pending id, so pressing it twice converges.\n * - The moment: a decision settles the row. The verbs give way to the word at once, the path steps back on the\n *   instant tier, and the header's count rolls down on the responsive tier. Nothing else moves.\n * - Fun mode (motion/fun.tsx, a trial, off unless a <FunMode> is on): the check of each row Keep all decides pops in a\n *   wave down the list (k × fun.wave apart, scale 0.6 to 1 on fun.pop), and a single Keep pops its own. Only a press\n *   here pops a check: a state the caller changes by itself settles as in Calm (R4). The transform is cleared when it\n *   lands, and a mark stands on its own paint layer in both modes, so the rest frame is Calm's to the pixel.\n * - Keys and assistive tech: the files are a list named by `label`. Tab visits each row's disclosure (aria-expanded,\n *   aria-controls while open, named in one run by the path, the kind, the lines in words and the decision: \"src/app.tsx,\n *   added, 12 lines added, kept\"; the drawn parts are hidden from it), then its verbs; Enter or Space opens the diff,\n *   whose scroller is the next stop. No single-letter keys: every verb is a Tab stop. One polite\n *   status says the decisions made with this list's own buttons once the caller has set them: \"Kept\n *   src/app/layout.tsx\", \"Undid 3 files\" and \"All reviewed: 3 kept, 1 undone\", never on the first render. A second\n *   copy of the same changes (an edit call's beside the review) shows a decision made in the other without saying it. When a\n *   pressed verb had focus and is replaced, focus moves to the verb that replaces it; when Keep all leaves, focus\n *   moves to the header, which reads the receipt (Tool approval's pattern).\n * - Motion: only transform and opacity move, plus color on the instant tier. The chevron turns a quarter on the\n *   instant tier. A diff fades in and travels a quarter of the travel token on the responsive tier and leaves on the\n *   exit token; its height snaps. Reduced motion: the hooks return 0 and fun mode is off, so everything happens at once.\n * - Touch: rows, verbs and the header's buttons are 44px on a finger (useDevice, so a phone preview shows it too). A\n *   pressed row takes its hover fill. A Tooltip opens on a press and hold, and the verbs' names carry the file.\n * - Disabled or read-only: with no `onDecide` there are no verbs and no header buttons; the rows show their state\n *   words only. Empty: \"No files changed.\"\n * - Forced colors: the signs and marks are glyphs, the state is words, and Keep all keeps the Button's border.\n */\nimport { useEffect, useId, useLayoutEffect, useRef, useState, type MouseEvent, type ReactNode } from \"react\";\nimport { animate, AnimatePresence, motion } from \"motion/react\";\nimport { Button } from \"./button\";\nimport { CodeBlock } from \"./code-block\";\nimport { CheckIcon, ChevronRightIcon, FileIcon, UndoIcon } from \"./icons\";\nimport { RollingNumber } from \"./rolling-number\";\nimport { Tooltip } from \"./tooltip\";\nimport { useDevice, useExit, useFun, useTransition, useTravel } from \"../../lib/litt/motion\";\n\n/* ----------------------------------------------------------------- data */\n\n/** What happened to a file. The row shows it in words, except a plain edit, which only its spoken name says. */\nexport type FileChangeKind = \"added\" | \"modified\" | \"deleted\" | \"renamed\";\n\n/** Where the person's review of a file stands. */\nexport type FileChangeState = \"pending\" | \"kept\" | \"undone\";\n\n/** What a person can decide about a file. Nobody decides \"pending\". */\nexport type FileDecision = Exclude<FileChangeState, \"pending\">;\n\ntype FileChangeBase = {\n  /** A stable id, unique in the review: the path is a good one. Decisions are reported by id; a later duplicate is left out. */\n  id: string;\n  /** The file's path now, with slashes: \"src/app/layout.tsx\". The directory is cut first and the file name never. */\n  path: string;\n  /** The file's unified diff, shown as a CodeBlock when the row opens. Leave it out until you have it, and pass onOpenChange to load it. */\n  diff?: string;\n  /** Lines added. Left out, it is counted from `diff`. */\n  added?: number;\n  /** Lines removed. Left out, it is counted from `diff`. */\n  removed?: number;\n  /** Where the review stands. Defaults to pending. It is yours: set it from onDecide. */\n  state?: FileChangeState;\n};\n\n/** One file the agent changed. Only a rename names where the file came from. */\nexport type FileChange = FileChangeBase &\n  ({ kind: Exclude<FileChangeKind, \"renamed\">; from?: never } | { kind: \"renamed\"; /** The path before the rename. */ from: string });\n\nexport type FileChangesProps = {\n  /** The files the agent changed, in the order to review them. Each one's `state` is yours: set it in onDecide. */\n  changes: FileChange[];\n  /** Called with the ids a press decides and the decision: one id from a row's Keep or Undo, every pending id from Keep all or Undo all. Set those changes' state in it. Without it the list is read-only and shows the state words only. */\n  onDecide?: (ids: string[], decision: FileDecision) => void;\n  /** Names the list for assistive tech. */\n  label?: string;\n  /** Shows the header: how many files changed, the lines in words, how many are left to review, and Keep all and Undo all. Turn it off for one file in an edit call's details, where the call's row says the rest. */\n  summary?: boolean;\n  /** Whether every diff starts open. Left out, one change starts open and many start closed. */\n  defaultOpen?: boolean;\n  /** Makes Keep all the primary action, the view's one accent fill, on a review screen with no Composer. In a chat leave it off: Send keeps the accent. */\n  accent?: boolean;\n  /** Called with the change and whether its diff is now open, when the person opens or closes a row. With it a change with no diff opens too and says \"Loading the diff\" until you pass one. */\n  onOpenChange?: (change: FileChange, open: boolean) => void;\n  /** Extra classes for the review, such as a top margin. It fills its container. */\n  className?: string;\n};\n\n/* ----------------------------------------------------------------- what the changes say */\n\n/** What the kind says: the visible word, or a hidden one for a plain edit. */\nconst KIND: Record<FileChangeKind, { word: string; shown: boolean }> = {\n  added: { word: \"Added\", shown: true },\n  modified: { word: \"modified\", shown: false },\n  deleted: { word: \"Deleted\", shown: true },\n  renamed: { word: \"Renamed\", shown: true },\n};\n\n/** A decided file's word, its mark and how far its path steps back. Pending says nothing. */\nconst SETTLED: Record<FileDecision, { word: string; tone: string; name: string }> = {\n  kept: { word: \"Kept\", tone: \"text-litt-fg-muted\", name: \"text-litt-fg-muted\" },\n  undone: { word: \"Undone\", tone: \"text-litt-fg-subtle\", name: \"text-litt-fg-subtle\" },\n};\n\n/** The two verbs in the order of their slots. A row shows the ones that differ from its state. */\nconst DECISIONS: readonly FileDecision[] = [\"kept\", \"undone\"];\nconst VERB: Record<FileDecision, { label: string; icon: ReactNode }> = {\n  kept: { label: \"Keep\", icon: <CheckIcon /> },\n  undone: { label: \"Undo\", icon: <UndoIcon /> },\n};\nconst other = (d: FileDecision): FileDecision => (d === \"kept\" ? \"undone\" : \"kept\");\n\n/** Lines added and removed, read from the diff as CodeBlock reads it: with hunks, nothing before the first @@ counts. */\nfunction countDiff(diff: string): { added: number; removed: number } {\n  const lines = diff.replace(/\\r\\n?/g, \"\\n\").split(\"\\n\");\n  const hunks = lines.some((l) => l.startsWith(\"@@\"));\n  let inHunk = !hunks;\n  let added = 0;\n  let removed = 0;\n  for (const line of lines) {\n    if (line.startsWith(\"@@\")) inHunk = true;\n    else if (!inHunk || (!hunks && (line.startsWith(\"+++\") || line.startsWith(\"---\")))) continue;\n    else if (line.startsWith(\"+\")) added++;\n    else if (line.startsWith(\"-\")) removed++;\n  }\n  return { added, removed };\n}\n\ntype Row = { change: FileChange; state: FileChangeState; added: number; removed: number };\ntype Review = { rows: Row[]; added: number; removed: number; pending: number; kept: number; undone: number };\n\n/** One reading of the changes for the header, the rows, the live region and the moment, so they never disagree. A later duplicate id is left out. */\nfunction readReview(changes: readonly FileChange[]): Review {\n  const seen = new Set<string>();\n  const rows: Row[] = [];\n  for (const change of changes) {\n    if (seen.has(change.id)) continue;\n    seen.add(change.id);\n    const counted = change.added === undefined || change.removed === undefined ? countDiff(change.diff ?? \"\") : { added: 0, removed: 0 };\n    rows.push({ change, state: change.state ?? \"pending\", added: change.added ?? counted.added, removed: change.removed ?? counted.removed });\n  }\n  const count = (s: FileChangeState) => rows.filter((r) => r.state === s).length;\n  return {\n    rows,\n    added: rows.reduce((n, r) => n + r.added, 0),\n    removed: rows.reduce((n, r) => n + r.removed, 0),\n    pending: count(\"pending\"),\n    kept: count(\"kept\"),\n    undone: count(\"undone\"),\n  };\n}\n\nconst lines = (n: number) => `${n} ${n === 1 ? \"line\" : \"lines\"}`;\nconst files = (n: number) => `${n} ${n === 1 ? \"file\" : \"files\"}`;\n\n/** \"All reviewed: 3 kept, 1 undone\". The header's receipt and what the live region says, in the same words. */\nconst receipt = (r: Review) => `All reviewed: ${[r.kept && `${r.kept} kept`, r.undone && `${r.undone} undone`].filter(Boolean).join(\", \")}`;\n\n/** \"24 lines added, 6 removed\", \"12 lines added\", \"6 lines removed\": what the figures say, for screen readers. */\nfunction spokenCounts(added: number, removed: number): string {\n  if (added && removed) return `${lines(added)} added, ${removed} removed`;\n  if (added) return `${lines(added)} added`;\n  return removed ? `${lines(removed)} removed` : \"\";\n}\n\n/** \"Kept src/app/layout.tsx\" or \"Kept 3 files\". */\nconst decided = (verb: string, rows: Row[]) => (rows.length === 1 ? `${verb} ${rows[0].change.path}` : `${verb} ${files(rows.length)}`);\n\n/** What a row says to a screen reader, in one run: \"src/styles/type.css, renamed from src/styles/typography.css, 1 line added, 1 removed, kept\". */\nfunction spokenRow({ change, state, added, removed }: Row): string {\n  const kind = KIND[change.kind].word.toLowerCase() + (change.kind === \"renamed\" ? ` from ${change.from}` : \"\");\n  return [change.path, kind, spokenCounts(added, removed), state === \"pending\" ? \"\" : SETTLED[state].word.toLowerCase()].filter(Boolean).join(\", \");\n}\n\n/* ----------------------------------------------------------------- shared pieces */\n\nconst COLOR_FADE = \"transition-colors duration-(--litt-dur-instant) ease-litt\";\n/* A row that opens takes fg 6% under the pointer, and under a finger while it is pressed. */\nconst ROW_FILL =\n  \"transition-colors duration-(--litt-dur-instant) ease-litt hover:bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)] \" +\n  \"pointer-coarse:active:bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)]\";\n\n/** The body under a row, copied from Tool call's Reveal: it fades in and travels a quarter of the travel token on the responsive tier, and leaves on the exit token. Its height snaps. */\nfunction Reveal({ open, id, className = \"\", children }: { open: boolean; id: string; className?: string; children: ReactNode }) {\n  const enter = useTransition(\"responsive\");\n  const exit = useExit(\"responsive\");\n  const travel = useTravel();\n  /* A body open on the first render appears without a fade. Not through AnimatePresence initial={false}, which would\n     swallow the entrances of every motion component inside it (see tabs.tsx). */\n  const mounted = useRef(false);\n  useEffect(() => {\n    mounted.current = true;\n  }, []);\n  return (\n    <AnimatePresence>\n      {open && (\n        <motion.div\n          key=\"body\"\n          id={id}\n          className={className}\n          initial={mounted.current ? { opacity: 0, y: -travel / 4 } : false}\n          animate={{ opacity: 1, y: 0, transition: enter }}\n          exit={{ opacity: 0, transition: exit }}\n        >\n          {children}\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n}\n\n/** The path in mono: the directory in the quiet ink, cut first and from its start; the file name in its row's ink, never cut (it wraps as a last resort). */\nfunction Path({ path, tone }: { path: string; tone: string }) {\n  /* The directory is cut from its start, so the folder nearest the file stays: \"…styles/fonts.css\". Right to left\n     only for where the ellipsis goes; the words inside stay an isolated left-to-right run. */\n  const slash = path.lastIndexOf(\"/\");\n  return (\n    <span className=\"flex min-w-0 max-w-full font-litt-mono text-litt-small\">\n      {slash > 0 && (\n        <span className=\"min-w-0 truncate text-litt-fg-subtle [direction:rtl]\">\n          <span dir=\"ltr\">{path.slice(0, slash)}</span>\n        </span>\n      )}\n      <span className={\"max-w-full shrink-0 [overflow-wrap:anywhere] \" + tone + \" \" + COLOR_FADE}>{path.slice(Math.max(0, slash))}</span>\n    </span>\n  );\n}\n\n/** The counts as \"+24 −6\", the sign in its status color. Nothing when no line changed. The row's spoken name says them in words. */\nfunction Counts({ added, removed }: { added: number; removed: number }) {\n  if (!added && !removed) return null;\n  return (\n    <span className=\"shrink-0 whitespace-nowrap text-litt-caption font-normal tabular-nums text-litt-fg-muted\">\n      {added > 0 && (\n        <>\n          <span className=\"text-litt-success\">+</span>\n          {added}\n        </>\n      )}\n      {added > 0 && removed > 0 && \" \"}\n      {removed > 0 && (\n        <>\n          <span className=\"text-litt-danger\">{\"−\"}</span>\n          {removed}\n        </>\n      )}\n    </span>\n  );\n}\n\n/* ----------------------------------------------------------------- FileChanges */\n\n/** Who the focus goes to once a press has been decided: the verb that replaces the pressed one, or the header. */\ntype Handoff = { ids: string[]; decision: FileDecision; bulk: boolean };\n\n/**\n * What an agent changed: a header with the counts, Keep all and Undo all, and a row per file with its lines added and\n * removed, its diff under it, and Keep and Undo. It holds no decision of its own: it reports them through onDecide and\n * shows the state you give back, so the same files can be an edit call's details and the end-of-turn review.\n */\nexport function FileChanges({\n  changes,\n  onDecide,\n  label = \"Files changed\",\n  summary = true,\n  defaultOpen,\n  accent = false,\n  onOpenChange,\n  className = \"\",\n}: FileChangesProps) {\n  const id = useId();\n  const { coarse } = useDevice();\n  const fun = useFun();\n  const review = readReview(changes);\n  const { rows } = review;\n  const decidedCount = review.kept + review.undone;\n\n  /* Disclosure is the only state the review keeps: it dies with the page. What starts open is settled on the first render. */\n  const [startOpen] = useState(defaultOpen ?? rows.length === 1);\n  const [opened, setOpened] = useState<Record<string, boolean>>({});\n\n  const verbs = useRef(new Map<string, HTMLButtonElement>());\n  const marks = useRef(new Map<string, HTMLSpanElement>());\n  const header = useRef<HTMLDivElement>(null);\n  const handoff = useRef<Handoff | null>(null);\n  /* The files a press here asked to keep. Only those pop in fun mode: a state the caller changes alone settles as in Calm. */\n  const byHand = useRef(new Set<string>());\n  /* The files a press here decided, for the live region. A second copy of the same changes (an edit call's beside the\n     review) shows the decision without saying it again. */\n  const pressed = useRef(new Set<string>());\n\n  const press = (ids: string[], decision: FileDecision, bulk: boolean, e: MouseEvent<HTMLButtonElement>) => {\n    if (!ids.length) return;\n    handoff.current = e.currentTarget === document.activeElement ? { ids, decision, bulk } : null;\n    if (decision === \"kept\") ids.forEach((i) => byHand.current.add(i));\n    ids.forEach((i) => pressed.current.add(i));\n    onDecide?.(ids, decision);\n  };\n  const pendingIds = rows.filter((r) => r.state === \"pending\").map((r) => r.change.id);\n\n  /* Each file's state at the last commit. The pop reads it before paint, then the live region reads it and moves it on. */\n  const last = useRef<Map<string, FileChangeState> | null>(null);\n\n  /* Fun mode's pop: the checks of the rows a press kept, in reading order, before they paint. A mark always stands\n     on its own paint layer (scale-100, in Calm too), so a wave of pops changes a value and never makes and drops\n     layers, whose fresh raster differs from the first by a few pixels of anti-aliasing: the rest frame is Calm's. */\n  useLayoutEffect(() => {\n    const before = last.current;\n    if (!before) return;\n    let k = 0;\n    for (const r of rows) {\n      const was = before.get(r.change.id);\n      if (was === undefined || was === r.state) continue;\n      const asked = byHand.current.delete(r.change.id);\n      const mark = marks.current.get(r.change.id);\n      if (!fun || !asked || r.state !== \"kept\" || !mark) continue;\n      animate(mark, { scale: [0.6, 1] }, { ...fun.pop, delay: k++ * fun.wave }).then(() => {\n        mark.style.transform = \"\";\n      });\n    }\n  });\n\n  /* One voice for the decisions made here. Quiet on the first render, and for a change only the caller made. */\n  const [said, setSaid] = useState(\"\");\n  useEffect(() => {\n    const before = last.current;\n    last.current = new Map(rows.map((r) => [r.change.id, r.state]));\n    if (!before) return;\n    const became = (s: FileDecision) =>\n      rows.filter((r) => r.state === s && before.has(r.change.id) && before.get(r.change.id) !== s && pressed.current.has(r.change.id));\n    const kept = became(\"kept\");\n    const undone = became(\"undone\");\n    for (const r of [...kept, ...undone]) pressed.current.delete(r.change.id);\n    const words: string[] = [];\n    if (kept.length) words.push(decided(\"Kept\", kept));\n    if (undone.length) words.push(decided(\"Undid\", undone));\n    const wasPending = rows.some((r) => (before.get(r.change.id) ?? \"pending\") === \"pending\");\n    if (words.length && review.pending === 0 && wasPending) words.push(receipt(review));\n    if (words.length) setSaid(words.join(\". \"));\n  });\n\n  /* Focus follows a press once the caller has decided it: to the verb that replaced the pressed one, or to the header\n     once Keep all and Undo all have gone. Only when focus fell to the page, so a person who moved on keeps their place. */\n  useEffect(() => {\n    const h = handoff.current;\n    if (!h) return;\n    const state = new Map(rows.map((r) => [r.change.id, r.state]));\n    if (h.ids.some((i) => state.has(i) && state.get(i) !== h.decision)) return;\n    handoff.current = null;\n    if (document.activeElement && document.activeElement !== document.body) return;\n    const next = h.bulk ? header.current : verbs.current.get(`${h.ids[0]} ${other(h.decision)}`);\n    next?.focus({ preventScroll: true });\n  });\n\n  const control = coarse ? \"md\" : \"sm\";\n  const slot = coarse ? \"size-11\" : \"size-8\";\n  const rowHeight = coarse ? \"min-h-11\" : \"min-h-8\";\n\n  const head = summary && rows.length > 0 && (\n    <div\n      ref={header}\n      tabIndex={-1}\n      className={\n        \"grid gap-2 rounded-litt-control @md:flex @md:items-center @md:justify-between @md:gap-4 \" +\n        \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus \" +\n        rowHeight\n      }\n    >\n      <div className=\"min-w-0\">\n        <p className=\"text-litt-small font-litt-title text-litt-fg\">{files(rows.length)} changed</p>\n        <p className=\"text-litt-caption font-normal text-litt-fg-subtle\">\n          {lines(review.added)} added, {review.removed} removed\n          <span aria-hidden=\"true\"> · </span>\n          <span className=\"sr-only\">. </span>\n          {review.pending > 0 ? (\n            <>\n              <RollingNumber value={review.pending} /> left to review\n            </>\n          ) : (\n            receipt(review)\n          )}\n        </p>\n      </div>\n      {onDecide &&\n        (review.pending > 0 ? (\n          <div className=\"flex shrink-0 items-center gap-2\">\n            <Button size={control} variant={accent ? \"primary\" : \"secondary\"} onClick={(e) => press(pendingIds, \"kept\", true, e)}>\n              {decidedCount ? \"Keep the rest\" : \"Keep all\"}\n            </Button>\n            <Button size={control} variant=\"ghost\" onClick={(e) => press(pendingIds, \"undone\", true, e)}>\n              {decidedCount ? \"Undo the rest\" : \"Undo all\"}\n            </Button>\n          </div>\n        ) : (\n          /* The buttons' place, kept so the header keeps its height when they go. */\n          <div aria-hidden=\"true\" className={\"shrink-0 \" + (coarse ? \"h-11\" : \"h-8\")} />\n        ))}\n    </div>\n  );\n\n  return (\n    <div\n      className={\n        \"@container min-w-0 \" +\n        (summary ? \"in-[[data-material=flat]]:rounded-litt-surface in-[[data-material=flat]]:bg-litt-surface-3 in-[[data-material=flat]]:p-4 in-[[data-material=flat]]:forced-colors:border \" : \"\") +\n        className\n      }\n    >\n      {head}\n      {rows.length === 0 ? (\n        <p className={\"text-litt-small text-litt-fg-subtle \" + (summary ? \"mt-2\" : \"\")}>No files changed.</p>\n      ) : (\n        <ul aria-label={label} className={\"m-0 grid list-none p-0 \" + (summary ? \"mt-2\" : \"\")}>\n          {rows.map((row) => {\n            const { change, state } = row;\n            const key = change.id;\n            const bodyId = `${id}-${key}`;\n            const opens = change.diff !== undefined || onOpenChange !== undefined;\n            const open = opens && (opened[key] ?? startOpen);\n            const toggle = () => {\n              setOpened((all) => ({ ...all, [key]: !open }));\n              onOpenChange?.(change, !open);\n            };\n            const settled = state === \"pending\" ? null : SETTLED[state];\n            const kind = KIND[change.kind];\n\n            /* The parts are drawn for the eye and said in one run (spokenRow), so a screen reader never hears the\n               layout's gaps inside a path or between the parts. */\n            const content = (\n              <>\n                <span className=\"sr-only\">{spokenRow(row)}</span>\n                <span aria-hidden=\"true\" className=\"grid h-5 place-items-center text-litt-fg-subtle\">\n                  {opens && (\n                    <ChevronRightIcon className={\"transition-[rotate] duration-(--litt-dur-instant) ease-litt \" + (open ? \"rotate-90\" : \"rotate-0\")} />\n                  )}\n                </span>\n                <span aria-hidden=\"true\" className=\"grid h-5 place-items-center text-litt-fg-subtle\">\n                  <FileIcon />\n                </span>\n                <span aria-hidden=\"true\" className=\"flex min-w-0 flex-wrap items-baseline gap-x-2\">\n                  <Path path={change.path} tone={settled ? settled.name : \"text-litt-fg\"} />\n                  <span className=\"flex min-w-0 items-center gap-2 @max-md:basis-full\">\n                    {kind.shown && <span className=\"shrink-0 text-litt-caption text-litt-fg-muted\">{kind.word}</span>}\n                    <Counts added={row.added} removed={row.removed} />\n                    {settled && (\n                      <span className={\"flex shrink-0 items-center gap-1 text-litt-caption \" + settled.tone + \" \" + COLOR_FADE}>\n                        <span\n                          className=\"inline-flex scale-100\"\n                          ref={(el) => {\n                            if (el) marks.current.set(key, el);\n                            else marks.current.delete(key);\n                          }}\n                        >\n                          {state === \"kept\" ? <CheckIcon /> : <UndoIcon />}\n                        </span>\n                        {settled.word}\n                      </span>\n                    )}\n                  </span>\n                  {change.kind === \"renamed\" && (\n                    <span className=\"min-w-0 basis-full truncate text-litt-caption font-normal text-litt-fg-subtle\">\n                      from <span className=\"font-litt-mono\">{change.from}</span>\n                    </span>\n                  )}\n                </span>\n              </>\n            );\n            const grid = \"grid min-w-0 flex-1 grid-cols-[1rem_1rem_minmax(0,1fr)] items-start gap-x-2 text-left \" + (coarse ? \"py-3\" : \"py-1.5\");\n\n            return (\n              <li key={key} className=\"min-w-0\">\n                <div\n                  className={\n                    \"-mx-2 flex min-w-0 items-start gap-2 rounded-litt-control px-2 in-[[data-material=flat]]:rounded-[calc(var(--litt-radius-surface)-4px)] \" + rowHeight + (opens ? \" \" + ROW_FILL : \"\")\n                  }\n                >\n                  {opens ? (\n                    <button\n                      type=\"button\"\n                      aria-expanded={open}\n                      aria-controls={open ? bodyId : undefined}\n                      onClick={toggle}\n                      className={\n                        grid +\n                        \" cursor-pointer rounded-litt-control [-webkit-tap-highlight-color:transparent] \" +\n                        \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus\"\n                      }\n                    >\n                      {content}\n                    </button>\n                  ) : (\n                    <div className={grid}>{content}</div>\n                  )}\n                  {onDecide && (\n                    <div className=\"flex shrink-0\">\n                      {DECISIONS.map((decision) => (\n                        <span key={decision} className={\"grid place-items-center \" + slot}>\n                          {decision !== state && (\n                            <Tooltip content={VERB[decision].label}>\n                              <Button\n                                ref={(el: HTMLButtonElement | null) => {\n                                  const k = `${key} ${decision}`;\n                                  if (el) verbs.current.set(k, el);\n                                  else verbs.current.delete(k);\n                                }}\n                                iconOnly\n                                size={control}\n                                variant=\"ghost\"\n                                aria-label={`${VERB[decision].label} ${change.path}`}\n                                onClick={(e) => press([key], decision, false, e)}\n                              >\n                                {VERB[decision].icon}\n                              </Button>\n                            </Tooltip>\n                          )}\n                        </span>\n                      ))}\n                    </div>\n                  )}\n                </div>\n                {opens && (\n                  <Reveal open={open} id={bodyId} className=\"pt-1 pb-2 pl-6\">\n                    {change.diff !== undefined ? (\n                      <CodeBlock lang=\"diff\" code={change.diff} label={`Diff of ${change.path}`} maxHeight=\"20rem\" />\n                    ) : (\n                      <p className=\"py-1.5 text-litt-small text-litt-fg-subtle\">Loading the diff</p>\n                    )}\n                  </Reveal>\n                )}\n              </li>\n            );\n          })}\n        </ul>\n      )}\n      <div role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {said}\n      </div>\n    </div>\n  );\n}\n",
      "target": "components/litt/file-changes.tsx"
    }
  ],
  "docs": "Import FileChanges from components/litt/file-changes. Pass changes ({ id, path, kind, diff?, added?, removed?, from? for a rename, state? }) and onDecide(ids, decision), and set each change's state from it: the component holds no decision of its own. Use one change in an edit call's details and every change at the end of the turn.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
