Thinking
AI & AgentsExpandable traces — steps, reasoning, search, coding.
Interactive Live Preview
components/ui/thinking-state.tsxTypeScript · React 19
1"use client";23import { useEffect, useLayoutEffect, useRef, useState } from "react";45/* ─────────────────────────────────────────────────────────6 * THINKING — expandable agent trace, four variants7 *8 * Steps step list with spinner → muted checks9 * Reasoning prose reasoning that expands, then settles10 * Search web-search trace: query + sources read11 * Coding tool trace: files read, edits, commands12 *13 * The trace runs once, settles, and remains expandable.14 * ───────────────────────────────────────────────────────── */1516const STAGES = [800, 600, 1800, 2600, 1600];1718function 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}2728type Row = {29 primary: string;30 secondary?: string;31 mono?: boolean;32 add?: number;33 del?: number;34 href?: string;35};3637const 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};7980function 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}9091const TONES = ["bg-accent", "bg-orange", "bg-green"];9293export 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]);107108 /* 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]);115116 return (117 <div118 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 <button127 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-1131 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 <span139 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 <span151 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 <svg159 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>166167 {/* expandable trace */}168 <div169 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 <span179 aria-hidden180 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` };224225 if (variant === "Search") {226 return (227 <a228 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 }239240 if (variant === "Coding") {241 const selected = selectedTool === row.primary;242 return (243 <button244 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 }255256 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 more265 </span>266 )}267 </div>268 </div>269 </div>270 </div>271 </div>272 );273}274
Props & Specification
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | Steps | Reasoning | Search | Coding | Steps | Visual state and layout presentation mode. |
| className | string | "" | Optional Tailwind CSS wrapper class overrides. |