agentui
componentfile-diffsource: ok · hit
File Diff
A syntax-highlighted file change disclosure with progressive rows, line numbers, live change counts, smooth following, and completion collapse.
npx shadcn add "https://www.agentui.pro/r/file-diff.json"free
Interaction
accordion
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumiconslucide-react
animationmotion
Files5 · source cached 10/9/2026, 3:21:26 PM
components/agents/file-diff.tsx
"use client";
// www.agentui.pro/components/agents/file-diff
import {
Check,
ChevronDown,
Copy,
FileCode2,
LoaderCircle,
} from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import {
type ReactNode,
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import {
type AgentCodeLanguage,
AgentCodeLine,
useAgentCodeTokens,
} from "@/components/agents/agent-code";
import { AgentDisclosure } from "@/components/agents/agent-disclosure";
import { SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type FileDiffStatus = "streaming" | "complete";
export type FileDiffLineType = "added" | "removed" | "context";
export interface FileDiffLine {
id: string;
type?: FileDiffLineType;
oldLine?: number;
newLine?: number;
content: string;
}
export interface FileDiffProps {
file: ReactNode;
lines: FileDiffLine[];
status?: FileDiffStatus;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
collapseOnComplete?: boolean;
maxHeight?: number;
language?: AgentCodeLanguage;
copyText?: string;
onCopy?: () => void | Promise<void>;
className?: string;
}
function ChangeCount({ value, type }: { value: number; type: "added" | "removed" }) {
if (!value) return null;
return (
<span
className={cn(
"font-mono text-xs tabular-nums",
type === "added"
? "text-emerald-600 dark:text-emerald-400"
: "text-rose-600 dark:text-rose-400",
)}
>
{type === "added" ? "+" : "−"}
{value}
</span>
);
}
export function FileDiff({
file,
lines,
status = "streaming",
open,
defaultOpen = true,
onOpenChange,
collapseOnComplete = true,
maxHeight = 220,
language = "typescript",
copyText,
onCopy,
className,
}: FileDiffProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const triggerId = `${baseId}-trigger`;
const contentId = `${baseId}-content`;
const viewportRef = useRef<HTMLDivElement>(null);
const previousStatus = useRef(status);
const copyTimer = useRef<number | undefined>(undefined);
const [copied, setCopied] = useState(false);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const currentOpen = open ?? internalOpen;
const streaming = status === "streaming";
const additions = lines.filter((line) => line.type === "added").length;
const deletions = lines.filter((line) => line.type === "removed").length;
const canCopy = Boolean(copyText || onCopy);
const code = lines.map((line) => line.content).join("\n");
const tokens = useAgentCodeTokens(code, language);
const setOpen = useCallback(
(next: boolean) => {
if (open === undefined) setInternalOpen(next);
onOpenChange?.(next);
},
[onOpenChange, open],
);
useEffect(() => {
if (previousStatus.current !== "streaming" && status === "streaming") {
setOpen(true);
}
if (
previousStatus.current === "streaming" &&
status === "complete" &&
collapseOnComplete
) {
setOpen(false);
}
previousStatus.current = status;
}, [collapseOnComplete, setOpen, status]);
useEffect(
() => () => {
if (copyTimer.current) window.clearTimeout(copyTimer.current);
},
[],
);
useLayoutEffect(() => {
const viewport = viewportRef.current;
if (!viewport || !currentOpen || !streaming) return;
const frame = requestAnimationFrame(() => {
if (viewport.scrollHeight <= viewport.clientHeight) return;
if (typeof viewport.scrollTo === "function") {
viewport.scrollTo({
top: viewport.scrollHeight,
behavior: reduce ? "auto" : "smooth",
});
} else {
viewport.scrollTop = viewport.scrollHeight;
}
});
return () => cancelAnimationFrame(frame);
});
const handleCopy = useCallback(async () => {
if (onCopy) await onCopy();
else if (copyText) await navigator.clipboard?.writeText(copyText);
setCopied(true);
if (copyTimer.current) window.clearTimeout(copyTimer.current);
copyTimer.current = window.setTimeout(() => setCopied(false), 1600);
}, [copyText, onCopy]);
return (
<div
data-state={status}
aria-busy={streaming}
className={cn("w-full text-sm", className)}
>
<button
id={triggerId}
type="button"
aria-expanded={currentOpen}
aria-controls={contentId}
onClick={() => setOpen(!currentOpen)}
className="group flex min-h-9 w-full items-center gap-2 rounded-md py-1 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<FileCode2
aria-hidden="true"
className="size-4 shrink-0 text-muted-foreground"
/>
<span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground/80">
{file}
</span>
<span className="flex shrink-0 items-center gap-2">
<ChangeCount value={additions} type="added" />
<ChangeCount value={deletions} type="removed" />
</span>
<span className="grid size-4 shrink-0 place-items-center text-muted-foreground/60">
{streaming ? (
<LoaderCircle
aria-label="Applying changes"
className={cn("size-3.5", !reduce && "animate-spin")}
/>
) : (
<Check aria-label="Changes applied" className="size-3.5" />
)}
</span>
<motion.span
aria-hidden="true"
animate={{ rotate: currentOpen ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="shrink-0 text-muted-foreground/45 transition-colors group-hover:text-muted-foreground"
>
<ChevronDown className="size-3.5" />
</motion.span>
</button>
<AgentDisclosure
id={contentId}
role="region"
aria-labelledby={triggerId}
open={currentOpen}
>
<div className="pl-6 pt-1.5">
<div className="overflow-hidden rounded-xl bg-muted/80">
<div
ref={viewportRef}
data-slot="file-diff-viewport"
aria-live="polite"
className="scrollbar-hide overflow-auto"
style={{ maxHeight }}
>
<div className="font-mono text-xs leading-5">
<span className="sr-only">File changes</span>
{lines.map((line, index) => {
const type = line.type ?? "context";
return (
<div
key={line.id}
className={cn(
"grid grid-cols-[2.25rem_2.25rem_1rem_minmax(0,1fr)]",
type === "added" && "bg-emerald-500/[0.07]",
type === "removed" && "bg-rose-500/[0.07]",
)}
>
<span className="select-none pr-2 text-right tabular-nums text-muted-foreground/40">
{line.oldLine}
</span>
<span className="select-none pr-2 text-right tabular-nums text-muted-foreground/40">
{line.newLine}
</span>
<span
className={cn(
"select-none text-center text-muted-foreground/45",
type === "added" &&
"text-emerald-600 dark:text-emerald-400",
type === "removed" &&
"text-rose-600 dark:text-rose-400",
)}
>
{type === "added"
? "+"
: type === "removed"
? "−"
: ""}
</span>
<AgentCodeLine
code={line.content}
tokens={tokens?.[index]}
className="min-w-0 whitespace-pre px-1.5"
/>
</div>
);
})}
</div>
</div>
{canCopy ? (
<div className="flex justify-end px-2 pb-1.5 pt-1">
<motion.button
type="button"
aria-label={copied ? "Copied" : "Copy diff"}
title={copied ? "Copied" : "Copy diff"}
onClick={handleCopy}
whileTap={reduce ? undefined : { scale: 0.9 }}
transition={SPRING_PRESS}
className="grid size-7 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-background/70 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{copied ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
</motion.button>
</div>
) : null}
</div>
</div>
</AgentDisclosure>
</div>
);
}
components/agents/agent-code.tsx
"use client";
import {
type CSSProperties,
Fragment,
useEffect,
useState,
} from "react";
import { createHighlighter, type Highlighter } from "shiki";
import { cn } from "@/lib/utils";
export type AgentCodeLanguage =
| "bash"
| "diff"
| "json"
| "text"
| "tsx"
| "typescript";
export interface AgentCodeToken {
content: string;
offset: number;
light?: string;
dark?: string;
}
export type AgentCodeTokenLines = AgentCodeToken[][];
export interface AgentCodeProps {
code: string;
language?: AgentCodeLanguage;
className?: string;
}
export interface AgentCodeLineProps {
code: string;
tokens?: AgentCodeToken[];
className?: string;
}
const LIGHT_THEME = "github-light-high-contrast";
const DARK_THEME = "github-dark-high-contrast";
let agentCodeHighlighter: Promise<Highlighter> | null = null;
const tokenCache = new Map<string, AgentCodeTokenLines>();
function getAgentCodeHighlighter() {
if (!agentCodeHighlighter) {
agentCodeHighlighter = createHighlighter({
themes: [LIGHT_THEME, DARK_THEME],
langs: ["bash", "diff", "json", "tsx", "typescript"],
});
}
return agentCodeHighlighter;
}
function tokenCacheKey(code: string, language: AgentCodeLanguage) {
return `${language}\u0000${code}`;
}
export function useAgentCodeTokens(
code: string,
language: AgentCodeLanguage,
) {
const key = tokenCacheKey(code, language);
const cached = tokenCache.get(key);
const [result, setResult] = useState<{
key: string;
code: string;
language: AgentCodeLanguage;
lines: AgentCodeTokenLines;
} | null>(cached ? { key, code, language, lines: cached } : null);
useEffect(() => {
const current = tokenCache.get(key);
if (current) {
setResult({ key, code, language, lines: current });
return;
}
let cancelled = false;
getAgentCodeHighlighter().then((highlighter) => {
if (cancelled) return;
const lines = highlighter
.codeToTokensWithThemes(code, {
lang: language,
themes: {
light: LIGHT_THEME,
dark: DARK_THEME,
},
})
.map((line) =>
line.map((token) => ({
content: token.content,
offset: token.offset,
light: token.variants.light?.color,
dark: token.variants.dark?.color,
})),
);
tokenCache.set(key, lines);
setResult({ key, code, language, lines });
});
return () => {
cancelled = true;
};
}, [code, key, language]);
if (result?.key === key) return result.lines;
if (result?.language === language && code.startsWith(result.code)) {
return result.lines;
}
return null;
}
export function AgentCodeLine({
code,
tokens,
className,
}: AgentCodeLineProps) {
return (
<span className={className}>
{tokens
? tokens.map((token) => (
<span
key={`${token.offset}-${token.content}`}
style={
{
"--agent-code-light": token.light ?? "currentColor",
"--agent-code-dark": token.dark ?? token.light ?? "currentColor",
} as CSSProperties
}
className="text-[var(--agent-code-light)] dark:text-[var(--agent-code-dark)]"
>
{token.content}
</span>
))
: code}
</span>
);
}
export function AgentCode({
code,
language = "bash",
className,
}: AgentCodeProps) {
const tokens = useAgentCodeTokens(code, language);
let offset = 0;
const lines = code.split("\n").map((content) => {
const line = { content, offset };
offset += content.length + 1;
return line;
});
return (
<pre
className={cn(
"m-0 overflow-x-auto whitespace-pre font-mono text-xs leading-5 text-foreground/85",
className,
)}
>
<code>
{lines.map((line, index) => (
<Fragment key={line.offset}>
<AgentCodeLine code={line.content} tokens={tokens?.[index]} />
{index < lines.length - 1 ? "\n" : null}
</Fragment>
))}
</code>
</pre>
);
}
components/agents/agent-disclosure.tsx
"use client";
import { motion, type HTMLMotionProps, useReducedMotion } from "motion/react";
import type { CSSProperties } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface AgentDisclosureProps
extends Omit<HTMLMotionProps<"div">, "animate" | "initial"> {
open: boolean;
openHeight?: CSSProperties["height"];
}
/** Shared transform-only reveal for collapsible agent content. */
export function AgentDisclosure({
open,
openHeight = "auto",
className,
style,
transition,
...props
}: AgentDisclosureProps) {
const reduce = useReducedMotion() ?? false;
return (
<motion.div
{...props}
aria-hidden={!open}
inert={!open}
initial={false}
animate={
reduce
? { opacity: open ? 1 : 0 }
: {
opacity: open ? 1 : 0,
clipPath: open ? "inset(0 0 0% 0)" : "inset(0 0 100% 0)",
y: open ? 0 : -4,
}
}
transition={
transition ?? {
duration: reduce ? 0 : open ? 0.22 : 0.14,
ease: EASE_OUT,
}
}
className={cn("overflow-hidden", className)}
style={{
...style,
height: open ? openHeight : 0,
pointerEvents: open ? undefined : "none",
transformOrigin: "top",
}}
/>
);
}
lib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}