AI Patterns

code

Diff Tabs

Per-file chips that switch an inline diff viewer between a batch of changed files.

thinking-loader/component.tsx+4-1
return (
<div className="flex items-center gap-3 rounded-xl border">
<div
aria-live="polite"
className="flex items-center gap-3 rounded-xl border"
>
"use client";

import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { FileCode2 } from "lucide-react";

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

export interface DiffLine {
  type: "context" | "add" | "remove";
  content: string;
}

export interface DiffTabFile {
  id: string;
  name: string;
  additions: number;
  deletions: number;
  lines: DiffLine[];
}

export interface DiffTabsProps {
  files: DiffTabFile[];
  defaultFileId?: string;
  className?: string;
}

export function DiffTabs({ files, defaultFileId, className }: DiffTabsProps) {
  const [activeId, setActiveId] = React.useState(defaultFileId ?? files[0]?.id);
  const active = files.find((file) => file.id === activeId) ?? files[0];

  return (
    <div className={cn("w-full", className)}>
      <div className="flex flex-wrap items-center gap-2" role="tablist">
        {files.map((file) => (
          <button
            key={file.id}
            type="button"
            role="tab"
            aria-selected={file.id === active.id}
            onClick={() => setActiveId(file.id)}
            className={cn(
              "flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition-colors",
              file.id === active.id
                ? "border-foreground/20 bg-accent text-foreground"
                : "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
            )}
          >
            <span className="max-w-40 truncate">{file.name}</span>
            <DiffCounts additions={file.additions} deletions={file.deletions} />
          </button>
        ))}
      </div>

      <div className="mt-2 overflow-hidden rounded-2xl border bg-card">
        <div className="flex items-center gap-2 border-b px-4 py-2.5">
          <FileCode2 className="size-4 shrink-0 text-muted-foreground" aria-hidden />
          <span className="flex-1 truncate text-xs font-medium">{active.name}</span>
          <DiffCounts additions={active.additions} deletions={active.deletions} />
        </div>

        <AnimatePresence mode="wait" initial={false}>
          <motion.div
            key={active.id}
            initial={{ opacity: 0, y: 4 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: -4 }}
            transition={{ duration: 0.15, ease: "easeInOut" }}
            className="overflow-x-auto"
          >
            <pre className="min-w-full font-mono text-xs leading-relaxed">
              {active.lines.map((line, index) => (
                <div
                  key={index}
                  className={cn(
                    "px-4 py-0.5",
                    line.type === "add" && "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
                    line.type === "remove" && "bg-red-500/10 text-red-700 dark:text-red-400"
                  )}
                >
                  <span aria-hidden className="mr-2 inline-block w-3 select-none text-muted-foreground">
                    {line.type === "add" ? "+" : line.type === "remove" ? "-" : ""}
                  </span>
                  {line.content}
                </div>
              ))}
            </pre>
          </motion.div>
        </AnimatePresence>
      </div>
    </div>
  );
}

function DiffCounts({ additions, deletions }: { additions: number; deletions: number }) {
  return (
    <span className="shrink-0 font-mono text-[11px]">
      <span className="text-emerald-600 dark:text-emerald-400">+{additions}</span>
      {deletions > 0 && <span className="ml-1 text-red-600 dark:text-red-400">-{deletions}</span>}
    </span>
  );
}

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

# Diff Tabs

## Summary
A row of per-file chips — each showing a file name and its add/delete line counts — that switch an inline diff viewer below between files. It lets the user flip through every file an agent touched without leaving the message, reading the actual changed lines rather than just a name and a count.

## When to use
- Right after (or instead of) a Diff Summary Card, when the user is likely to want to actually read the changes inline rather than open a separate diff viewer.
- A small-to-medium batch of files (roughly 2–8) where showing every file as a chip stays scannable in one row.
- Edits precise enough to render as real add/remove/context lines, not just a file-level description.

## When not to use
- A single file — skip the tab row and show its diff directly.
- Large batches (dozens of files): a wall of chips wrapping across many rows stops being scannable; fall back to a Diff Summary Card's list-plus-overflow instead.
- Binary or non-text changes (images, assets) — there's no line-level diff to render.

## Anatomy
- Tab row: one chip per file — file name (truncated, not wrapped) plus colored +N/-N counts — with the active file visually distinct (filled background) from the rest.
- Diff panel: header repeating the active file's name and counts, then the diff body.
- Diff body: one line per row, each tagged context / addition / removal, with removed and added lines tinted (not just colored text) so the eye can scan long files quickly.

## Behavior
- Selecting a chip swaps the diff panel's content; the swap should transition (a brief fade/slide), not hard-cut, so it reads as "same panel, new content" rather than a page change.
- Exactly one file is active at a time — this is a tab pattern, not a multi-select filter.
- The active chip and the diff header always show the same file name and counts; they must never fall out of sync.
- Long lines scroll horizontally within the diff panel rather than wrapping, which would break the line-by-line reading of a diff.

## Content guidelines
- File names show enough path to disambiguate same-named files in different folders; don't silently collapse to the basename.
- Additions and deletions are always shown together as +N/-N, even when one side is zero (omit only the zero side, e.g. a pure addition shows "+13" with no "-0").

## Accessibility
- The tab row and panel should use `role="tablist"`/`role="tab"` semantics (or equivalent) with `aria-selected` on the active chip, so the relationship between chip and panel is programmatic, not just visual.
- Chips must be reachable and operable by keyboard (arrow keys or tab order plus Enter/Space).
- Addition/removal styling must not rely on background tint alone if it's the only signal — keep the +/- marker in front of each line so the diff still reads in a high-contrast or no-color mode.

## Related patterns
- Diff Summary Card — the collapsed, file-list-only alternative for larger batches or when inline reading isn't the goal; Diff Tabs is the "let me actually read it here" counterpart.