AI

Commit Card

One agent commit as a card — a Conventional Commits badge parsed out of the raw git message, the files it touched behind an announced cap, an agent and timestamp line, a two-step one-shot revert, and a grouped rail for a whole run.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  BookText,
  Bug,
  Check,
  ChevronRight,
  CircleAlert,
  Cog,
  Copy,
  CopyX,
  ExternalLink,
  FileDiff,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/commit-card.json

Prompt

Build a React + TypeScript + Tailwind "CommitCard" component with zod and
lucide-react, reusing your shadcn Badge and Button primitives.

Contract
- A zod schema (`commitCardItemSchema` in a sibling contract file) is the single
  source of truth for ONE commit: { sha, message, files[], agent, at, type?,
  scope?, breaking?, branch?, url?, reverted? }, where files[] is { path,
  additions, deletions, binary? } and agent is { name, model?, avatarUrl? }.
- `message` is the WHOLE git message, exactly as `git log -1 --format=%B` hands
  it over — subject line, blank line, body. Do NOT ask the caller to pre-split
  it; the component derives subject and body, and a data layer that had to split
  it first would be re-implementing this parser badly.
- `at` accepts an ISO string, epoch ms or a Date. `sha` is the FULL 40-char hash
  and is the identity: React key, disclosure key, revert lock key, and what the
  copy button writes.
- `additions` / `deletions` are LINE COUNTS, never diff text: a card that carried
  hunks would ship a megabyte of patch to render four rows. The diff lives behind
  `onViewDiff` / `url`, on the consumer's side.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
  card's own render state, independent of any commit. `status="error"` means the
  run log failed to load; a commit being reverted is a different thing entirely
  and reads differently on screen. `ready` with zero items renders the empty
  branch: an empty list is an empty state whatever label the envelope carries.
- Props: items: Commit[]; status; now? (injected clock); grouped? (defaults to
  items.length greater than 1); runLabel?; maxFiles? (3); shaLength? (7);
  defaultExpanded? (false); onViewDiff?(sha); onRevert?(sha); onRetry?; locale?
  ("en-US"); timeZone? ("UTC"); emptyState?; errorMessage?; label? ("Commits");
  className plus the rest of the element props, ref forwarded to the root.
- Numeric props are clamped (maxFiles 1..500, shaLength 4..40) and every line
  count is re-clamped to a non-negative integer at render time, so a 0, a NaN or
  a negative from a sloppy API can never print "+NaN" or hide every file.

Behavior
- CONVENTIONAL COMMITS ARE PARSED, NOT DEMANDED. Match the first line against
  `type(scope)!: subject`: the type becomes a badge, the scope a chip, the `!` a
  breaking mark, and the prefix is STRIPPED from the displayed subject so it is
  never printed twice. Explicit `type` / `scope` / `breaking` fields always win
  over what parsing found. An unknown type word (`hotfix`, `deps`, `wip`) is not
  an error — it renders with neutral chrome, because inventing types is what
  teams do. Two guards stop the parser eating the subject: a prefix with nothing
  after it, and a subject that starts with "//" (that is a URL, and "https" is
  not a commit type). A `BREAKING CHANGE:` footer raises the same mark as `!`.
- REVERT IS TWO-STEP AND ONE-SHOT. The first press ARMS the same button in place
  ("Revert" becomes "Confirm revert"): the trigger is never unmounted, so
  keyboard focus is never dropped to <body> mid-decision the way a swapped-in
  confirmation row would. The second press fires — unless it lands within a
  ~350 ms guard of the arm, because a double-click is one gesture, not two
  decisions. The guard is a timestamp compared inside the handler, so there is no
  timer to leak. Escape disarms, and stops propagation ONLY when it actually
  disarmed, so a card inside a dialog doesn't eat that dialog's Escape.
- The one-shot lock is a Set of shas in a REF, read and written synchronously in
  the click handler: several clicks dispatched inside one task all read the same
  stale state, so a state-only guard would ask your app to revert the same commit
  twice. A sha is permanent, so the lock never needs to expire. While the request
  is out the button reads "Reverting…" with aria-disabled — never the native
  `disabled` attribute, which blurs the button the instant it flips.
- `reverted: true` is the durable outcome and comes back in the DATA: the
  affordance disappears (you cannot revert a commit twice), a "reverted" badge
  takes its place, and the in-flight moment is NOT modelled in the contract — a
  failed round trip would otherwise need a fourth value to crawl back out of.
- TRUNCATION IS ALWAYS ANNOUNCED. Only `maxFiles` rows are drawn, above the
  commit's real totals — the totals are for the whole commit, never for the
  visible slice — and one control says exactly what is missing: "Show 21 more
  files", or "Show 21 more files and the full message" when there is a body too.
  Nothing on this path has a max-height or a line clamp; the cap is a number you
  can read and a control you can press.
- A file path renders as a dimmed directory plus an emphasised file name, and it
  is the DIRECTORY that truncates. A row ending in "…" tells the reviewer
  nothing; the file name is what they are scanning for. `binary: true` prints the
  word "binary" instead of an honest-looking "+0 −0" and is excluded from the
  line totals while still counting as a file. Zero files (a merge commit) says so
  instead of printing a total of zero.
