About
A file tree for the changes an agent makes: added, modified, deleted and renamed files get letters and line counts, changed folders carry a dot, and arrow keys move through it. Each file opens beside the tree.
Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds File Tree to your project and fits it to your theme.
Code
How File Tree is used: the demo's code. The component's own source comes with a free account.
Usage.tsx
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { type ThemedToken, type HighlighterCore, type GrammarState, type ThemeRegistration } from "shiki/core"
import { FileTree, FileTreeNode, FileTreeStatus, Icon, IconNode, createIcon } from "@/components/ui/file-tree"
/** How long a choice holds a tour before it carries on. */
const RESUME_MS = 1800
/** True until `RESUME_MS` after the last `hold()`, for tours with their own state. */
function useHold() {
const [held, setHeld] = React.useState(false)
const timer = React.useRef(0)
React.useEffect(() => () => window.clearTimeout(timer.current), [])
const hold = React.useCallback(() => {
setHeld(true)
window.clearTimeout(timer.current)
timer.current = window.setTimeout(() => setHeld(false), RESUME_MS)
}, [])
return [held, hold] as const
}
const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches
interface Change {
path: string
status: FileTreeStatus
churn: { additions: number; deletions: number }
code: string
}
/** An agent adding retries to a payments client, one file per beat. */
const CHANGES: Change[] = [
{
path: "src/lib/retry.ts",
status: "added",
churn: { additions: 48, deletions: 0 },
code: `export async function retry<T>(run: () => Promise<T>, tries = 3) {
for (let attempt = 1; ; attempt++) {
try {
return await run()
} catch (error) {
if (attempt >= tries) throw error
await sleep(2 ** attempt * 100)
}
}
}`,
},
{
path: "src/lib/stripe.ts",
status: "modified",
churn: { additions: 7, deletions: 3 },
code: `import { Stripe } from "../lib/stripe-sdk"
import { retry } from "./retry"
const client = new Stripe(process.env.STRIPE_KEY!)
export function createCharge(input: ChargeInput) {
return retry(() => client.charges.create(input), 4)
}
export function refund(id: string) {
return retry(() => client.refunds.create({ charge: id }))
}`,
},
{
path: "src/lib/legacy-retry.ts",
status: "deleted",
churn: { additions: 0, deletions: 57 },
code: `// Deleted. Its only caller now uses retry() from ./retry.
//
// export function retryCharge(input, attempts) {
// let left = attempts
// while (left > 0) {
// ...
// }
// }`,
},
{
path: "src/hooks/use-checkout.ts",
status: "renamed",
churn: { additions: 2, deletions: 2 },
code: `// Renamed from use-payment.ts
import { useMutation } from "../lib/query"
import { createCharge } from "../lib/stripe"
export function useCheckout(cart: Cart) {
const charge = useMutation(createCharge)
return {
pay: () => charge.mutate(toChargeInput(cart)),
paying: charge.isPending,
}
}`,
},
{
path: "docs/webhooks.md",
status: "modified",
churn: { additions: 12, deletions: 1 },
code: `# Webhooks
## Retries
Failed charges retry up to four times with
exponential backoff: 200 ms, 400 ms, 800 ms
and 1.6 s. The charge.failed webhook fires
only after the last attempt.
## Refunds
Refunds retry three times.`,
},
{
path: "package.json",
status: "modified",
churn: { additions: 1, deletions: 1 },
code: `{
"name": "checkout",
"version": "2.5.0",
"private": true,
"scripts": {
"dev": "next dev",
"test": "vitest run"
},
"dependencies": {
"stripe": "^17.2.0"
}
}`,
},
]
const REST_BEATS = 0
const BEAT_MS = 1300
const BASE: string[] = ["src/lib/money.ts"]
/** Builds the tree with the first `count` changes applied. */
function treeAt(count: number): FileTreeNode[] {
const applied = new Map(CHANGES.slice(0, count).map((c) => [c.path, c]))
const paths = [...BASE, ...CHANGES.map((c) => c.path)]
const root: FileTreeNode[] = []
for (const path of paths) {
const change = applied.get(path)
// A new file only shows once the agent writes it.
if (!change && CHANGES.some((c) => c.path === path && c.status === "added")) continue
const parts = path.split("/")
let level = root
parts.forEach((part, i) => {
const leaf = i === parts.length - 1
let node = level.find((n) => n.name === part)
if (!node) {
node = leaf ? { name: part } : { name: part, children: [] }
level.push(node)
}
if (leaf && change) {
node.status = change.status
node.churn = change.churn
}
if (!leaf) level = node.children!
})
}
const sort = (nodes: FileTreeNode[]): FileTreeNode[] =>
nodes
.map((n) => (n.children ? { ...n, children: sort(n.children) } : n))
.sort((a, b) => Number(!a.children) - Number(!b.children) || a.name.localeCompare(b.name))
return sort(root)
}
const FIT_CSS = "html,body{overflow:hidden}[data-fit-small] [data-fit-hide]{display:none!important}"
/**
* 21st shows a demo in a small frame: about 674 by 421 on a component page, 358 by 224 on a
* phone and 245 by 139 on a profile card. A demo taller than its frame gets scrollbars, which
* render white on dark pages. FitFrame fills the frame exactly instead.
*
* - "scale": the content shrinks until it fits, centered. For visuals such as orbs.
* - "crop": the content stays at full size from the top and fades out at the bottom. For text
* that streams in, where a changing scale would make the page breathe.
*
* `minWidth` lays out a wide demo (a table, a two column panel) at that width and scales it down
* to the frame, instead of letting it collapse to a narrow layout. The frame centers it, so it
* shrinks evenly on both sides.
*
* Anything marked `data-fit-hide` (state buttons, the chatcn.dev line) drops out in frames
* shorter than `hideBelow`, so a profile card shows the visual alone.
*/
function FitFrame({
children,
mode = "scale",
hideBelow = 360,
minWidth,
}: {
children: React.ReactNode
mode?: "scale" | "crop"
hideBelow?: number
minWidth?: string
}) {
const content = React.useRef<HTMLDivElement>(null)
const [cropped, setCropped] = React.useState(false)
React.useLayoutEffect(() => {
const el = content.current
if (!el) return
const measure = () => {
const vw = window.innerWidth
const vh = window.innerHeight
el.toggleAttribute("data-fit-small", vh < hideBelow)
// Layout sizes ignore the transform, so measuring never feeds back.
const w = Math.max(el.offsetWidth, el.scrollWidth)
const h = Math.max(el.offsetHeight, el.scrollHeight)
const scale = mode === "scale" ? Math.min(1, vw / w, vh / h) : 1
el.style.transform = scale < 1 ? `scale(${scale})` : ""
setCropped(mode === "crop" && h > vh + 1)
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(el)
// Content that changes inside a fixed width box (a headline that cycles words) never resizes
// the box itself, so changes below it measure again on the next frame.
let frame = 0
const mutations = new MutationObserver(() => {
cancelAnimationFrame(frame)
frame = requestAnimationFrame(measure)
})
mutations.observe(el, { subtree: true, childList: true, characterData: true })
window.addEventListener("resize", measure)
return () => {
observer.disconnect()
mutations.disconnect()
cancelAnimationFrame(frame)
window.removeEventListener("resize", measure)
}
}, [mode, hideBelow])
const fade = "linear-gradient(to bottom, #000 calc(100% - 40px), transparent)"
return (
<div
className="flex w-full justify-center overflow-hidden"
style={{
height: "100dvh",
alignItems: mode === "crop" ? "flex-start" : "center",
maskImage: cropped ? fade : undefined,
WebkitMaskImage: cropped ? fade : undefined,
}}
>
<style>{FIT_CSS}</style>
<div
ref={content}
className="w-full shrink-0"
style={{ transformOrigin: mode === "crop" ? "top center" : "center", minWidth }}
>
{children}
</div>
</div>
)
}
const OPEN = ["src", "src/lib", "src/hooks", "docs"]
/* -------------------------------------------------------------------------------------------------
* Types
* -----------------------------------------------------------------------------------------------*/
interface CodeBlockOptions {
/** Line numbers in a sticky gutter. */
showLineNumbers?: boolean
/** Number of the first line, for excerpts. Defaults to 1. */
startLine?: number
/** Lines to mark, by number: `[3, 4]` or `"3-4,9"`. Counted from `startLine`. */
highlightLines?: number[] | string
/** Wrap long lines. Horizontal scroll is the default. */
wrap?: boolean
defaultWrap?: boolean
onWrapChange?: (wrap: boolean) => void
/**
* Blocks longer than this collapse behind "Show N more lines". Blocks that stream in never
* collapse, so the reader never loses their place. `false` turns it off. Defaults to 24.
*/
collapseAfter?: number | false
}
interface CodeBlockProps
extends CodeBlockOptions,
Omit<React.ComponentProps<"figure">, "children"> {
code: string
/** Fence language: `tsx`, `bash`, `python`. Aliases such as `ts` and `sh` resolve. */
language?: string
/** Shown instead of the language in the header. */
filename?: string
/** True while the model is writing this block: highlights incrementally and shows the caret. */
streaming?: boolean
/** Extra header actions, placed before Copy. */
actions?: React.ReactNode
/** Hide the header row (language, wrap and copy). */
hideHeader?: boolean
}
function useControllable<T>(value: T | undefined, defaultValue: T, onChange?: (v: T) => void) {
const [inner, setInner] = React.useState(defaultValue)
const current = value ?? inner
const set = React.useCallback(
(next: T) => {
if (value === undefined) setInner(next)
onChange?.(next)
},
[value, onChange],
)
return [current, set] as const
}
/* -------------------------------------------------------------------------------------------------
* Languages
* -----------------------------------------------------------------------------------------------*/
const ALIASES: Record<string, string> = {
ts: "typescript",
mts: "typescript",
cts: "typescript",
js: "javascript",
mjs: "javascript",
cjs: "javascript",
sh: "bash",
shell: "bash",
zsh: "bash",
console: "bash",
shellsession: "bash",
py: "python",
rb: "ruby",
rs: "rust",
golang: "go",
yml: "yaml",
md: "markdown",
mdx: "mdx",
"c++": "cpp",
cs: "csharp",
"c#": "csharp",
kt: "kotlin",
ps1: "powershell",
dockerfile: "docker",
tf: "hcl",
jsonc: "jsonc",
plaintext: "text",
txt: "text",
plain: "text",
}
/** Lower-cases and resolves common aliases: `ts` to `typescript`, `sh` to `bash`. */
function resolveLanguage(lang?: string | null): string {
const id = (lang ?? "").trim().toLowerCase()
if (!id) return "text"
return ALIASES[id] ?? id
}
const LABELS: Record<string, string> = {
typescript: "TypeScript",
tsx: "TSX",
javascript: "JavaScript",
jsx: "JSX",
json: "JSON",
jsonc: "JSON",
bash: "Bash",
python: "Python",
rust: "Rust",
go: "Go",
ruby: "Ruby",
html: "HTML",
css: "CSS",
scss: "SCSS",
sql: "SQL",
yaml: "YAML",
toml: "TOML",
markdown: "Markdown",
mdx: "MDX",
diff: "Diff",
docker: "Dockerfile",
graphql: "GraphQL",
swift: "Swift",
kotlin: "Kotlin",
java: "Java",
cpp: "C++",
c: "C",
csharp: "C#",
php: "PHP",
powershell: "PowerShell",
hcl: "HCL",
prisma: "Prisma",
vue: "Vue",
svelte: "Svelte",
text: "Plain text",
}
/** A readable label for a header: `TypeScript`, `Bash`, or the id as written. */
function languageLabel(lang?: string | null): string {
const id = resolveLanguage(lang)
return LABELS[id] ?? id
}
/* -------------------------------------------------------------------------------------------------
* Frame and header
* -----------------------------------------------------------------------------------------------*/
function CodeBlockFrame({ className, ...props }: React.ComponentProps<"figure">) {
return (
<figure
data-slot="code-block"
className={cn(
// `clip`, not `hidden`: rounds the corners without becoming a scroll container, so the
// header can stay pinned while a long block scrolls past.
"group/code-block rounded-(--chatcn-radius-lg) bg-(color:--chatcn-bg-subtle) shadow-(--chatcn-shadow-border) relative isolate overflow-clip",
"text-[0.8125rem]/[1.25rem] text-(color:--chatcn-fg) forced-colors:outline-1 forced-colors:outline-solid",
className,
)}
{...props}
/>
)
}
/**
* Marks the header `data-stuck` while it rides over the code, so it gains a hairline only then.
* Listens to scrolling only while the block is on screen.
*/
function useStuck(ref: React.RefObject<HTMLDivElement | null>) {
React.useEffect(() => {
const header = ref.current
const frame = header?.parentElement?.closest("[data-slot=code-block]")
if (!header || !frame || typeof IntersectionObserver === "undefined") return
let frameId = 0
const check = () => {
frameId = 0
const offset = header.getBoundingClientRect().top - frame.getBoundingClientRect().top
header.toggleAttribute("data-stuck", offset > 1)
}
const onScroll = () => {
frameId ||= requestAnimationFrame(check)
}
const observer = new IntersectionObserver(([entry]) => {
if (entry?.isIntersecting) {
document.addEventListener("scroll", onScroll, { capture: true, passive: true })
check()
} else {
document.removeEventListener("scroll", onScroll, { capture: true })
header.removeAttribute("data-stuck")
}
})
observer.observe(frame)
return () => {
observer.disconnect()
document.removeEventListener("scroll", onScroll, { capture: true })
cancelAnimationFrame(frameId)
}
}, [ref])
}
function CodeBlockHeader({ className, ...props }: React.ComponentProps<"div">) {
const ref = React.useRef<HTMLDivElement>(null)
useStuck(ref)
return (
<div
ref={ref}
data-slot="code-block-header"
className={cn(
// Pinned to the top of the scroll area while the block is in view: Copy is always one
// click away on a long file. Set `--chatcn-code-sticky-top` under a fixed page header.
"bg-(color:--chatcn-bg-subtle) sticky top-(--chatcn-code-sticky-top,0px) z-10 flex h-9 min-w-0 items-center gap-2 ps-3.5 pe-1.5",
"data-[stuck]:shadow-[0_1px_0_var(--chatcn-border)]",
className,
)}
{...props}
/>
)
}
interface TooltipProps {
content: React.ReactNode
children: React.ReactElement
side?: "top" | "bottom" | "left" | "right"
align?: "start" | "center" | "end"
/** A shortcut hint shown after the label, for example `⌘C`. */
shortcut?: React.ReactNode
}
function Tooltip({ content, children, side = "top", align = "center", shortcut }: TooltipProps) {
return (
<TooltipPrimitive.Root>
<TooltipPrimitive.Trigger render={children} />
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner side={side} align={align} sideOffset={6} className="z-50">
<TooltipPrimitive.Popup
data-slot="tooltip"
className={cn(
"rounded-(--chatcn-radius-sm) bg-(color:--chatcn-fg) text-[0.75rem]/[1rem] text-(color:--chatcn-bg) shadow-(--chatcn-shadow-md) flex max-w-xs origin-(--transform-origin) items-center gap-2 px-2 py-1 font-medium text-pretty",
"ease-(--chatcn-ease-out) transition-[opacity,scale] duration-125",
"data-[ending-style]:scale-97 data-[ending-style]:opacity-0 data-[ending-style]:duration-100 data-[starting-style]:scale-97 data-[starting-style]:opacity-0",
// Once one tooltip is open, the next appears without animation. Reduced motion keeps
// only the fade.
"data-[instant]:transition-none motion-reduce:transition-opacity motion-reduce:data-[ending-style]:scale-100 motion-reduce:data-[starting-style]:scale-100",
)}
>
{content}
{shortcut ? <span className="font-normal opacity-60">{shortcut}</span> : null}
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
)
}
type ButtonVariant = "solid" | "outline" | "ghost" | "subtle"
type ButtonSize = "xs" | "sm" | "md" | "lg" | "icon-xs" | "icon-sm" | "icon-md" | "icon-lg"
interface ButtonProps extends React.ComponentProps<"button"> {
variant?: ButtonVariant
size?: ButtonSize
}
const base = cn(
"chatcn-focus inline-flex shrink-0 items-center justify-center gap-1.5 font-medium whitespace-nowrap select-none",
"ease-(--chatcn-ease-standard) transition-[background-color,color,box-shadow,scale] duration-150",
"active:scale-(--chatcn-press-scale) active:duration-100 disabled:pointer-events-none disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0",
)
const variants: Record<ButtonVariant, string> = {
solid: "bg-(color:--chatcn-primary) text-(color:--chatcn-primary-fg) hover:bg-(color:--chatcn-primary-hover)",
outline: "bg-(color:--chatcn-card) text-(color:--chatcn-fg) shadow-(--chatcn-shadow-xs) hover:bg-(color:--chatcn-fill-hover)",
ghost: "text-(color:--chatcn-fg-muted) hover:bg-(color:--chatcn-fill-hover) hover:text-(color:--chatcn-fg)",
subtle: "bg-(color:--chatcn-fill) text-(color:--chatcn-fg) hover:bg-(color:--chatcn-fill-hover)",
}
// Heights follow the control scale: 24, 28, 32, 36. Radii stay concentric inside 12 px surfaces.
const sizes: Record<ButtonSize, string> = {
xs: "h-6 rounded-(--chatcn-radius-sm) px-2 text-[0.75rem]/[1rem] [&_svg]:size-3.5",
sm: "h-7 rounded-(--chatcn-radius-sm) px-2.5 text-[0.8125rem]/[1.25rem] [&_svg]:size-3.5",
md: "h-8 rounded-(--chatcn-radius-md) px-3 text-[0.875rem]/[1.25rem] tracking-[-0.003em] [&_svg]:size-4",
lg: "h-9 rounded-(--chatcn-radius-md) px-3.5 text-[0.875rem]/[1.25rem] tracking-[-0.003em] [&_svg]:size-4",
"icon-xs": "size-6 rounded-(--chatcn-radius-sm) active:scale-(--chatcn-press-scale-icon) [&_svg]:size-3.5",
"icon-sm": "size-7 rounded-(--chatcn-radius-sm) active:scale-(--chatcn-press-scale-icon) [&_svg]:size-3.5",
"icon-md": "size-8 rounded-(--chatcn-radius-md) active:scale-(--chatcn-press-scale-icon) [&_svg]:size-4",
"icon-lg": "size-9 rounded-(--chatcn-radius-md) active:scale-(--chatcn-press-scale-icon) [&_svg]:size-4",
}
/** Class names for anything that should look like a button, such as a link. */
function buttonVariants({
variant = "ghost",
size = "md",
}: {
variant?: ButtonVariant
size?: ButtonSize
} = {}) {
return cn(base, variants[variant], sizes[size])
}
function Button({
variant = "ghost",
size = "md",
type = "button",
className,
...props
}: ButtonProps) {
return (
<button
type={type}
data-slot="button"
data-variant={variant}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
)
}
const iconNode: IconNode = [
["path", {"d":"M3 6h18"}],
["path", {"d":"M3 12h15a3 3 0 0 1 0 6h-1.5"}],
["path", {"d":"m15 15.5-1.62 1.62a1.25 1.25 0 0 0 0 1.76L15 20.5"}],
["path", {"d":"M3 18h5.5"}],
]
const WrapTextIcon: Icon = /* @__PURE__ */ createIcon("wrap-text", iconNode)
function WrapToggle({ wrap, onChange }: { wrap: boolean; onChange: (wrap: boolean) => void }) {
return (
<Tooltip content={wrap ? "Scroll long lines" : "Wrap long lines"}>
<Button
size="icon-sm"
aria-label="Wrap lines"
aria-pressed={wrap}
onClick={() => onChange(!wrap)}
className={cn(wrap && "bg-(color:--chatcn-fill-active) text-(color:--chatcn-fg)")}
>
<WrapTextIcon />
</Button>
</Tooltip>
)
}
interface CopyButtonProps extends Omit<ButtonProps, "value" | "onCopy"> {
value: string | (() => string)
label?: string
onCopy?: (value: string) => void
}
const iconNode2: IconNode = [
["path", {"d":"M11.5 9h7a2.5 2.5 0 0 1 2.5 2.5v7a2.5 2.5 0 0 1-2.5 2.5h-7A2.5 2.5 0 0 1 9 18.5v-7A2.5 2.5 0 0 1 11.5 9"}],
["path", {"d":"M6 15h-.5A2.5 2.5 0 0 1 3 12.5v-7A2.5 2.5 0 0 1 5.5 3h7A2.5 2.5 0 0 1 15 5.5V6"}],
]
const CopyIcon: Icon = /* @__PURE__ */ createIcon("copy", iconNode2)
const iconNode3: IconNode = [
["path", {"d":"m4.5 12.75 3.23 3.23a2.5 2.5 0 0 0 3.54 0l8.48-8.48"}],
]
const CheckIcon: Icon = /* @__PURE__ */ createIcon("check", iconNode3)
/** Copy swaps to a check with a 150 ms blur crossfade, holds for 1.5 s and announces itself. */
function CopyButton({
value,
label = "Copy",
onCopy,
onClick,
children,
size = "icon-sm",
className,
...props
}: CopyButtonProps) {
// A counter, not a flag: copying again while the check shows restarts the 1.5 s hold.
const [copies, setCopies] = React.useState(0)
const [copied, setCopied] = React.useState(false)
React.useEffect(() => {
if (!copies) return
setCopied(true)
const id = window.setTimeout(() => setCopied(false), 1500)
return () => window.clearTimeout(id)
}, [copies])
// Reduced motion keeps the crossfade and drops the scale and blur.
const layer = cn(
"ease-(--chatcn-ease-out) absolute transition-[opacity,scale,filter] duration-150",
"motion-reduce:scale-100 motion-reduce:blur-none motion-reduce:transition-opacity",
)
return (
<Button
{...props}
size={size}
aria-label={children ? undefined : copied ? "Copied" : label}
onClick={(event) => {
// Compose with handlers injected by wrappers such as Tooltip instead of replacing them.
onClick?.(event)
if (event.defaultPrevented) return
const text = typeof value === "function" ? value() : value
navigator.clipboard
?.writeText(text)
.then(() => {
setCopies((n) => n + 1)
onCopy?.(text)
})
// Clipboard access can be denied (insecure context, permissions). Stay quiet and unchanged.
.catch(() => {})
}}
className={cn("relative", className)}
>
{children ? (
<>
<span className="relative grid size-3.5 place-items-center">
<CopyIcon
className={cn(
layer,
copied ? "scale-75 opacity-0 blur-[2px]" : "scale-100 opacity-100",
)}
/>
<CheckIcon
className={cn(
layer,
copied ? "scale-100 opacity-100" : "scale-75 opacity-0 blur-[2px]",
)}
/>
</span>
{children}
</>
) : null}
{children ? null : (
<>
<CopyIcon
className={cn(
layer,
copied ? "scale-75 opacity-0 blur-[2px]" : "scale-100 opacity-100",
)}
/>
<CheckIcon
className={cn(
layer,
copied ? "scale-100 opacity-100" : "scale-75 opacity-0 blur-[2px]",
)}
/>
</>
)}
<span aria-live="polite" className="sr-only">
{copied ? "Copied" : ""}
</span>
</Button>
)
}
/* -------------------------------------------------------------------------------------------------
* Body
* -----------------------------------------------------------------------------------------------*/
interface CodeBlockBodyProps extends CodeBlockOptions {
code: string
language?: string
streaming?: boolean
wrap: boolean
label: string
}
/* -------------------------------------------------------------------------------------------------
* Roles and theme
* -----------------------------------------------------------------------------------------------*/
type ChatcnTokenRole =
| "plain"
| "keyword"
| "comment"
| "string"
| "constant"
| "function"
| "type"
| "tag"
| "attribute"
| "punctuation"
interface ChatcnToken {
content: string
role: ChatcnTokenRole
}
type ChatcnTokenLine = ChatcnToken[]
/** Return value of `useChatcnHighlight`. */
interface UseChatcnHighlightResult {
lines: ChatcnTokenLine[]
/** False while the grammar loads (lines are plain) or for unknown languages. */
highlighted: boolean
}
const PLAIN = new Set(["text", "txt", "plain", "plaintext", "ansi", "log", "output"])
let ready: HighlighterCore | undefined
const loaded = new Set<string>()
/** True once `lang` can be tokenized synchronously. */
function isLanguageReady(lang?: string | null) {
const id = resolveLanguage(lang)
return PLAIN.has(id) || (ready !== undefined && loaded.has(id))
}
const missing = new Set<string>()
const pending = new Map<string, Promise<boolean>>()
/* -------------------------------------------------------------------------------------------------
* Loading
* -----------------------------------------------------------------------------------------------*/
let core: Promise<HighlighterCore> | undefined
// Shiki needs a color per scope. The "color" is the role name, read back in `toLine`.
const role = (name: ChatcnTokenRole) => `var(--chatcn-code-${name})`
const chatcnTheme: ThemeRegistration = {
name: "chatcn",
type: "light",
colors: { "editor.foreground": role("plain"), "editor.background": "transparent" },
tokenColors: [
{
scope: ["comment", "punctuation.definition.comment", "string.quoted.docstring"],
settings: { foreground: role("comment") },
},
{
scope: [
"string",
"string.template",
"string.regexp",
"markup.inline.raw",
"markup.fenced_code",
],
settings: { foreground: role("string") },
},
{
scope: [
"constant.numeric",
"constant.language",
"constant.character",
"constant.other",
"support.constant",
"variable.other.enummember",
],
settings: { foreground: role("constant") },
},
{
scope: [
"keyword",
"storage",
"storage.type",
"storage.modifier",
"keyword.control",
"variable.language",
"markup.heading",
"markup.bold",
],
settings: { foreground: role("keyword") },
},
{
scope: [
"keyword.operator",
"keyword.operator.assignment",
"keyword.operator.arithmetic",
"storage.type.function.arrow",
],
settings: { foreground: role("punctuation") },
},
{
scope: [
"keyword.operator.new",
"keyword.operator.expression",
"keyword.operator.logical.python",
"keyword.operator.typeof",
"keyword.operator.instanceof",
],
settings: { foreground: role("keyword") },
},
{
scope: [
"entity.name.function",
"support.function",
"meta.function-call entity.name.function",
"entity.name.method",
],
settings: { foreground: role("function") },
},
{
scope: [
"entity.name.type",
"entity.name.class",
"entity.other.inherited-class",
"support.type",
"support.class",
"meta.type.annotation",
"entity.name.namespace",
],
settings: { foreground: role("type") },
},
{
scope: ["entity.name.tag", "support.class.component", "meta.tag.sgml"],
settings: { foreground: role("tag") },
},
{
scope: [
"entity.other.attribute-name",
"meta.object-literal.key",
"variable.other.property",
"variable.parameter",
],
settings: { foreground: role("attribute") },
},
{
scope: [
"punctuation",
"meta.brace",
"punctuation.definition.tag",
"punctuation.separator",
"punctuation.terminator",
"punctuation.accessor",
"meta.delimiter",
],
settings: { foreground: role("punctuation") },
},
{
scope: ["support.type.property-name", "meta.mapping.key", "string.unquoted.argument"],
settings: { foreground: role("plain") },
},
{
scope: ["punctuation.definition.string", "punctuation.definition.template-expression"],
settings: { foreground: role("string") },
},
{
scope: ["markup.deleted", "markup.inserted", "markup.changed"],
settings: { foreground: role("plain") },
},
],
}
function getCore() {
core ??= (async () => {
const [{ createHighlighterCore }, { createJavaScriptRegexEngine }] = await Promise.all([
import("shiki/core"),
import("shiki/engine/javascript"),
])
const highlighter = await createHighlighterCore({
themes: [chatcnTheme],
langs: [],
engine: createJavaScriptRegexEngine({ forgiving: true }),
})
ready = highlighter
return highlighter
})()
return core
}
/**
* Loads Shiki and one grammar. Resolves `true` when the language can be highlighted, `false`
* when it is unknown (the block then stays plain, which is the right fallback).
*/
function loadLanguage(lang?: string | null): Promise<boolean> {
const id = resolveLanguage(lang)
if (PLAIN.has(id) || missing.has(id)) return Promise.resolve(false)
if (loaded.has(id) && ready) return Promise.resolve(true)
const existing = pending.get(id)
if (existing) return existing
const task = (async () => {
try {
const [highlighter, { bundledLanguages, bundledLanguagesAlias }] = await Promise.all([
getCore(),
import("shiki/langs"),
])
const input =
(bundledLanguages as Record<string, unknown>)[id] ??
(bundledLanguagesAlias as Record<string, unknown>)[id]
if (!input) {
missing.add(id)
return false
}
await highlighter.loadLanguage(input as Parameters<HighlighterCore["loadLanguage"]>[0])
loaded.add(id)
return true
} catch {
missing.add(id)
return false
} finally {
pending.delete(id)
}
})()
pending.set(id, task)
return task
}
/**
* Incremental tokenizer for streaming code. Finished lines are tokenized once and kept (same
* array identity, so memoized line components never re-render); only the line being written is
* re-tokenized on each update, continuing from the saved grammar state.
*/
interface ChatcnLineTokenizer {
update(code: string): ChatcnTokenLine[] | null
reset(): void
}
/** Plain lines, used before the grammar loads and for unknown languages. */
function plainLines(code: string): ChatcnTokenLine[] {
return code.split("\n").map((content) => (content ? [{ content, role: "plain" as const }] : []))
}
const ROLE = /--chatcn-code-([a-z]+)/
/* -------------------------------------------------------------------------------------------------
* Tokenizing
* -----------------------------------------------------------------------------------------------*/
function toLine(tokens: ThemedToken[] | undefined): ChatcnTokenLine {
const line: ChatcnTokenLine = []
for (const token of tokens ?? []) {
const match = token.color ? ROLE.exec(token.color) : null
const tokenRole = (match?.[1] as ChatcnTokenRole | undefined) ?? "plain"
const last = line.at(-1)
// Merge neighbours with the same role: fewer spans, same look.
if (last && last.role === tokenRole) last.content += token.content
else line.push({ content: token.content, role: tokenRole })
}
return line
}
function createLineTokenizer(lang?: string | null): ChatcnLineTokenizer {
const id = resolveLanguage(lang)
let stableCode = ""
let stable: ChatcnTokenLine[] = []
let state: GrammarState | undefined
const reset = () => {
stableCode = ""
stable = []
state = undefined
}
const run = (code: string) => {
if (!ready) return { tokens: [] as ThemedToken[][], grammarState: undefined }
return ready.codeToTokens(code, {
lang: id,
theme: "chatcn",
...(state ? { grammarState: state } : {}),
})
}
return {
reset,
update(code) {
if (PLAIN.has(id) || missing.has(id)) return plainLines(code)
if (!ready || !loaded.has(id)) return null
try {
if (!code.startsWith(stableCode)) reset()
const rest = code.slice(stableCode.length)
const lastBreak = rest.lastIndexOf("\n")
if (lastBreak >= 0) {
const done = rest.slice(0, lastBreak)
const result = run(done)
stable = [...stable, ...result.tokens.map(toLine)]
state = result.grammarState
stableCode += rest.slice(0, lastBreak + 1)
}
const tail = code.slice(stableCode.length)
const tailLine = tail ? toLine(run(tail).tokens[0]) : []
return [...stable, tailLine]
} catch {
return plainLines(code)
}
},
}
}
/** Tokenizes a whole snippet. Returns `null` until the language is ready. */
function tokenize(code: string, lang?: string | null): ChatcnTokenLine[] | null {
const id = resolveLanguage(lang)
if (PLAIN.has(id) || missing.has(id)) return plainLines(code)
if (!ready || !loaded.has(id)) return null
try {
return ready.codeToTokens(code, { lang: id, theme: "chatcn" }).tokens.map(toLine)
} catch {
return plainLines(code)
}
}
/**
* Tokenizes code with the shared lazy Shiki. Renders plain lines first, then swaps to highlighted
* lines on the frame the grammar is ready. Same text, same metrics: the swap never moves layout.
* While `streaming`, only the line being written is re-tokenized on each update.
*/
function useChatcnHighlight(
code: string,
language?: string,
{ streaming = false }: { streaming?: boolean } = {},
): UseChatcnHighlightResult {
const lang = resolveLanguage(language)
const [, setVersion] = React.useState(0)
const ready = isLanguageReady(lang)
React.useEffect(() => {
if (ready) return
let alive = true
void loadLanguage(lang).then(() => {
if (alive) setVersion((v) => v + 1)
})
return () => {
alive = false
}
}, [lang, ready])
// One incremental tokenizer per language, kept across renders.
const tokenizer = React.useMemo(() => createLineTokenizer(lang), [lang])
return React.useMemo(() => {
const lines = streaming ? tokenizer.update(code) : tokenize(code, lang)
if (lines) return { lines, highlighted: ready }
return { lines: plainLines(code), highlighted: false }
// `ready` re-runs this once the grammar arrives.
}, [code, lang, streaming, tokenizer, ready])
}
/* -------------------------------------------------------------------------------------------------
* Helpers
* -----------------------------------------------------------------------------------------------*/
/** `"1,3-5"` or `[1, 3, 4, 5]` to a set of line numbers. */
function parseLineRanges(value: number[] | string | undefined): Set<number> {
const out = new Set<number>()
if (!value) return out
if (Array.isArray(value)) {
for (const n of value) out.add(n)
return out
}
for (const part of value.split(",")) {
const [a, b] = part.split("-").map((s) => Number.parseInt(s.trim(), 10))
if (a === undefined || Number.isNaN(a)) continue
const end = b === undefined || Number.isNaN(b) ? a : b
for (let n = Math.min(a, end); n <= Math.max(a, end); n++) out.add(n)
}
return out
}
/* -------------------------------------------------------------------------------------------------
* Lines
* -----------------------------------------------------------------------------------------------*/
interface LineProps {
tokens: ChatcnTokenLine
number: number
numbered: boolean
marked: boolean
caret: boolean
/** Added or removed line in a `diff` fence. The +/- glyph stays as the non-color backup. */
diff?: "add" | "del"
}
/** Each role reads one syntax variable. Keywords lead in red, comments and punctuation recede. */
const chatcnTokenClass: Record<ChatcnTokenRole, string> = {
plain: "text-(color:--chatcn-syntax-plain)",
keyword: "text-(color:--chatcn-syntax-keyword)",
comment: "text-(color:--chatcn-syntax-comment)",
string: "text-(color:--chatcn-syntax-string)",
constant: "text-(color:--chatcn-syntax-constant)",
function: "text-(color:--chatcn-syntax-function)",
type: "text-(color:--chatcn-syntax-type)",
tag: "text-(color:--chatcn-syntax-tag)",
attribute: "text-(color:--chatcn-syntax-attribute)",
punctuation: "text-(color:--chatcn-syntax-punctuation)",
}
type StreamingCaretStatus = "streaming" | "waiting" | "done"
interface StreamingCaretProps extends Omit<React.ComponentProps<"span">, "children"> {
/**
* `streaming` is solid, `waiting` blinks slowly, `done` removes the caret. Defaults to
* `streaming`.
*/
status?: StreamingCaretStatus
/**
* Pass the streamed text (or anything that changes with each token). While it keeps changing
* the caret stays solid; once it holds still for `idleMs` the caret starts to blink.
*/
activity?: unknown
/** Quiet time before the caret switches to blinking. Defaults to 600 ms. */
idleMs?: number
}
/**
* The signature mark: a small rounded square that means "the model is writing here". It sits on
* the text baseline at 0.55em, so it scales with whatever text it follows. Solid while tokens flow,
* a slow 1.2 s blink only while waiting, gone when done. Static under reduced motion.
*/
function StreamingCaret({
status = "streaming",
activity,
idleMs = 600,
className,
...props
}: StreamingCaretProps) {
const [idle, setIdle] = React.useState(false)
const tracking = activity !== undefined && status === "streaming"
React.useEffect(() => {
if (!tracking) return
setIdle(false)
const id = window.setTimeout(() => setIdle(true), idleMs)
return () => window.clearTimeout(id)
}, [activity, tracking, idleMs])
if (status === "done") return null
const blinking = status === "waiting" || (tracking && idle)
return (
<span
aria-hidden="true"
data-slot="streaming-caret"
data-caret-state={blinking ? "waiting" : "streaming"}
className={cn(
"bg-(color:--chatcn-accent) ms-0.5 inline-block size-[0.55em] translate-y-[0.04em] rounded-[0.12em] align-baseline forced-colors:bg-[CanvasText]",
blinking &&
"animate-[chatcn-caret-blink_1.2s_steps(1,end)_infinite] motion-reduce:animate-none",
className,
)}
{...props}
/>
)
}
const sameLine = (a: ChatcnTokenLine, b: ChatcnTokenLine) =>
a === b ||
(a.length === b.length && a.every((t, i) => t.content === b[i]?.content && t.role === b[i]?.role))
// Finished lines keep their props, so a streaming block only re-renders the line being written.
const Line = React.memo(
function Line({ tokens, number, numbered, marked, caret, diff }: LineProps) {
return (
<span
data-line={number}
data-highlighted={marked ? "" : undefined}
data-diff={diff}
className={cn(
// The block's surface is inherited all the way down and tints are painted over it, so
// the sticky gutter is opaque and code never shows through it when scrolled sideways.
"flex min-h-5 bg-inherit",
(diff || marked) && "bg-[linear-gradient(var(--row),var(--row))]",
diff === "add" && "[&_span]:text-(color:--chatcn-diff-add-fg) [--row:var(--chatcn-diff-add-bg)]",
diff === "del" && "[&_span]:text-(color:--chatcn-diff-del-fg) [--row:var(--chatcn-diff-del-bg)]",
marked && "shadow-[inset_2px_0_0_var(--chatcn-fg-subtle)] [--row:var(--chatcn-fill)]",
)}
>
{numbered ? (
<span
aria-hidden="true"
className={cn(
"sticky start-0 shrink-0 bg-inherit [background-image:inherit] ps-4 pe-4 text-end tabular-nums [box-shadow:inherit] select-none",
"w-[calc(var(--chatcn-code-digits)*1ch+2rem)]",
marked ? "text-(color:--chatcn-fg-muted)" : "text-(color:--chatcn-fg-subtle)",
)}
>
{number}
</span>
) : null}
<span className={cn("min-w-0 flex-1 pe-4", !numbered && "ps-4")}>
{tokens.map((token, i) => (
<span key={i} className={chatcnTokenClass[token.role]}>
{token.content}
</span>
))}
{caret ? <StreamingCaret /> : null}
</span>
</span>
)
},
(a, b) =>
a.number === b.number &&
a.numbered === b.numbered &&
a.marked === b.marked &&
a.caret === b.caret &&
a.diff === b.diff &&
sameLine(a.tokens, b.tokens),
)
/** In a `diff` fence, `+` and `-` lines (not the `+++` and `---` file headers) get a tint. */
function diffKind(tokens: ChatcnTokenLine): LineProps["diff"] {
const text = tokens.map((t) => t.content).join("")
if (text.startsWith("+") && !text.startsWith("+++")) return "add"
if (text.startsWith("-") && !text.startsWith("---")) return "del"
return undefined
}
const iconNode4: IconNode = [
["path", {"d":"m6 9.25 4.23 4.23a2.5 2.5 0 0 0 3.54 0L18 9.25"}],
]
const ChevronDownIcon: Icon = /* @__PURE__ */ createIcon("chevron-down", iconNode4)
const iconNode5: IconNode = [
["path", {"d":"m18 14.75-4.23-4.23a2.5 2.5 0 0 0-3.54 0L6 14.75"}],
]
const ChevronUpIcon: Icon = /* @__PURE__ */ createIcon("chevron-up", iconNode5)
function CodeBlockBody({
code,
language,
streaming = false,
wrap,
label,
showLineNumbers = false,
startLine = 1,
highlightLines,
collapseAfter = 24,
}: CodeBlockBodyProps) {
// A trailing newline is a fence artifact, not a line the reader should see.
const source = code.endsWith("\n") ? code.slice(0, -1) : code
const { lines } = useChatcnHighlight(source, language, { streaming })
const marked = React.useMemo(() => parseLineRanges(highlightLines), [highlightLines])
// Decide once, at mount: a block that arrived streaming stays open when it finishes.
const [streamedIn] = React.useState(streaming)
const limit = collapseAfter === false ? Number.POSITIVE_INFINITY : collapseAfter
const collapsible = !streamedIn && !streaming && lines.length > limit + 3
const [expanded, setExpanded] = React.useState(false)
const collapsed = collapsible && !expanded
const visible = collapsed ? lines.slice(0, limit) : lines
const hidden = lines.length - visible.length
const lastNumber = startLine + lines.length - 1
const isDiff = language !== undefined && /^(diff|patch)$/i.test(language)
// Long lines fade at the edge they continue past, so the reader can tell there is more.
// Attributes, not state: scrolling never re-renders the lines.
const preRef = React.useRef<HTMLPreElement>(null)
const measureEdges = React.useCallback(() => {
const el = preRef.current
if (!el) return
el.toggleAttribute("data-scroll-start", el.scrollLeft > 1)
el.toggleAttribute("data-scroll-end", el.scrollLeft + el.clientWidth < el.scrollWidth - 1)
}, [])
React.useEffect(() => {
const el = preRef.current
if (!el || typeof ResizeObserver === "undefined") return
measureEdges()
const observer = new ResizeObserver(measureEdges)
observer.observe(el)
if (el.firstElementChild) observer.observe(el.firstElementChild)
return () => observer.disconnect()
}, [measureEdges])
return (
<div data-slot="code-block-body" className="relative bg-inherit">
<section aria-label={`${label} code`} className="bg-inherit">
<pre
ref={preRef}
onScroll={measureEdges}
dir="ltr"
// biome-ignore lint/a11y/noNoninteractiveTabindex: scrollable regions must be reachable by keyboard
tabIndex={0}
data-wrap={wrap ? "" : undefined}
className={cn(
"chatcn-scroll overflow-x-auto overscroll-x-contain bg-inherit pt-0.5 pb-3.5 font-mono outline-none",
"focus-visible:shadow-[inset_0_0_0_2px_var(--chatcn-accent)]",
wrap ? "[overflow-wrap:anywhere] break-words whitespace-pre-wrap" : "whitespace-pre",
// Side fades start after the line-number gutter, so numbers never fade.
"[--edge-end:black] [--edge-start:black] [--fade-end:0px] [--fade-start:0px]",
"data-[scroll-start]:[--edge-start:transparent] data-[scroll-start]:[--fade-start:2rem]",
"data-[scroll-end]:[--edge-end:transparent] data-[scroll-end]:[--fade-end:2rem]",
showLineNumbers
? "[--gutter:calc(var(--chatcn-code-digits)*1ch+2rem)]"
: "[--gutter:0px]",
"[--sides:linear-gradient(to_right,black_var(--gutter),var(--edge-start)_var(--gutter),black_calc(var(--gutter)+var(--fade-start)),black_calc(100%-var(--fade-end)),var(--edge-end))]",
collapsed
? "[mask-image:var(--sides),linear-gradient(to_bottom,black_62%,transparent)] pb-0"
: "[mask-image:var(--sides)]",
// The focus ring is inset, so the side fades step aside while the block has focus.
"[mask-composite:intersect]",
collapsed
? "focus-visible:[mask-image:linear-gradient(to_bottom,black_62%,transparent)]"
: "focus-visible:[mask-image:none]",
)}
style={{ "--chatcn-code-digits": String(lastNumber).length } as React.CSSProperties}
>
<code className={cn("flex min-w-full flex-col bg-inherit", !wrap && "w-max")}>
{visible.map((tokens, i) => (
<Line
key={i}
tokens={tokens}
number={startLine + i}
numbered={showLineNumbers}
marked={marked.has(startLine + i)}
caret={streaming && i === lines.length - 1}
diff={isDiff ? diffKind(tokens) : undefined}
/>
))}
</code>
</pre>
</section>
{collapsible ? (
<div
className={cn(
"flex justify-center pb-2.5",
collapsed ? "absolute inset-x-0 bottom-0" : "pt-1",
)}
>
<Button
size="xs"
variant="outline"
aria-expanded={!collapsed}
onClick={() => setExpanded((v) => !v)}
className="rounded-full ps-2.5 pe-2"
>
{collapsed ? (
<>
Show {hidden} more {hidden === 1 ? "line" : "lines"}
<ChevronDownIcon />
</>
) : (
<>
Show fewer lines
<ChevronUpIcon />
</>
)}
</Button>
</div>
) : null}
</div>
)
}
/* -------------------------------------------------------------------------------------------------
* CodeBlock
* -----------------------------------------------------------------------------------------------*/
function CodeBlock({
code,
language,
filename,
streaming = false,
actions,
hideHeader = false,
wrap: wrapProp,
defaultWrap = false,
onWrapChange,
showLineNumbers,
startLine,
highlightLines,
collapseAfter,
className,
...props
}: CodeBlockProps) {
const [wrap, setWrap] = useControllable(wrapProp, defaultWrap, onWrapChange)
const label = languageLabel(language)
return (
<CodeBlockFrame
data-language={language}
data-streaming={streaming ? "" : undefined}
aria-busy={streaming || undefined}
className={className}
{...props}
>
{hideHeader ? null : (
<CodeBlockHeader>
<figcaption
className={cn(
"min-w-0 flex-1 truncate",
filename
? "text-[0.75rem]/[1rem] text-(color:--chatcn-fg-muted) font-mono"
: "text-[0.75rem]/[1rem] text-(color:--chatcn-fg-subtle)",
)}
>
{filename ?? label}
</figcaption>
<div className="flex shrink-0 items-center gap-0.5">
{actions}
<WrapToggle wrap={wrap} onChange={setWrap} />
<CopyButton value={code.replace(/\n$/, "")} label="Copy code" />
</div>
</CodeBlockHeader>
)}
<CodeBlockBody
code={code}
language={language}
streaming={streaming}
wrap={wrap}
label={filename ?? label}
showLineNumbers={showLineNumbers}
startLine={startLine}
highlightLines={highlightLines}
collapseAfter={collapseAfter}
/>
</CodeBlockFrame>
)
}
const languageOf = (path: string) =>
path.endsWith(".md") ? "md" : path.endsWith(".json") ? "json" : "ts"
/** The line under each 21st demo that points back to the kit. */
function MoreOn({ slug, children }: { slug: string; children: React.ReactNode }) {
return (
<p data-fit-hide className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-faint) text-center text-balance">
{children}{" "}
<a
href={`https://chatcn.dev/docs/components/${slug}`}
target="_blank"
rel="noopener"
className="text-(color:--chatcn-fg-subtle) hover:text-(color:--chatcn-fg) underline underline-offset-2"
>
chatcn.dev
</a>
</p>
)
}
/**
* An agent adds retries to a checkout: each file it touches lights up in the tree with its
* status and line counts, the selection follows it, and the file opens beside the tree. Loops.
* Pick a file yourself and the tour waits a moment on it.
*/
export default function FileTreePreview() {
const [beat, setBeat] = React.useState(1)
const [held, hold] = useHold()
const [mine, setMine] = React.useState<string | null>(null)
React.useEffect(() => {
if (reducedMotion()) {
setBeat(CHANGES.length)
return
}
if (held) return
const id = window.setInterval(
() => setBeat((b) => (b + 1) % (CHANGES.length + REST_BEATS)),
BEAT_MS,
)
return () => window.clearInterval(id)
}, [held])
const count = Math.min(CHANGES.length, beat + 1)
const nodes = React.useMemo(() => treeAt(count), [count])
const touched = CHANGES[count - 1]!
const selected = held && mine ? mine : touched.path
const shown = CHANGES.find((c) => c.path === selected)
return (
<FitFrame minWidth="50rem">
<div className="flex w-full flex-col items-center justify-center gap-4 p-6">
<div className="grid h-[22.5rem] w-full max-w-3xl grid-cols-[17rem_1fr] gap-3">
<div className="rounded-(--chatcn-radius-lg) bg-(color:--chatcn-card) shadow-(--chatcn-shadow-sm) flex flex-col overflow-hidden">
<div className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-subtle) flex items-center justify-between px-3 pt-2.5 pb-1">
<span>Changes</span>
<span className="tabular-nums">{count} files</span>
</div>
<FileTree
nodes={nodes}
defaultExpanded={OPEN}
selected={selected}
onSelectedChange={(path) => {
hold()
setMine(path)
}}
label="Files the agent changed"
/>
</div>
<div className="flex min-w-0 flex-col [--chatcn-code-sticky-top:-100rem]">
<CodeBlock
className="h-full"
filename={selected}
language={languageOf(selected)}
code={shown?.code ?? `// ${selected}\n// No changes in this file.`}
/>
</div>
</div>
<MoreOn slug="file-tree">Change markers, line counts and keyboard navigation. On</MoreOn>
</div>
</FitFrame>
)
}








