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.
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.
components/agent-task-list.tsxnpx hoverlab add agent-task-list
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.
2 of 5 complete · 1 failed
Complete: Pull cancellations for Q3
2,481 rows from warehouse.subscriptions
Complete: Join to account seat counts
Matched 2,463 of 2,481 — 18 accounts deleted
Failed: Fetch exit-survey responses
CRM returned 429 — rate limited after 40 of 300
Running: Score accounts by churn risk
Running the model over the joined cohort
Queued: Draft the summary
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
/**
* <AgentTaskList> — the run queue: what the agent is doing now, what it has
* finished, and what failed.
*
* The block exists because a long-running agent is otherwise invisible. A
* user who kicked off a job and sees nothing will start it again, and the
* second run is usually the one that does damage.
*
* Live-region handling, which is the hard part:
*
* - The list is `aria-live="polite"` but `aria-relevant="additions"`, so a
* task changing from Running to Done does not re-read every row. Only a
* genuinely new task is announced.
* - The *summary* line above the list is a `role="status"` and is where
* "3 of 5 complete" lives. Progress belongs in one small region that
* changes rarely, not scattered across rows that change constantly.
* - A failed row exposes its retry as a real button with the task name in
* its accessible name — five buttons all called "Retry" is a menu nobody
* can use.
* - `<progress>` is the native element, so the value is exposed without
* `role="progressbar"` and its ARIA triplet. Its bar is restyled but its
* semantics are untouched.
*/
'use client'
import * as React from 'react'
import { AlertCircle, Check, Loader2, Pause, Play, RotateCw, Square } from 'lucide-react'
export type TaskState = 'done' | 'running' | 'failed' | 'queued'
export interface AgentTask {
id: string
title: string
detail?: string
state: TaskState
/** 0–100, only meaningful while running. */
progress?: number
duration?: string
}
export interface AgentTaskListProps {
heading?: string
tasks?: AgentTask[]
className?: string
}
const DEFAULT_TASKS: AgentTask[] = [
{
id: '1',
title: 'Pull cancellations for Q3',
detail: '2,481 rows from warehouse.subscriptions',
state: 'done',
duration: '2.7s',
},
{
id: '2',
title: 'Join to account seat counts',
detail: 'Matched 2,463 of 2,481 — 18 accounts deleted',
state: 'done',
duration: '1.4s',
},
{
id: '3',
title: 'Fetch exit-survey responses',
detail: 'CRM returned 429 — rate limited after 40 of 300',
state: 'failed',
duration: '12.0s',
},
{
id: '4',
title: 'Score accounts by churn risk',
detail: 'Running the model over the joined cohort',
state: 'running',
progress: 62,
},
{
id: '5',
title: 'Draft the summary',
state: 'queued',
},
]
const STATE_LABEL = {
done: 'Complete',
running: 'Running',
failed: 'Failed',
queued: 'Queued',
} as const
export function AgentTaskList({
heading = 'Quarterly churn review',
tasks = DEFAULT_TASKS,
className = '',
}: AgentTaskListProps) {
const [paused, setPaused] = React.useState(false)
const done = tasks.filter((t) => t.state === 'done').length
const failed = tasks.filter((t) => t.state === 'failed').length
const active = tasks.some((t) => t.state === 'running')
return (
<div className={`mx-auto w-full max-w-2xl p-6 ${className}`}>
<div
aria-busy={active && !paused}
className="overflow-y-hidden rounded-2xl border border-border/60 bg-card"
>
{/* -- Header ---------------------------------------------------- */}
<div className="flex items-center gap-3 border-b border-border/60 px-4 py-3">
<div className="min-w-0 flex-1">
{heading ? <h3 data-stress-ignore className="truncate text-sm font-semibold">{heading}</h3> : null}
{/* The one place progress is announced. */}
<p role="status" className="mt-0.5 text-xs text-muted-foreground">
{done} of {tasks.length} complete
{failed > 0 ? ` · ${failed} failed` : ''}
{paused ? ' · paused' : ''}
</p>
</div>
<button
type="button"
onClick={() => setPaused((v) => !v)}
className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 px-2.5 py-1.5 text-xs font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{paused ? (
<Play aria-hidden className="h-3.5 w-3.5" />
) : (
<Pause aria-hidden className="h-3.5 w-3.5" />
)}
{paused ? 'Resume' : 'Pause'}
</button>
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 px-2.5 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Square aria-hidden className="h-3 w-3" />
Stop
</button>
</div>
{/* -- Rows ------------------------------------------------------ */}
<ul
aria-live="polite"
aria-relevant="additions"
aria-label="Tasks"
className="divide-y divide-border/40"
>
{tasks.map((task) => (
<li key={task.id} className="flex items-start gap-3 px-4 py-3">
<StateIcon state={task.state} paused={paused} />
<div className="min-w-0 flex-1">
<p
className={`text-sm ${
task.state === 'queued' ? 'text-muted-foreground' : 'font-medium'
}`}
>
<span className="sr-only">{STATE_LABEL[task.state]}: </span>
{task.title}
</p>
{task.detail ? (
<p
className={`mt-0.5 truncate text-xs ${
task.state === 'failed' ? 'text-rose-500' : 'text-muted-foreground'
}`}
>
{task.detail}
</p>
) : null}
{task.state === 'running' && typeof task.progress === 'number' ? (
<progress
value={task.progress}
max={100}
aria-label={`${task.title} progress`}
className="mt-2 h-1 w-full appearance-none overflow-hidden rounded-full [&::-moz-progress-bar]:bg-primary [&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-bar]:bg-muted [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-primary"
>
{task.progress}%
</progress>
) : null}
</div>
{task.state === 'failed' ? (
<button
type="button"
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-border/60 px-2.5 py-1 text-xs font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<RotateCw aria-hidden className="h-3 w-3" />
Retry
{/* Distinguishes this Retry from every other Retry. */}
<span className="sr-only"> {task.title}</span>
</button>
) : task.duration ? (
<span className="shrink-0 pt-0.5 font-mono text-[11px] text-muted-foreground">
{task.duration}
</span>
) : null}
</li>
))}
</ul>
</div>
</div>
)
}
function StateIcon({ state, paused }: { state: TaskState; paused: boolean }) {
const base = 'mt-0.5 h-4 w-4 shrink-0'
if (state === 'done') return <Check aria-hidden className={`${base} text-emerald-500`} />
if (state === 'failed') return <AlertCircle aria-hidden className={`${base} text-rose-500`} />
if (state === 'queued') {
return (
<span
aria-hidden
className={`${base} rounded-full border-2 border-dashed border-muted-foreground/40`}
/>
)
}
return (
<Loader2
aria-hidden
// Slowed, not stopped, under reduced motion — and genuinely stopped
// when the run is paused, where a still icon is the correct signal.
className={`${base} text-primary motion-reduce:[animation-duration:2.4s] ${
paused ? '' : 'animate-spin'
}`}
/>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/agent-task-list.tsx and import it where you need the section:
import { AgentTaskList } from '@/components/agent-task-list'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 | 'Quarterly churn review' |
tasks | AgentTask[] | DEFAULT_TASKS |
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.
<!--
Live Agent Task List — 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-3 border-b border-border/60 px-4 py-3">
<div class="min-w-0 flex-1">
<h3 data-stress-ignore="true" class="truncate text-sm font-semibold">Quarterly churn review</h3>
<p role="status" class="mt-0.5 text-xs text-muted-foreground">2 of 5 complete · 1 failed</p>
</div>
<button type="button" class="inline-flex items-center gap-1.5 rounded-lg border border-border/60 px-2.5 py-1.5 text-xs font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 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-pause h-3.5 w-3.5" aria-hidden="true"><rect x="14" y="4" width="4" height="16" rx="1"></rect><rect x="6" y="4" width="4" height="16" rx="1"></rect></svg>Pause</button>
<button type="button" class="inline-flex items-center gap-1.5 rounded-lg border border-border/60 px-2.5 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 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-square h-3 w-3" aria-hidden="true"><rect width="18" height="18" x="3" y="3" rx="2"></rect></svg>Stop</button>
</div>
<ul aria-live="polite" aria-relevant="additions" aria-label="Tasks" class="divide-y divide-border/40">
<li class="flex items-start gap-3 px-4 py-3">
<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 mt-0.5 h-4 w-4 shrink-0 text-emerald-500" aria-hidden="true">
<path d="M20 6 9 17l-5-5"></path>
</svg>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium"><span class="sr-only">Complete: </span>Pull cancellations for Q3</p>
<p class="mt-0.5 truncate text-xs text-muted-foreground">2,481 rows from warehouse.subscriptions</p>
</div>
<span class="shrink-0 pt-0.5 font-mono text-[11px] text-muted-foreground">2.7s</span>
</li>
<li class="flex items-start gap-3 px-4 py-3">
<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 mt-0.5 h-4 w-4 shrink-0 text-emerald-500" aria-hidden="true">
<path d="M20 6 9 17l-5-5"></path>
</svg>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium"><span class="sr-only">Complete: </span>Join to account seat counts</p>
<p class="mt-0.5 truncate text-xs text-muted-foreground">Matched 2,463 of 2,481 — 18 accounts deleted</p>
</div>
<span class="shrink-0 pt-0.5 font-mono text-[11px] text-muted-foreground">1.4s</span>
</li>
<li class="flex items-start gap-3 px-4 py-3">
<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-circle-alert mt-0.5 h-4 w-4 shrink-0 text-rose-500" aria-hidden="true">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" x2="12" y1="8" y2="12"></line>
<line x1="12" x2="12.01" y1="16" y2="16"></line>
</svg>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium"><span class="sr-only">Failed: </span>Fetch exit-survey responses</p>
<p class="mt-0.5 truncate text-xs text-rose-500">CRM returned 429 — rate limited after 40 of 300</p>
</div>
<button type="button" class="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-border/60 px-2.5 py-1 text-xs font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 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-rotate-cw h-3 w-3" aria-hidden="true"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg>Retry<span class="sr-only"> Fetch exit-survey responses</span></button>
</li>
<li class="flex items-start gap-3 px-4 py-3">
<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 mt-0.5 h-4 w-4 shrink-0 text-primary motion-reduce:[animation-duration:2.4s] animate-spin" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
</svg>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium"><span class="sr-only">Running: </span>Score accounts by churn risk</p>
<p class="mt-0.5 truncate text-xs text-muted-foreground">Running the model over the joined cohort</p>
<progress value="62" max="100" aria-label="Score accounts by churn risk progress" class="mt-2 h-1 w-full appearance-none overflow-hidden rounded-full [&amp;::-moz-progress-bar]:bg-primary [&amp;::-webkit-progress-bar]:rounded-full [&amp;::-webkit-progress-bar]:bg-muted [&amp;::-webkit-progress-value]:rounded-full [&amp;::-webkit-progress-value]:bg-primary">62%</progress>
</div>
</li>
<li class="flex items-start gap-3 px-4 py-3">
<span aria-hidden="true" class="mt-0.5 h-4 w-4 shrink-0 rounded-full border-2 border-dashed border-muted-foreground/40"></span>
<div class="min-w-0 flex-1">
<p class="text-sm text-muted-foreground"><span class="sr-only">Queued: </span>Draft the summary</p>
</div>
</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.
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.
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.