Thinking

AI & Agents

Expandable traces — steps, reasoning, search, coding.

Interactive Live Preview
components/ui/thinking-state.tsxTypeScript · React 19
1"use client";
2
3import { useEffect, useLayoutEffect, useRef, useState } from "react";
4
5/* ─────────────────────────────────────────────────────────
6 * THINKING — expandable agent trace, four variants
7 *
8 * Steps step list with spinner → muted checks
9 * Reasoning prose reasoning that expands, then settles
10 * Search web-search trace: query + sources read
11 * Coding tool trace: files read, edits, commands
12 *
13 * The trace runs once, settles, and remains expandable.
14 * ───────────────────────────────────────────────────────── */
15
16const STAGES = [800, 600, 1800, 2600, 1600];
17
18function useSequence(steps: number[]) {
19 const [stage, setStage] = useState(0);
20 useEffect(() => {
21 if (stage >= steps.length - 1) return;
22 const t = setTimeout(() => setStage((s) => s + 1), steps[stage]);
23 return () => clearTimeout(t);
24 }, [stage, steps]);
25 return stage;
26}
27
28type Row = {
29 primary: string;
30 secondary?: string;
31 mono?: boolean;
32 add?: number;
33 del?: number;
34 href?: string;
35};
36
37const VARIANTS: Record<
38 string,
39 { active: string; done: string; rows: Row[]; query?: string }
40> = {
41 Steps: {
42 active: "Thinking",
43 done: "Thought for 4 seconds",
44 rows: [
45 { primary: "Reading flavor briefs" },
46 { primary: "Scanning supplier lists" },
47 { primary: "Comparing tasting notes", secondary: "6 flavors" },
48 { primary: "Writing the scoop report" },
49 ],
50 },
51 Reasoning: {
52 active: "Thinking",
53 done: "Thought for 4 seconds",
54 rows: [
55 { primary: "Summer demand spikes for stone-fruit flavors — peach and apricot lead." },
56 { primary: "I should check cone inventory before promoting a waffle-bowl special." },
57 ],
58 },
59 Search: {
60 active: "Searching the web",
61 done: "Searched the web",
62 query: "best waffle cone supplier",
63 rows: [
64 { primary: "Joy Cone", secondary: "joycone.com", href: "https://joycone.com/fs_products/waffle-cones/" },
65 { primary: "WebstaurantStore", secondary: "webstaurantstore.com", href: "https://www.webstaurantstore.com/ice-cream-shop-supplies.html" },
66 { primary: "The Konery", secondary: "thekonery.com", href: "https://www.thekonery.com/" },
67 ],
68 },
69 Coding: {
70 active: "Running tools",
71 done: "Ran 3 tools",
72 rows: [
73 { primary: "Read", secondary: "flavors.ts", mono: true },
74 { primary: "Edit", secondary: "ChurnSchedule.tsx", mono: true, add: 74, del: 41 },
75 { primary: "Run", secondary: "npm run freeze", mono: true },
76 ],
77 },
78};
79
80function Dot({ tone }: { tone: string }) {
81 return (
82 <span className={`flex size-3.5 shrink-0 items-center justify-center rounded-full text-white ${tone}`}>
83 <svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
84 <circle cx="12" cy="12" r="9" />
85 <path d="M3.5 12h17M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" />
86 </svg>
87 </span>
88 );
89}
90
91const TONES = ["bg-accent", "bg-orange", "bg-green"];
92
93export default function ThinkingState({ variant = "Steps", onSettled }: { variant?: string; onSettled?: () => void }) {
94 const stage = useSequence(STAGES);
95 const [manualExpanded, setManualExpanded] = useState<boolean | null>(null);
96 const [selectedTool, setSelectedTool] = useState<string | null>(null);
97 const v = VARIANTS[variant] ?? VARIANTS.Steps;
98 const autoExpanded = stage >= 1 && stage < 4;
99 const expanded = manualExpanded ?? autoExpanded;
100 const working = stage < 3;
101 const visible = stage < 2 ? 0 : stage === 2 ? Math.min(2, v.rows.length) : v.rows.length;
102 const traceRef = useRef<HTMLDivElement>(null);
103 const [lineHeight, setLineHeight] = useState(0);
104 useLayoutEffect(() => {
105 if (traceRef.current) setLineHeight(traceRef.current.offsetHeight);
106 }, [visible, expanded, variant, stage]);
107
108 /* let embedders sequence content after the trace settles */
109 const settledRef = useRef(false);
110 useEffect(() => {
111 if (working || settledRef.current) return;
112 settledRef.current = true;
113 onSettled?.();
114 }, [working, onSettled]);
115
116 return (
117 <div
118 key={variant}
119 className="flex w-full max-w-95 flex-col"
120 style={{
121 minHeight: working || expanded ? 176 : undefined,
122 transition: "min-height 400ms cubic-bezier(0.23,1,0.32,1)",
123 }}
124 >
125 {/* header — shared across variants */}
126 <button
127 type="button"
128 aria-expanded={expanded}
129 onClick={() => setManualExpanded((current) => !(current ?? autoExpanded))}
130 className="-mx-1.5 flex w-fit items-center gap-2 rounded-control px-1.5 py-1
131 transition-colors duration-100 hover:bg-hover-2"
132 >
133 <svg width="16" height="16" viewBox="0 0 24 24" fill={working ? "var(--ink-2)" : "var(--ink-3)"}>
134 <path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />
135 </svg>
136 <span role="status" className="contents">
137 {working ? (
138 <span
139 className="bg-clip-text text-[13px] font-medium whitespace-nowrap text-transparent"
140 style={{
141 backgroundImage:
142 "linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%)",
143 backgroundSize: "200% 100%",
144 animation: "shimmer-text 1.4s linear infinite",
145 }}
146 >
147 {v.active}
148 </span>
149 ) : (
150 <span
151 className="text-[13px] font-medium whitespace-nowrap text-ink-2"
152 style={{ animation: "fade-in 350ms ease-out both" }}
153 >
154 {v.done}
155 </span>
156 )}
157 </span>
158 <svg
159 width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
160 className="transition-transform duration-300"
161 style={{ transform: expanded ? "rotate(180deg)" : "rotate(0)" }}
162 >
163 <path d="M6 9l6 6 6-6" />
164 </svg>
165 </button>
166
167 {/* expandable trace */}
168 <div
169 className="grid transition-[grid-template-rows,opacity] duration-400"
170 style={{
171 gridTemplateRows: expanded ? "1fr" : "0fr",
172 opacity: expanded ? 1 : 0,
173 transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
174 }}
175 >
176 <div className="overflow-hidden">
177 <div className="relative mt-1 ml-[5px] pl-4">
178 <span
179 aria-hidden
180 className="absolute left-[3px] w-px bg-line"
181 style={{ top: -8, height: lineHeight ? lineHeight - 2 : 0, transition: "height 500ms cubic-bezier(0.23,1,0.32,1)" }}
182 />
183 <div ref={traceRef} className="flex flex-col gap-1 py-1">
184 {v.query && (
185 <div className="flex h-6 items-center gap-2 px-1.5" style={{ animation: expanded ? "fade-up 300ms cubic-bezier(0.23,1,0.32,1) both" : undefined }}>
186 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinecap="round" className="shrink-0">
187 <circle cx="11" cy="11" r="7" />
188 <path d="M21 21l-4.3-4.3" />
189 </svg>
190 <span className="text-[12.5px] text-ink-2">{v.query}</span>
191 </div>
192 )}
193 {v.rows.slice(0, visible).map((row, i) => {
194 const content = (
195 <>
196 {variant === "Search" && <Dot tone={TONES[i % 3]} />}
197 {variant === "Steps" && (
198 i < visible - 1 || !working ? (
199 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--ink-3)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
200 <path d="M20 6L9 17l-5-5" />
201 </svg>
202 ) : (
203 <span className="size-3 shrink-0 rounded-full border-[1.5px] border-line-strong border-t-ink-2" style={{ animation: "spin 700ms linear infinite" }} />
204 )
205 )}
206 <span className={`min-w-0 truncate text-[12.5px] ${variant === "Reasoning" ? "whitespace-normal leading-relaxed text-ink-2" : "font-medium text-ink"} ${variant === "Search" ? "animated-underline" : ""}`}>
207 {row.primary}
208 </span>
209 {row.secondary && (
210 <span className={`shrink-0 text-[11.5px] text-ink-3 ${row.mono ? "font-mono" : ""}`}>
211 {row.secondary}
212 </span>
213 )}
214 {row.add !== undefined && (
215 <span className="shrink-0 font-mono text-[11px] tabular-nums">
216 <span className="text-green">+{row.add}</span>{" "}
217 <span className="text-red">−{row.del}</span>
218 </span>
219 )}
220 </>
221 );
222 const rowClass = "flex min-h-7 w-full items-center gap-2 rounded-[6px] px-1.5 py-0.5 text-left";
223 const animation = { animation: `fade-up 320ms cubic-bezier(0.23,1,0.32,1) ${i * 120}ms both` };
224
225 if (variant === "Search") {
226 return (
227 <a
228 key={row.primary}
229 href={row.href}
230 target="_blank"
231 rel="noreferrer"
232 className={`${rowClass} transition-colors duration-150 hover:bg-hover`}
233 style={animation}
234 >
235 {content}
236 </a>
237 );
238 }
239
240 if (variant === "Coding") {
241 const selected = selectedTool === row.primary;
242 return (
243 <button
244 key={row.primary}
245 type="button"
246 aria-pressed={selected}
247 onClick={() => setSelectedTool(selected ? null : row.primary)}
248 className={`${rowClass} transition-colors duration-150 ${selected ? "bg-inset" : "hover:bg-hover"}`}
249 style={animation}
250 >
251 {content}
252 </button>
253 );
254 }
255
256 return (
257 <div key={row.primary} className={rowClass} style={animation}>
258 {content}
259 </div>
260 );
261 })}
262 {variant === "Search" && stage >= 3 && (
263 <span className="text-[12px] text-ink-3" style={{ animation: "fade-in 300ms ease-out both" }}>
264 +7 more
265 </span>
266 )}
267 </div>
268 </div>
269 </div>
270 </div>
271 </div>
272 );
273}
274

Props & Specification

PropTypeDefaultDescription
variantSteps | Reasoning | Search | CodingStepsVisual state and layout presentation mode.
classNamestring""Optional Tailwind CSS wrapper class overrides.

Related Components in AI & Agents