AI Patterns

code

Diff Summary Card

A collapsed summary of a batch of file edits, with undo and an overflow list.

Edited 11 files

now
"use client";

import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { ChevronDown, ChevronRight, Code2, Copy, Pin, Volume2 } from "lucide-react";

import { cn } from "@/lib/utils";

export interface DiffFile {
  id: string;
  name: string;
  additions: number;
  deletions: number;
}

export interface DiffSummaryCardProps {
  files: DiffFile[];
  visibleCount?: number;
  timestamp?: string;
  onUndo?: () => void;
  onViewChanges?: () => void;
  className?: string;
}

export function DiffSummaryCard({
  files,
  visibleCount = 5,
  timestamp = "now",
  onUndo,
  onViewChanges,
  className,
}: DiffSummaryCardProps) {
  const [expanded, setExpanded] = React.useState(false);
  const primary = files.slice(0, visibleCount);
  const rest = files.slice(visibleCount);

  return (
    <div className={cn("w-full overflow-hidden rounded-2xl border bg-card", className)}>
      <div className="flex items-center justify-between gap-3 border-b px-4 py-3">
        <p className="text-xs font-medium">Edited {files.length} files</p>
        <div className="flex items-center gap-2">
          <button
            type="button"
            onClick={onUndo}
            className="text-xs text-muted-foreground transition-colors hover:text-foreground"
          >
            Undo
          </button>
          <button
            type="button"
            onClick={onViewChanges}
            className="rounded-full border px-3 py-1 text-xs font-medium transition-colors hover:bg-accent"
          >
            View changes
          </button>
        </div>
      </div>

      <ul className="divide-y">
        {primary.map((file) => (
          <FileRow key={file.id} file={file} />
        ))}
      </ul>

      <AnimatePresence initial={false}>
        {expanded && rest.length > 0 && (
          <motion.ul
            initial={{ height: 0, opacity: 0 }}
            animate={{ height: "auto", opacity: 1 }}
            exit={{ height: 0, opacity: 0 }}
            transition={{ duration: 0.2, ease: "easeInOut" }}
            className="divide-y overflow-hidden border-t"
          >
            {rest.map((file) => (
              <FileRow key={file.id} file={file} />
            ))}
          </motion.ul>
        )}
      </AnimatePresence>

      {rest.length > 0 && (
        <button
          type="button"
          onClick={() => setExpanded((v) => !v)}
          className="flex w-full items-center gap-1.5 border-t px-4 py-2.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
        >
          {expanded ? "Show less" : `Show ${rest.length} more`}
          <ChevronDown className={cn("size-4 transition-transform", expanded && "rotate-180")} />
        </button>
      )}

      <div className="flex items-center justify-between border-t px-4 py-2.5">
        <div className="flex items-center gap-1 text-muted-foreground">
          <button
            type="button"
            aria-label="Copy"
            className="rounded-md p-1.5 transition-colors hover:bg-accent hover:text-foreground"
          >
            <Copy className="size-3.5" />
          </button>
          <button
            type="button"
            aria-label="Pin"
            className="rounded-md p-1.5 transition-colors hover:bg-accent hover:text-foreground"
          >
            <Pin className="size-3.5" />
          </button>
          <button
            type="button"
            aria-label="Read aloud"
            className="rounded-md p-1.5 transition-colors hover:bg-accent hover:text-foreground"
          >
            <Volume2 className="size-3.5" />
          </button>
        </div>
        <span className="text-xs text-muted-foreground">{timestamp}</span>
      </div>
    </div>
  );
}

function FileRow({ file }: { file: DiffFile }) {
  return (
    <li>
      <button
        type="button"
        className="flex w-full items-center gap-2.5 px-4 py-2.5 text-left transition-colors hover:bg-accent/50"
      >
        <Code2 className="size-4 shrink-0 text-muted-foreground" />
        <span className="flex-1 truncate text-xs">{file.name}</span>
        <span className="shrink-0 font-mono text-xs">
          <span className="text-emerald-600 dark:text-emerald-400">+{file.additions}</span>
          <span className="ml-1.5 text-red-600 dark:text-red-400">-{file.deletions}</span>
        </span>
        <ChevronRight className="size-4 shrink-0 text-muted-foreground" />
      </button>
    </li>
  );
}

A UX spec for this pattern — written for agents implementing or reusing it, not the code.

# Diff Summary Card

## Summary
A collapsed summary of a batch of file edits an agent just made: a count, an undo action, a "view changes" action, a per-file list of names with add/delete line counts, and an overflow expander for large batches. It lets the user trust that changes happened and skim their scope without reading every diff.

## When to use
- Immediately after an agent finishes a multi-file edit (a coding assistant, a batch content update, a config migration).
- When individual files have a clear, quantifiable diff (lines added/removed) worth surfacing at a glance.

## When not to use
- For a single-file edit — a one-line inline confirmation is enough; a whole card is overkill for one file.
- As the only way to inspect what changed. "View changes" must lead somewhere real (a diff viewer); this card is a summary, not a substitute for the actual diff.
- For edits that can't be undone. Only offer "Undo" when it actually reverts the change — a decorative Undo that doesn't work erodes trust fast.

## Anatomy
- Header: file count ("Edited N files"), an Undo action, a View changes action.
- File list: one row per file — an icon, the file name (truncated, not wrapped), additions/deletions counts, and an affordance that the row is clickable.
- Overflow control: "Show N more" beneath the first handful of rows, expanding in place.
- Footer: secondary actions (e.g. copy, pin, read aloud) and a timestamp.

## Behavior
- Show only the first handful of files (5–8) by default; collapse the rest behind "Show N more" so the card doesn't dominate the screen for large batches.
- Expanding the overflow list animates height smoothly rather than snapping; it does not replace or reflow the already-visible rows.
- Additions and deletions are always shown together (+N in one color, -N in another) so scanning the list gives an at-a-glance sense of which files grew, shrank, or were rewritten.
- Undo should act on the whole batch, not per file — this card represents one atomic change.
- Clicking a file row should open that file's specific diff, not the whole batch's.

## Content guidelines
- File names show their path when it disambiguates (e.g. two files with the same basename in different folders) — don't silently truncate to just the basename.
- The header count and the actual number of rows must always agree, including after expanding.

## Accessibility
- Each file row and the overflow toggle must be reachable and operable by keyboard, not just by mouse.
- Additions/deletions must not rely on color alone — the +/- sign already carries the meaning, so keep it even if you restyle the colors.
- The overflow toggle should update its accessible name/state (e.g. `aria-expanded`) when toggled.

## Related patterns
- Often appears as the terminal state of an agent turn, after a Thinking Loader/Expandable Trace sequence — the "here's what I actually changed" summary once work completes.