Collapsible Thinking Trace
The "thought for 5s" panel: a native details/summary outside, individually expandable steps inside — buttons rather than nested disclosures, which are announced as a disclosure within a disclosure.
Dense tool invocations that open into their arguments and result, with status carried by icon and word rather than colour, and errors marked as alerts only once the user opens them.
components/agent-tool-calls.tsxnpx hoverlab add agent-tool-calls
Or over MCP, from your editor's agent — no account needed.
Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.
Start a page with this section — add more, order them, and leave with the page source.
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
/**
* <AgentToolCalls> — tool invocations as compact chips that open into their
* arguments and result.
*
* A run makes a dozen calls and only two of them are interesting, so the
* default has to be dense: name, a one-line argument summary, a duration, a
* status. Everything else is one click away.
*
* What is worth lifting:
*
* - The status is never colour alone. Each chip carries an icon *and* an
* `sr-only` word, because "which of these failed" answered only in red is
* not answered for roughly one man in twelve.
* - Arguments render as a definition list — `<dt>` name, `<dd>` value —
* rather than a table, since the shape is genuinely key/value and a table
* of two columns forces a reader through row-and-column navigation for
* what is really a list of pairs.
* - Errors are `role="alert"` only when the panel is opened by the user.
* Marking a failed chip as an alert while the list renders would fire
* every past failure at a screen reader on page load.
* - The whole list is one `aria-busy` container while anything is running,
* which is how a reader learns the run is not finished without a live
* region reciting each call.
*/
'use client'
import * as React from 'react'
import {
AlertTriangle,
Check,
ChevronRight,
Database,
FileCode2,
Globe,
Loader2,
Terminal,
} from 'lucide-react'
export type ToolStatus = 'ok' | 'error' | 'running'
export interface ToolCall {
id: string
name: string
kind?: 'query' | 'http' | 'shell' | 'file'
/** One-line argument summary shown on the collapsed chip. */
preview: string
args?: Record<string, string>
result?: string
status?: ToolStatus
duration?: string
}
export interface AgentToolCallsProps {
heading?: string
calls?: ToolCall[]
className?: string
}
const KIND_ICON = {
query: Database,
http: Globe,
shell: Terminal,
file: FileCode2,
} as const
const STATUS_LABEL = {
ok: 'Succeeded',
error: 'Failed',
running: 'Running',
} as const
const DEFAULT_CALLS: ToolCall[] = [
{
id: '1',
name: 'warehouse.query',
kind: 'query',
preview: 'select … from subscriptions where canceled_at …',
args: {
sql: 'select seat_band, sum(arr) from subscriptions where canceled_at between $1 and $2 group by 1',
params: '["2025-07-01", "2025-09-30"]',
timeout: '30s',
},
result: '3 rows · 214 ms',
status: 'ok',
duration: '2.7s',
},
{
id: '2',
name: 'files.read',
kind: 'file',
preview: 'pricing-handbook.md',
args: { path: 'docs/pricing-handbook.md', range: 'L1–L240' },
result: '9.4 KB read',
status: 'ok',
duration: '0.3s',
},
{
id: '3',
name: 'crm.search',
kind: 'http',
preview: 'GET /accounts?status=at_risk',
args: { url: 'https://api.crm.internal/v2/accounts', query: 'status=at_risk&limit=100' },
result: 'HTTP 429 — rate limited, retrying in 4s',
status: 'error',
duration: '1.2s',
},
{
id: '4',
name: 'sandbox.exec',
kind: 'shell',
preview: 'python analyse_cohorts.py',
args: { command: 'python analyse_cohorts.py --window 90d', cwd: '/workspace' },
status: 'running',
},
]
export function AgentToolCalls({
heading = '4 tool calls · 2 messages',
calls = DEFAULT_CALLS,
className = '',
}: AgentToolCallsProps) {
const [open, setOpen] = React.useState<string[]>([])
const busy = calls.some((c) => (c.status ?? 'ok') === 'running')
const baseId = React.useId()
function toggle(id: string) {
setOpen((list) => (list.includes(id) ? list.filter((x) => x !== id) : [...list, id]))
}
return (
<div className={`mx-auto w-full max-w-2xl p-6 ${className}`}>
<div
aria-busy={busy}
className="overflow-y-hidden rounded-2xl border border-border/60 bg-card"
>
<div className="flex items-center gap-2 border-b border-border/60 px-4 py-2.5">
<Terminal aria-hidden className="h-3.5 w-3.5 text-muted-foreground" />
{heading ? (
<h3
data-stress-ignore
className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"
>
{heading}
</h3>
) : null}
</div>
<ul className="divide-y divide-border/40">
{calls.map((call) => {
const status = call.status ?? 'ok'
const Icon = KIND_ICON[call.kind ?? 'query']
const expanded = open.includes(call.id)
const panelId = `${baseId}-${call.id}`
return (
<li key={call.id}>
<button
type="button"
aria-expanded={expanded}
aria-controls={panelId}
onClick={() => toggle(call.id)}
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-start transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
<ChevronRight
aria-hidden
className={`h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform ${
// Closed points at the panel it opens — start edge, so
// it mirrors. Open points down, which RTL leaves alone.
expanded ? 'rotate-90' : 'rtl:rotate-180'
}`}
/>
<Icon aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<code className="shrink-0 font-mono text-xs font-semibold">{call.name}</code>
<span className="min-w-0 flex-1 break-words font-mono text-xs text-muted-foreground">
{call.preview}
</span>
{call.duration ? (
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
{call.duration}
</span>
) : null}
<StatusMark status={status} />
</button>
{expanded ? (
<div id={panelId} className="space-y-3 bg-muted/30 px-4 pb-3.5 pt-1">
{call.args ? (
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 pt-2">
{Object.entries(call.args).map(([key, value]) => (
<React.Fragment key={key}>
<dt className="font-mono text-[11px] text-muted-foreground">{key}</dt>
<dd className="min-w-0 break-words font-mono text-[11px]">{value}</dd>
</React.Fragment>
))}
</dl>
) : null}
{call.result ? (
<p
// Announced only because the user just opened this
// panel — never on first render.
role={status === 'error' ? 'alert' : undefined}
className={`rounded-lg border px-2.5 py-1.5 font-mono text-[11px] ${
status === 'error'
? 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-400'
: 'border-border/60 bg-background text-muted-foreground'
}`}
>
{call.result}
</p>
) : null}
{status === 'running' && !call.result ? (
<p className="font-mono text-[11px] text-muted-foreground">
Waiting for output…
</p>
) : null}
</div>
) : null}
</li>
)
})}
</ul>
</div>
</div>
)
}
/**
* Icon plus hidden word. The spinner degrades rather than stops under
* reduced motion: a frozen spinner beside "Running" reads as a hung
* process, which removes the feedback instead of the discomfort.
*/
function StatusMark({ status }: { status: ToolStatus }) {
return (
<span className="shrink-0">
<span className="sr-only">{STATUS_LABEL[status]}</span>
{status === 'ok' ? (
<Check aria-hidden className="h-3.5 w-3.5 text-emerald-500" />
) : status === 'error' ? (
<AlertTriangle aria-hidden className="h-3.5 w-3.5 text-rose-500" />
) : (
<Loader2
aria-hidden
className="h-3.5 w-3.5 animate-spin text-primary motion-reduce:[animation-duration:2.4s]"
/>
)}
</span>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/agent-tool-calls.tsx and import it where you need the section:
import { AgentToolCalls } from '@/components/agent-tool-calls'1 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.
Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.
| Prop | Type | Default |
|---|---|---|
heading | string | '4 tool calls · 2 messages' |
calls | ToolCall[] | DEFAULT_CALLS |
className | string | '' |
The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.
This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.
<!--
Tool Call Chips — markup from the Hoverlab catalog.
This is the block rendered once to HTML and wrapped as a component
file. It is not a port of the React source: the Tailwind classes carry
the design, which is the part that took the work, and they are the same
in every framework.
This block is interactive in React and the handlers are NOT here.
Buttons, toggles and menus render in their initial state and do
nothing until you wire them up.
-->
<div class="mx-auto w-full max-w-2xl p-6 ">
<div aria-busy="true" class="overflow-y-hidden rounded-2xl border border-border/60 bg-card">
<div class="flex items-center gap-2 border-b border-border/60 px-4 py-2.5">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-terminal h-3.5 w-3.5 text-muted-foreground" aria-hidden="true">
<path d="M12 19h8"></path>
<path d="m4 17 6-6-6-6"></path>
</svg>
<h3 data-stress-ignore="true" class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">4 tool calls · 2 messages</h3>
</div>
<ul class="divide-y divide-border/40">
<li>
<button type="button" aria-expanded="false" aria-controls="_R_0_-1" class="flex w-full items-center gap-2.5 px-4 py-2.5 text-start transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform rtl:rotate-180" aria-hidden="true">
<path d="m9 18 6-6-6-6"></path>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-database h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true">
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
<path d="M3 5V19A9 3 0 0 0 21 19V5"></path>
<path d="M3 12A9 3 0 0 0 21 12"></path>
</svg>
<code class="shrink-0 font-mono text-xs font-semibold">warehouse.query</code>
<span class="min-w-0 flex-1 break-words font-mono text-xs text-muted-foreground">select … from subscriptions where canceled_at …</span>
<span class="shrink-0 font-mono text-[11px] text-muted-foreground">2.7s</span>
<span class="shrink-0">
<span class="sr-only">Succeeded</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check h-3.5 w-3.5 text-emerald-500" aria-hidden="true">
<path d="M20 6 9 17l-5-5"></path>
</svg>
</span>
</button>
</li>
<li>
<button type="button" aria-expanded="false" aria-controls="_R_0_-2" class="flex w-full items-center gap-2.5 px-4 py-2.5 text-start transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform rtl:rotate-180" aria-hidden="true">
<path d="m9 18 6-6-6-6"></path>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-code2 lucide-file-code-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true">
<path d="M4 22h14a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v4"></path>
<path d="M14 2v4a2 2 0 0 0 2 2h4"></path>
<path d="m5 12-3 3 3 3"></path>
<path d="m9 18 3-3-3-3"></path>
</svg>
<code class="shrink-0 font-mono text-xs font-semibold">files.read</code>
<span class="min-w-0 flex-1 break-words font-mono text-xs text-muted-foreground">pricing-handbook.md</span>
<span class="shrink-0 font-mono text-[11px] text-muted-foreground">0.3s</span>
<span class="shrink-0">
<span class="sr-only">Succeeded</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check h-3.5 w-3.5 text-emerald-500" aria-hidden="true">
<path d="M20 6 9 17l-5-5"></path>
</svg>
</span>
</button>
</li>
<li>
<button type="button" aria-expanded="false" aria-controls="_R_0_-3" class="flex w-full items-center gap-2.5 px-4 py-2.5 text-start transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform rtl:rotate-180" aria-hidden="true">
<path d="m9 18 6-6-6-6"></path>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-globe h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"></path>
<path d="M2 12h20"></path>
</svg>
<code class="shrink-0 font-mono text-xs font-semibold">crm.search</code>
<span class="min-w-0 flex-1 break-words font-mono text-xs text-muted-foreground">GET /accounts?status=at_risk</span>
<span class="shrink-0 font-mono text-[11px] text-muted-foreground">1.2s</span>
<span class="shrink-0">
<span class="sr-only">Failed</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert h-3.5 w-3.5 text-rose-500" aria-hidden="true">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path>
<path d="M12 9v4"></path>
<path d="M12 17h.01"></path>
</svg>
</span>
</button>
</li>
<li>
<button type="button" aria-expanded="false" aria-controls="_R_0_-4" class="flex w-full items-center gap-2.5 px-4 py-2.5 text-start transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform rtl:rotate-180" aria-hidden="true">
<path d="m9 18 6-6-6-6"></path>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-terminal h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true">
<path d="M12 19h8"></path>
<path d="m4 17 6-6-6-6"></path>
</svg>
<code class="shrink-0 font-mono text-xs font-semibold">sandbox.exec</code>
<span class="min-w-0 flex-1 break-words font-mono text-xs text-muted-foreground">python analyse_cohorts.py</span>
<span class="shrink-0">
<span class="sr-only">Running</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-loader-circle h-3.5 w-3.5 animate-spin text-primary motion-reduce:[animation-duration:2.4s]" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
</svg>
</span>
</button>
</li>
</ul>
</div>
</div>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.
Want the whole screen instead of this one section? Open a page and copy it entire.
All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.
Browse Agent ReasoningCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
The "thought for 5s" panel: a native details/summary outside, individually expandable steps inside — buttons rather than nested disclosures, which are announced as a disclosure within a disclosure.
Run status as rows — done, running, failed, queued — with native progress elements, per-task retry buttons that name their task, and progress announced from one small status region instead of every row.
The gap before the first token: a shimmer grid, a phase label that advances as the wait lengthens, and a running counter — which is the only honest answer to "is it stuck". The timer is hidden from the live region on purpose.
The trace after something broke: retries counted in the open with what each one cost, a step that was silently worked around drawn in amber rather than green, and the raw error kept apart from the agent’s reading of it.
What the agent tried, what failed, and what it did about it — the run history a user needs before they trust a second attempt.
What one run cost, per step, so an expensive answer is attributable rather than a monthly surprise.