File Diff
A syntax-highlighted file change disclosure with progressive rows, line numbers, live change counts, smooth following, and completion collapse.
Preview
TSXcomponents/previews/agents/file-diff.preview.tsx
"use client";
import { RotateCcw } from "lucide-react";
import { useState } from "react";
import {
FileDiff,
type FileDiffLine,
} from "@/components/agents/file-diff";
import { useToolResultDemo } from "./use-tool-result-demo";
const DIFF_LINES: FileDiffLine[] = [
{ id: "1", oldLine: 18, newLine: 18, content: "export async function runTask() {" },
{
id: "2",
type: "removed",
oldLine: 19,
content: " return execute(task);",
},
{
id: "3",
type: "added",
newLine: 19,
content: " const result = await execute(task);",
},
{
id: "4",
type: "added",
newLine: 20,
content: " return normalize(result);",
},
{ id: "5", oldLine: 20, newLine: 21, content: "}" },
];
function FileRun() {
const { visible, status } = useToolResultDemo(DIFF_LINES.length, 360);
return (
<FileDiff
file="src/runner.ts"
lines={DIFF_LINES.slice(0, visible)}
status={status === "success" ? "complete" : "streaming"}
copyText={DIFF_LINES.map((line) => line.content).join("\n")}
maxHeight={150}
/>
);
}
export function FileDiffPreview() {
const [run, setRun] = useState(0);
return (
<div className="relative h-[300px] w-full max-w-lg">
<FileRun key={run} />
<button
type="button"
onClick={() => setRun((value) => value + 1)}
className="absolute bottom-0 left-0 inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<RotateCcw className="size-3" />
Replay
</button>
</div>
);
}
TSXcomponents/agents/file-diff.tsx
"use client";
// beui.dev/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>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/file-diff
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion shiki tailwind-mergeAdd util files
TSXlib/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;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/agents/file-diff.tsx
"use client";
// beui.dev/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>
);
}
TSXcomponents/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>
);
}
TSXcomponents/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",
}}
/>
);
}
TSXcomponents/previews/agents/use-tool-result-demo.ts
"use client";
import { useEffect, useState } from "react";
import type { ToolResultStatus } from "@/components/agents/tool-result";
export function useToolResultDemo(
steps: number,
interval = 420,
finalStatus: Exclude<ToolResultStatus, "running"> = "success",
) {
const [visible, setVisible] = useState(0);
const [status, setStatus] = useState<ToolResultStatus>("running");
useEffect(() => {
const timers = Array.from({ length: steps }, (_, index) =>
window.setTimeout(() => setVisible(index + 1), index * interval + 180),
);
timers.push(
window.setTimeout(
() => setStatus(finalStatus),
steps * interval + 480,
),
);
return () => timers.forEach(window.clearTimeout);
}, [finalStatus, interval, steps]);
return { visible, status };
}
TSXcomponents/agents/tool-result.tsx
"use client";
import {
Ban,
Braces,
Check,
ChevronDown,
CircleCheck,
CircleX,
Copy,
LoaderCircle,
RotateCcw,
SquareTerminal,
Wrench,
} from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import {
type ReactNode,
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import {
AgentCode,
type AgentCodeLanguage,
} from "@/components/agents/agent-code";
import { ActionSwapRollText } from "@/components/motion/action-swap-roll";
import { AgentDisclosure } from "@/components/agents/agent-disclosure";
import { SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ToolResultStatus = "running" | "success" | "error" | "cancelled";
export type ToolResultKind = "terminal" | "request" | "custom";
export interface ToolResultProps {
tool: ReactNode;
title: ReactNode;
children: ReactNode;
status?: ToolResultStatus;
kind?: ToolResultKind;
meta?: ReactNode;
icon?: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
collapseOnComplete?: boolean;
maxHeight?: number;
copyText?: string;
onCopy?: () => void | Promise<void>;
onRetry?: () => void;
className?: string;
contentClassName?: string;
}
export interface ToolResultOutputProps {
children: string;
language?: AgentCodeLanguage;
className?: string;
}
function getStatusLabel(status: ToolResultStatus) {
if (status === "running") return "Running";
if (status === "success") return "Completed";
if (status === "error") return "Failed";
return "Cancelled";
}
function getSwapKey(value: ReactNode, fallback: string) {
return typeof value === "string" || typeof value === "number"
? String(value)
: fallback;
}
function getStatusClass(status: ToolResultStatus) {
if (status === "running") {
return "text-blue-600 dark:text-blue-400";
}
if (status === "success") {
return "text-emerald-600 dark:text-emerald-400";
}
if (status === "error") {
return "text-rose-600 dark:text-rose-400";
}
return "text-muted-foreground";
}
function KindIcon({ kind }: { kind: ToolResultKind }) {
if (kind === "terminal") return <SquareTerminal className="size-4" />;
if (kind === "request") return <Braces className="size-4" />;
return <Wrench className="size-4" />;
}
function StatusIcon({
status,
reduce,
}: {
status: ToolResultStatus;
reduce: boolean;
}) {
if (status === "running") {
return <LoaderCircle className={cn("size-3", !reduce && "animate-spin")} />;
}
if (status === "success") return <CircleCheck className="size-3" />;
if (status === "error") return <CircleX className="size-3" />;
return <Ban className="size-3" />;
}
function ToolResultAction({
label,
onClick,
children,
}: {
label: string;
onClick: () => void;
children: ReactNode;
}) {
const reduce = useReducedMotion() ?? false;
return (
<motion.button
type="button"
aria-label={label}
title={label}
onClick={onClick}
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-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{children}
</motion.button>
);
}
export function ToolResultOutput({
children,
language = "bash",
className,
}: ToolResultOutputProps) {
return (
<AgentCode
code={children}
language={language}
className={cn(
"whitespace-pre-wrap break-words text-foreground/80",
className,
)}
/>
);
}
export function ToolResult({
tool,
title,
children,
status = "running",
kind = "custom",
meta,
icon,
open,
defaultOpen = true,
onOpenChange,
collapseOnComplete = true,
maxHeight = 220,
copyText,
onCopy,
onRetry,
className,
contentClassName,
}: ToolResultProps) {
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 running = status === "running";
const canCopy = Boolean(copyText || onCopy);
const titleKey = getSwapKey(title, status);
const metaKey = getSwapKey(meta, `${status}-meta`);
const toolKey = getSwapKey(tool, `${status}-tool`);
const statusLabel = getStatusLabel(status);
const setOpen = useCallback(
(next: boolean) => {
if (open === undefined) setInternalOpen(next);
onOpenChange?.(next);
},
[onOpenChange, open],
);
useEffect(() => {
if (previousStatus.current !== "running" && status === "running") {
setOpen(true);
}
if (
previousStatus.current === "running" &&
status !== "running" &&
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 || !running) return;
const frame = requestAnimationFrame(() => {
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={running}
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"
>
<span
aria-hidden="true"
className="grid size-4 shrink-0 place-items-center text-muted-foreground"
>
{icon ?? <KindIcon kind={kind} />}
</span>
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="min-w-0 truncate font-medium text-foreground/90">
<ActionSwapRollText value={titleKey}>
{title}
</ActionSwapRollText>
</span>
{meta ? (
<span className="shrink-0 text-xs text-muted-foreground/60">
<ActionSwapRollText value={metaKey}>
{meta}
</ActionSwapRollText>
</span>
) : null}
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground/55">
<ActionSwapRollText value={toolKey}>
{tool}
</ActionSwapRollText>
</span>
</span>
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 text-[11px] font-medium",
getStatusClass(status),
)}
>
<StatusIcon status={status} reduce={reduce} />
<ActionSwapRollText value={status}>{statusLabel}</ActionSwapRollText>
</span>
<motion.span
aria-hidden="true"
animate={{ rotate: currentOpen ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="shrink-0 text-muted-foreground/50 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}
role="log"
aria-live="polite"
className="scrollbar-hide overflow-y-auto"
style={{ maxHeight }}
>
<div className={cn("p-3", contentClassName)}>{children}</div>
</div>
{canCopy || onRetry ? (
<div className="flex items-center gap-0.5 px-2 pb-1.5">
{canCopy ? (
<ToolResultAction
label={copied ? "Copied" : "Copy result"}
onClick={handleCopy}
>
{copied ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
</ToolResultAction>
) : null}
{onRetry ? (
<ToolResultAction label="Run again" onClick={onRetry}>
<RotateCcw className="size-3.5" />
</ToolResultAction>
) : null}
<span className="ml-auto text-[11px] text-muted-foreground/55">
<ActionSwapRollText value={status}>
{statusLabel}
</ActionSwapRollText>
</span>
</div>
) : null}
</div>
</div>
</AgentDisclosure>
</div>
);
}
TSXcomponents/motion/action-swap-roll.tsx
"use client";
import {
ActionSwapButton,
ActionSwapIcon,
ActionSwapText,
type ActionSwapButtonProps,
type ActionSwapIconProps,
type ActionSwapTextProps,
} from "./action-swap";
export type {
ActionSwapButtonSize,
ActionSwapButtonVariant,
ActionSwapItem,
} from "./action-swap";
export type ActionSwapRollButtonProps = Omit<ActionSwapButtonProps, "animation">;
export type ActionSwapRollTextProps = Omit<ActionSwapTextProps, "animation">;
export type ActionSwapRollIconProps = Omit<ActionSwapIconProps, "animation">;
export function ActionSwapRollButton(props: ActionSwapRollButtonProps) {
return <ActionSwapButton {...props} animation="roll" />;
}
export function ActionSwapRollText(props: ActionSwapRollTextProps) {
return <ActionSwapText {...props} animation="roll" />;
}
export function ActionSwapRollIcon(props: ActionSwapRollIconProps) {
return <ActionSwapIcon {...props} animation="roll" />;
}
TSXcomponents/motion/action-swap.tsx
"use client";
import { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps, type Variants } from "motion/react";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { EASE_OUT, EASE_OUT_CSS, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ActionSwapItem = {
id: string;
label: ReactNode;
icon?: ReactNode;
ariaLabel?: string;
};
export type ActionSwapButtonVariant = "primary" | "secondary" | "outline" | "ghost";
export type ActionSwapButtonSize = "sm" | "md" | "lg" | "icon";
export type ActionSwapAnimation = "blur" | "roll" | "cascade";
/** Animations with a single-element variant set (cascade animates per letter). */
type CoreAnimation = "blur" | "roll";
export interface ActionSwapButtonProps extends Omit<
HTMLMotionProps<"button">,
"children" | "onChange"
> {
items: ActionSwapItem[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string, item: ActionSwapItem) => void;
variant?: ActionSwapButtonVariant;
size?: ActionSwapButtonSize;
animation?: ActionSwapAnimation;
iconOnly?: boolean;
cycle?: boolean;
}
export interface ActionSwapTextProps {
value: string;
children: ReactNode;
animation?: ActionSwapAnimation;
className?: string;
}
export interface ActionSwapIconProps {
value: string;
children: ReactNode;
animation?: ActionSwapAnimation;
className?: string;
}
const BLUR_TRANSITION = { duration: 0.2, ease: "easeInOut" } as const;
const ROLL_TRANSITION = SPRING_SWAP;
const ROLL_EXIT_TRANSITION = { duration: 0.14, ease: EASE_OUT } as const;
const SWAP_BLUR = "blur(8px)";
const ROLL_BLUR = "blur(3px)";
// Cascade rolls the label one letter at a time, left to right. The leaving
// and landing strings overlap as independent layers (no shared cells), so
// proportional glyph widths never jitter. Exits cascade at half the enter
// stagger so the tail of the old label lingers briefly.
const CASCADE_STAGGER = 0.025;
const CASCADE_LETTER_VARIANTS: Variants = {
initial: { opacity: 0, y: "105%", filter: ROLL_BLUR },
animate: (delay: number = 0) => ({
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number = 0) => ({
opacity: 0,
y: "-105%",
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },
}),
};
const TEXT_VARIANTS: Record<CoreAnimation, Variants> = {
blur: {
initial: { opacity: 0, scale: 0.94, filter: SWAP_BLUR },
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
transition: BLUR_TRANSITION,
},
exit: {
opacity: 0,
scale: 0.94,
filter: SWAP_BLUR,
transition: BLUR_TRANSITION,
},
},
roll: {
initial: { opacity: 0, y: "90%", filter: ROLL_BLUR },
animate: {
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: ROLL_TRANSITION,
},
exit: {
opacity: 0,
y: "-90%",
filter: ROLL_BLUR,
transition: ROLL_EXIT_TRANSITION,
},
},
};
const ICON_VARIANTS: Record<CoreAnimation, Variants> = {
blur: {
initial: { opacity: 0, scale: 0.25, filter: SWAP_BLUR },
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
transition: BLUR_TRANSITION,
},
exit: {
opacity: 0,
scale: 0.25,
filter: SWAP_BLUR,
transition: BLUR_TRANSITION,
},
},
roll: {
initial: { opacity: 0, y: 12, filter: ROLL_BLUR },
animate: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: ROLL_TRANSITION,
},
exit: {
opacity: 0,
y: -12,
filter: ROLL_BLUR,
transition: ROLL_EXIT_TRANSITION,
},
},
};
const VARIANT_CLASS: Record<ActionSwapButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
outline: "border border-border bg-transparent text-foreground hover:bg-primary/5",
ghost: "text-muted-foreground hover:bg-primary/5 hover:text-foreground",
};
const SIZE_CLASS: Record<ActionSwapButtonSize, string> = {
sm: "h-8 gap-1.5 rounded-full px-3 text-xs",
md: "h-10 gap-2 rounded-full px-4 text-sm",
lg: "h-12 gap-2.5 rounded-full px-5 text-base",
icon: "h-10 w-10 rounded-full",
};
export function ActionSwapText({
value,
children,
animation = "blur",
className,
}: ActionSwapTextProps) {
const reduce = useReducedMotion();
const measureRef = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number>();
useLayoutEffect(() => {
const nextWidth = measureRef.current?.offsetWidth;
if (!nextWidth) return;
setWidth((currentWidth) => (currentWidth === nextWidth ? currentWidth : nextWidth));
});
// Cascade needs a plain string to split into letters; non-string content
// and reduced motion fall back to the closest single-element animation.
const label = typeof children === "string" ? children : null;
const cascade = animation === "cascade" && label !== null && !reduce;
const coreAnimation: CoreAnimation =
animation === "cascade" ? "roll" : animation;
return (
<span
className={cn("relative inline-block overflow-hidden whitespace-nowrap align-bottom", className)}
style={{
width,
transition: reduce ? undefined : `width 220ms ${EASE_OUT_CSS}`,
}}
>
<span
ref={measureRef}
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{children}
</span>
{cascade ? (
<>
{/* Letters are decorative fragments; readers get the whole label. */}
<span className="sr-only">{label}</span>
<AnimatePresence initial={false}>
<motion.span
key={`cascade-${value}`}
aria-hidden
initial="initial"
animate="animate"
exit="exit"
className="absolute left-0 top-0 inline-block whitespace-pre"
>
{label.split("").map((char, i) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity — the letter at a position is exactly what rolls.
key={i}
custom={i * CASCADE_STAGGER}
variants={CASCADE_LETTER_VARIANTS}
className="inline-block whitespace-pre will-change-[opacity,filter,transform]"
>
{char}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</>
) : (
<AnimatePresence initial={false}>
<motion.span
key={`${animation}-${value}`}
variants={TEXT_VARIANTS[coreAnimation]}
initial={reduce ? false : "initial"}
animate={reduce ? { opacity: 1, filter: "blur(0px)", scale: 1, y: 0 } : "animate"}
exit={reduce ? undefined : "exit"}
className="absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</span>
);
}
export function ActionSwapIcon({
value,
children,
animation = "blur",
className,
}: ActionSwapIconProps) {
const reduce = useReducedMotion();
// Icons are single elements — cascade maps to its closest motion, roll.
const coreAnimation: CoreAnimation =
animation === "cascade" ? "roll" : animation;
return (
<span className={cn("relative inline-grid shrink-0 place-items-center overflow-hidden", className)}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={`${animation}-${value}`}
aria-hidden
variants={ICON_VARIANTS[coreAnimation]}
initial={reduce ? false : "initial"}
animate={reduce ? { opacity: 1, filter: "blur(0px)", scale: 1, y: 0 } : "animate"}
exit={reduce ? undefined : "exit"}
className="col-start-1 row-start-1 inline-flex items-center justify-center will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
</span>
);
}
export function ActionSwapButton({
items,
value,
defaultValue,
onValueChange,
variant = "secondary",
size = "md",
animation = "blur",
iconOnly = size === "icon",
cycle = true,
className,
disabled,
onClick,
...rest
}: ActionSwapButtonProps) {
const reduce = useReducedMotion();
const [internalValue, setInternalValue] = useState(defaultValue ?? items[0]?.id);
const currentValue = value ?? internalValue;
const activeIndex = Math.max(0, items.findIndex((item) => item.id === currentValue));
const activeItem = items[activeIndex] ?? items[0];
const hasIcon = items.some((item) => item.icon);
const nextItem = cycle && items.length > 0 ? items[(activeIndex + 1) % items.length] : undefined;
if (!activeItem) return null;
const accessibleLabel = activeItem.ariaLabel ?? (iconOnly && typeof activeItem.label === "string" ? activeItem.label : undefined);
return (
<motion.button
type="button"
disabled={disabled}
whileTap={reduce || disabled ? undefined : { scale: 0.97 }}
transition={SPRING_PRESS}
className={cn(
"inline-flex items-center justify-center overflow-hidden font-medium transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
aria-label={accessibleLabel}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented || disabled || !cycle || !nextItem) return;
if (value === undefined) setInternalValue(nextItem.id);
onValueChange?.(nextItem.id, nextItem);
}}
{...rest}
>
{hasIcon ? (
<ActionSwapIcon value={activeItem.id} animation={animation} className="h-4 w-4">
{activeItem.icon ?? null}
</ActionSwapIcon>
) : null}
{!iconOnly ? (
<ActionSwapText value={activeItem.id} animation={animation}>
{activeItem.label}
</ActionSwapText>
) : null}
</motion.button>
);
}
API Reference
fileReactNode—linesFileDiffLine[]—status?"complete" | "streaming"streamingopen?boolean—defaultOpen?booleantrueonOpenChange?((open: boolean) => void)—collapseOnComplete?booleantruemaxHeight?number220language?"text" | "bash" | "diff" | "json" | "tsx" | "typescript"typescriptcopyText?string—onCopy?(() => void | Promise<void>)—className?string—Composition
Place the patch inside the tool output that produced the file change.
ToolResult
└── ToolResultOutput
└── FileDiffNote: Code Block provides the shared syntax presentation for source lines. Tool Result summarizes the command or edit that produced the patch. Agent Activity records the file edit among surrounding agent events.
How it works
A file diff explains a proposed or completed edit. It should preserve the relationship between old and new lines while making the scale and status of the change visible at a glance.
Updated