Tool Chips
AI & AgentsCode edits and tool calls as compact chips.
Interactive Live Preview
components/ui/tool-chips.tsxTypeScript · React 19
1"use client";23import { useEffect, useState } from "react";4import { createPortal } from "react-dom";56/* ─────────────────────────────────────────────────────────7 * TOOL CHIPS8 * An agent run as compact rows: tool calls with inline9 * chips, then file-diff chips summarizing the edits.10 * Hover a row to reveal its chevron; every row expands11 * to show what the tool actually did.12 * ───────────────────────────────────────────────────────── */1314const STEP_MS = 700;1516const Icons: Record<string, React.ReactNode> = {17 think: <path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />,18 write: <g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z" /></g>,19 run: <g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 17l6-5-6-5M12 19h8" /></g>,20 read: <g fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></g>,21};2223type DetailLine = { text: string; tone?: "add" };2425const ROWS: { icon: string; label: string; chip: string; mono: boolean; detailMono: boolean; detail: DetailLine[] }[] = [26 {27 icon: "think", label: "Thinking", chip: "Planning the churn schedule…", mono: false, detailMono: false,28 detail: [29 { text: "Weekend demand carries pistachio, so it churns first." },30 { text: "Batch capacity leaves two evening freezer windows." },31 ],32 },33 {34 icon: "write", label: "Write 204 lines", chip: "ChurnSchedule.tsx", mono: true, detailMono: true,35 detail: [36 { text: "+ const windows = slots.filter((s) => s.temp <= -12)", tone: "add" },37 { text: "+ return schedule(windows, { hero: \"pistachio\" })", tone: "add" },38 ],39 },40 {41 icon: "run", label: "Rebuild and verify", chip: "npm run freeze", mono: true, detailMono: true,42 detail: [43 { text: "✓ built in 1.2s" },44 { text: "✓ 34 checks passed" },45 ],46 },47 {48 icon: "read", label: "Read image", chip: "flavor-chart.png", mono: true, detailMono: false,49 detail: [50 { text: "1280 × 720 · line chart, three summers." },51 { text: "Mint chip trends up 12% through July." },52 ],53 },54];5556const DIFFS = [57 { file: "flavors.css", add: 13, del: 0 },58 { file: "ChurnSchedule.tsx", add: 74, del: 41 },59 { file: "menu.ts", add: 8, del: 2 },60];6162/* hovering a file chip opens its diff — green added, red removed */63type DiffLine = { text: string; tone: "add" | "del" | "ctx" };64const DIFF_LINES: Record<string, DiffLine[]> = {65 "flavors.css": [66 { text: ".scoop-card {", tone: "ctx" },67 { text: " gap: 14px;", tone: "del" },68 { text: " gap: 12px;", tone: "add" },69 { text: " container-type: inline-size;", tone: "add" },70 { text: "}", tone: "ctx" },71 ],72 "ChurnSchedule.tsx": [73 { text: "const slots = coldSlots(week);", tone: "ctx" },74 { text: "const windows = slots;", tone: "del" },75 { text: "const windows = slots.filter(", tone: "add" },76 { text: " (s) => s.temp <= -12,", tone: "add" },77 { text: ");", tone: "add" },78 ],79 "menu.ts": [80 { text: "export const hero = \"mint-chip\";", tone: "del" },81 { text: "export const hero = \"pistachio\";", tone: "add" },82 ],83};8485export default function ToolChips() {86 const [step, setStep] = useState(0);87 const [open, setOpen] = useState(true);88 const [openRows, setOpenRows] = useState<Set<string>>(new Set());89 /* Rendered in a body portal so animated/translated reply wrappers cannot90 * redefine the fixed-position coordinate system. */91 const [preview, setPreview] = useState<{92 file: string;93 x: number;94 top?: number;95 bottom?: number;96 } | null>(null);97 const openPreview = (file: string) => (event: React.SyntheticEvent) => {98 const rect = (event.currentTarget as Element).closest("[data-diffchip]")!.getBoundingClientRect();99 const previewHeight = 38 + (DIFF_LINES[file]?.length ?? 0) * 19;100 const fitsBelow = rect.bottom + 6 + previewHeight <= window.innerHeight - 12;101 setPreview({102 file,103 x: Math.max(12, Math.min(rect.left, window.innerWidth - 300)),104 ...(fitsBelow105 ? { top: rect.bottom + 6 }106 : { bottom: window.innerHeight - rect.top + 6 }),107 });108 };109 const closePreview = (file: string) => () =>110 setPreview((current) => (current?.file === file ? null : current));111 const total = ROWS.length + 1; // rows, then diff chips112113 useEffect(() => {114 if (step >= total) return;115 const t = setTimeout(() => setStep((s) => s + 1), STEP_MS);116 return () => clearTimeout(t);117 }, [step, total]);118119 const toggleRow = (label: string) =>120 setOpenRows((current) => {121 const next = new Set(current);122 next.has(label) ? next.delete(label) : next.add(label);123 return next;124 });125126 return (127 <div className="min-h-[220px] w-full max-w-80 pb-1">128 {/* collapsed run header */}129 <button130 type="button"131 aria-expanded={open}132 onClick={() => setOpen((current) => !current)}133 className="-mx-1.5 flex w-fit items-center gap-1.5 rounded-control px-1.5 py-1 text-[12.5px] text-ink-2 transition-colors duration-100 hover:bg-hover-2"134 >135 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" className="transition-transform duration-200" style={{ transform: open ? "rotate(0deg)" : "rotate(-90deg)" }}>136 <path d="M6 9l6 6 6-6" />137 </svg>138 <span className="tabular-nums">4 tool calls, 2 messages</span>139 </button>140141 {/* tool call rows */}142 <div className="grid transition-[grid-template-rows,opacity] duration-300" style={{ gridTemplateRows: open ? "1fr" : "0fr", opacity: open ? 1 : 0 }}>143 {/* -mx-1 + px-1.5 keeps content at the same x while giving the144 row hover pills room inside this overflow-hidden clip box */}145 <div className="-mx-1 overflow-hidden px-1.5 pb-1">146 <div className="mt-1.5 flex flex-col gap-1">147 {ROWS.slice(0, step).map((row) => {148 const rowOpen = openRows.has(row.label);149 return (150 <div key={row.label} style={{ animation: "fade-up 300ms cubic-bezier(0.23,1,0.32,1) both" }}>151 <button152 type="button"153 aria-expanded={rowOpen}154 onClick={() => toggleRow(row.label)}155 className="group/row -mx-[3px] flex h-7 w-[calc(100%+6px)] min-w-0 items-center gap-2 rounded-control px-[3px] text-left transition-colors duration-100 hover:bg-hover-2"156 >157 <span className="relative flex size-4 shrink-0 items-center justify-center text-ink-3">158 <svg159 width="13" height="13" viewBox="0 0 24 24" fill={row.icon === "think" ? "currentColor" : "none"} stroke="currentColor"160 className={`transition-opacity duration-100 group-hover/row:opacity-0 ${rowOpen ? "opacity-0" : ""}`}161 >162 {Icons[row.icon]}163 </svg>164 <svg165 width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"166 className={`absolute transition-[opacity,transform] duration-150 group-hover/row:opacity-100 ${rowOpen ? "opacity-100" : "opacity-0"}`}167 style={{ transform: rowOpen ? "rotate(0deg)" : "rotate(-90deg)" }}168 >169 <path d="M6 9l6 6 6-6" />170 </svg>171 </span>172 <span className="shrink-0 text-[12.5px] font-medium text-ink">{row.label}</span>173 <span174 className={`inline-flex h-5.5 min-w-0 flex-1 cursor-pointer items-center truncate rounded-chip bg-field px-1.5175 text-[11.5px] text-ink-2 shadow-hairline transition-colors duration-100 hover:bg-hover-2176 ${row.mono ? "font-mono" : ""}`}177 >178 {row.chip}179 </span>180 </button>181182 {/* expanded detail */}183 <div184 className="grid transition-[grid-template-rows,opacity] duration-300"185 style={{ gridTemplateRows: rowOpen ? "1fr" : "0fr", opacity: rowOpen ? 1 : 0, transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)" }}186 >187 <div className="min-h-0 overflow-hidden">188 <div className="mt-0.5 mb-1 ml-2 flex flex-col gap-0.5 border-l border-line py-0.5 pl-3.5">189 {row.detail.map((line) => (190 <span191 key={line.text}192 className={`truncate text-[11.5px] leading-[1.6] ${row.detailMono ? "font-mono" : ""} ${line.tone === "add" ? "text-green" : "text-ink-2"}`}193 >194 {line.text}195 </span>196 ))}197 </div>198 </div>199 </div>200 </div>201 );202 })}203 </div>204205 {/* file-diff chips */}206 {step >= total && (207 <div className="mt-2.5 flex max-w-full flex-wrap gap-1.5 border-t border-line pt-2.5">208 {DIFFS.map((d, i) => (209 <span210 key={d.file}211 data-diffchip212 className="relative"213 onMouseEnter={openPreview(d.file)}214 onMouseLeave={closePreview(d.file)}215 >216 <button217 type="button"218 aria-expanded={preview?.file === d.file}219 aria-label={`Show diff for ${d.file}`}220 onFocus={openPreview(d.file)}221 onBlur={closePreview(d.file)}222 className="inline-flex h-7 max-w-full items-center gap-1.5 rounded-chip223 bg-surface px-2 font-mono text-[11.5px] text-ink shadow-btn224 transition-colors duration-100 hover:bg-hover"225 style={{ animation: `pop-in 250ms cubic-bezier(0.23,1,0.32,1) ${i * 80}ms both` }}226 >227 <span className="min-w-0 truncate">{d.file}</span>228 <span className="shrink-0 text-green tabular-nums">+{d.add}</span>229 {d.del > 0 && <span className="shrink-0 text-red tabular-nums">−{d.del}</span>}230 </button>231232 </span>233 ))}234 <button235 type="button"236 className="inline-flex h-7 items-center rounded-chip px-1.5 font-mono text-[11.5px] text-ink-3237 underline decoration-transparent underline-offset-2 transition-colors duration-100238 hover:text-ink-2 hover:decoration-current"239 style={{ animation: `fade-in 300ms ease-out ${DIFFS.length * 80}ms both` }}240 >241 +2 more242 </button>243 </div>244 )}245 </div>246 </div>247 {preview && typeof document !== "undefined" && createPortal(248 <div249 className="fixed z-50 w-72 overflow-hidden rounded-[10px] bg-surface shadow-overlay"250 style={{251 left: preview.x,252 top: preview.top,253 bottom: preview.bottom,254 animation: "pop-in 160ms cubic-bezier(0.23,1,0.32,1) both",255 transformOrigin: preview.top === undefined ? "bottom left" : "top left",256 }}257 >258 <div className="flex items-center justify-between border-b border-line px-2.5 py-1.5 font-mono text-[11px]">259 <span className="min-w-0 truncate text-ink-2">{preview.file}</span>260 <span className="shrink-0 tabular-nums">261 <span className="text-green">+{DIFFS.find((diff) => diff.file === preview.file)?.add}</span>262 {(DIFFS.find((diff) => diff.file === preview.file)?.del ?? 0) > 0 && (263 <span className="text-red"> −{DIFFS.find((diff) => diff.file === preview.file)?.del}</span>264 )}265 </span>266 </div>267 <div className="py-1 font-mono text-[11px] leading-[1.8]">268 {(DIFF_LINES[preview.file] ?? []).map((line, index) => (269 <div270 key={index}271 className={`flex gap-2 px-2.5 whitespace-pre ${272 line.tone === "add"273 ? "bg-green-tint text-green"274 : line.tone === "del"275 ? "bg-red-tint text-red"276 : "text-ink-2"277 }`}278 >279 <span className="w-3 shrink-0 select-none">{line.tone === "add" ? "+" : line.tone === "del" ? "−" : " "}</span>280 <span className="min-w-0 truncate">{line.text}</span>281 </div>282 ))}283 </div>284 </div>,285 document.body,286 )}287 </div>288 );289}290
Props & Specification
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | string | "default" | Visual state and layout presentation mode. |
| className | string | "" | Optional Tailwind CSS wrapper class overrides. |