- THE CARD OWNS NO CLOCK. Pass `now` and each timestamp reads "22 minutes ago",
  derived from `at - now` every render; omit it and the absolute date is printed
  instead. Calling Date.now() inside the component would desync server and client
  output and make every screenshot fixture unstable. `locale` and `timeZone`
  default to fixed values ("en-US", "UTC") for the same reason, and a typo'd
  locale falls back instead of throwing at Intl construction.
- GROUPED IS CHROME, NEVER A FILTER. Several commits are always a list: a section
  holding an ordered list on a rail whose connector stops at the last commit.
  `grouped` adds the run header on top of it — the run's own totals, where a file
  touched by two commits is counted ONCE, because "9 files touched" is a fact
  about the working tree, not a sum of rows. A single commit with no run chrome
  is a bare article instead, because a list of one is noise to read out. The flag
  never decides which commits survive, and the card never sorts: it renders your
  order.
- Identity is reconciled during render, not in an effect: when the sha list
  changes, a commit that is still there keeps whatever the reader opened, a
  commit that just streamed in adopts `defaultExpanded`, and an armed revert
  pointing at a commit that vanished is dropped. Appending to a live run must not
  collapse what someone was reading.
- Actions only render when they can do something: `onViewDiff` draws a button, a
  bare `url` draws a real anchor (target=_blank rel=noreferrer), and neither
  draws nothing at all. The sha chip copies the FULL hash, not the seven
  characters on screen, and a blocked clipboard flips to a visible failure —
  a silent no-op reads as a broken button.

Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
  bg-muted/30 for the file panel, text-destructive for the breaking mark and the
  envelope failure, the Badge and Button variants for everything interactive. No
  hard-coded colours anywhere — including the diff numbers, where the SIGN
  carries the meaning ("+96", "−14") so the card survives a monochrome theme, a
  greyscale print and a colour-blind reader. If your palette encodes add/remove,
  var(--chart-2) / var(--chart-5) is the opt-in lever.
- Commit types get an icon AND their own word (feat / fix / revert…): the word is
  the signal, the icon is the scan aid, the hue is neither.
- Root is an article (single) or a section with an ol (grouped), labelled
  "Commits: 9f4c1e7" / "Commits: 3 commits". The disclosure is a real
  button[aria-expanded] with no aria-controls, because the reveal GROWS the list
  that is already there instead of mounting a separate panel. Long subjects,
  paths, model names and branch names use `wrap-anywhere` plus `min-w-0`, NOT
  `break-words`: only the former lowers the element's min-content width, which is
  what actually stops a 100-character path from widening the card.
- A broken avatar falls back to initials, including the pre-hydration case a ref
  callback has to probe for (`complete && naturalWidth === 0`), so a dead URL
  never leaves a broken-image glyph. Every transition and the spinner are
  motion-reduce-guarded; the "Reverting…" state still reads without animation.
- cn() merges the consumer's className into the root, which also spreads the
  remaining element props and forwards its ref.

Customization levers
- Density: `maxFiles` decides how much of the changeset is drawn before the
  announced control — 3 in a chat transcript, 10 in a review panel, 500 to show
  everything. Never make it silent; the count is part of the reading.
- Shape: `grouped` forces the run chrome even for a single commit (a run that
  happened to produce one commit still reads as a run) or suppresses it for a
  feed where every card is its own row. `runLabel` titles the run.
- Identity: `shaLength` trades scan-ability for precision on screen only; copy
  always writes all 40 characters.
- Time: `now` turns timestamps relative — tick it once per app, not per card —
  and `locale` / `timeZone` own the wording and the zone. Drop `now` entirely in
  a screenshot fixture and every card prints a stable absolute date.
- Decisions: omit `onRevert` and the destructive path disappears completely;
  omit `onViewDiff` and a commit's `url` takes over as a real link. Raise the
  guard window above ~350 ms for a trackpad-heavy audience, or replace the
  in-place arm with your own ConfirmDialog if your app already has one — keep the
  ref lock either way.
- Chrome: `label` names the region, `emptyState` replaces the empty body, and
  `errorMessage` + `onRetry` own the envelope failure.

Concepts

  • The message is the source, not a pre-split payload — the card takes the raw git message and derives the subject, the body, the Conventional Commits type, the scope and the breaking mark itself, then strips the prefix so it is never printed twice. A data layer that had to split it first would be re-implementing this parser badly, and an unknown type word survives with neutral chrome instead of being swallowed.
  • Arm in place, then confirm — the revert trigger turns into its own confirmation rather than being replaced by a new row, so keyboard focus is never dropped to the document body in the middle of a destructive decision. Escape disarms, and only swallows the key when it actually did.
  • A double-click is one gesture — a confirm that lands within ~350 ms of the arm is ignored and the button stays armed. The guard is a timestamp compared inside the handler, not a timer, so there is nothing to clean up and nothing to leak.
  • One-shot lock keyed by sha — the guard is a Set in a ref, read and written synchronously in the click handler, because several clicks dispatched in one task all observe the same stale state. A sha is permanent, so the lock expires by itself: it never has to.
  • Truncation is always announced — only the first few files are drawn, but the totals are for the whole commit and one control names exactly what is missing ("Show 21 more files and the full message"). A silent cap makes you review the wrong changeset; the directory truncates on a long path, the file name never does.
  • Grouped counts the working tree, not the rows — a run's header sums the lines but counts a file touched by two commits once, because "9 files touched" is a claim about the repository. Appending a fourth commit to a live run reconciles identity during render, so nothing the reader opened closes underneath them.

On This Page