Tool Chips

AI & Agents

Code edits and tool calls as compact chips.

Interactive Live Preview
components/ui/tool-chips.tsxTypeScript · React 19
1"use client";
2
3import { useEffect, useState } from "react";
4import { createPortal } from "react-dom";
5
6/* ─────────────────────────────────────────────────────────
7 * TOOL CHIPS
8 * An agent run as compact rows: tool calls with inline
9 * chips, then file-diff chips summarizing the edits.
10 * Hover a row to reveal its chevron; every row expands
11 * to show what the tool actually did.
12 * ───────────────────────────────────────────────────────── */
13
14const STEP_MS = 700;
15
16const 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};
22
23type DetailLine = { text: string; tone?: "add" };
24
25const 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];
55
56const 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];
61
62/* 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};
84
85export 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 cannot
90 * 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 ...(fitsBelow
105 ? { 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 chips
112
113 useEffect(() => {
114 if (step >= total) return;
115 const t = setTimeout(() => setStep((s) => s + 1), STEP_MS);
116 return () => clearTimeout(t);
117 }, [step, total]);
118
119 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 });
125
126 return (
127 <div className="min-h-[220px] w-full max-w-80 pb-1">
128 {/* collapsed run header */}
129 <button
130 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>
140
141 {/* 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 the
144 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 <button
152 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 <svg
159 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 <svg
165 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 <span
174 className={`inline-flex h-5.5 min-w-0 flex-1 cursor-pointer items-center truncate rounded-chip bg-field px-1.5
175 text-[11.5px] text-ink-2 shadow-hairline transition-colors duration-100 hover:bg-hover-2
176 ${row.mono ? "font-mono" : ""}`}
177 >
178 {row.chip}
179 </span>
180 </button>
181
182 {/* expanded detail */}
183 <div
184 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 <span
191 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>
204
205 {/* 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 <span
210 key={d.file}
211 data-diffchip
212 className="relative"
213 onMouseEnter={openPreview(d.file)}
214 onMouseLeave={closePreview(d.file)}
215 >
216 <button
217 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-chip
223 bg-surface px-2 font-mono text-[11.5px] text-ink shadow-btn
224 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>
231
232 </span>
233 ))}
234 <button
235 type="button"
236 className="inline-flex h-7 items-center rounded-chip px-1.5 font-mono text-[11.5px] text-ink-3
237 underline decoration-transparent underline-offset-2 transition-colors duration-100
238 hover:text-ink-2 hover:decoration-current"
239 style={{ animation: `fade-in 300ms ease-out ${DIFFS.length * 80}ms both` }}
240 >
241 +2 more
242 </button>
243 </div>
244 )}
245 </div>
246 </div>
247 {preview && typeof document !== "undefined" && createPortal(
248 <div
249 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 <div
270 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

PropTypeDefaultDescription
variantstring&quot;default&quot;Visual state and layout presentation mode.
classNamestring""Optional Tailwind CSS wrapper class overrides.

Related Components in AI & Agents