53 / Agent UI
Chain of Thought
The steps an assistant took before answering, open while it is working and folded away once it is done.
Read the retry handler800ms, done
src/upload/retry.ts, 140 lines.Traced where the listener is added, running
onReconnect registers it again without removing the previous one.
"use client"
import * as React from "react"
import { ChainOfThought } from "mischief-ui/chain-of-thought"
const thoughts = [
{
id: "read",
label: "Read the retry handler",
duration: 0.8,
detail: "src/upload/retry.ts, 140 lines.",
},
{
id: "trace",
label: "Traced where the listener is added",
duration: 2.1,
detail: "onReconnect registers it again without removing the previous one.",
},
{ id: "check", label: "Checked the reconnect tests", duration: 1.4 },
{ id: "write", label: "Wrote the failing case first", duration: 3.2 },
]
export function ChainOfThoughtDemo() {
const [shown, setShown] = React.useState(2)
React.useEffect(() => {
if (shown >= thoughts.length) return
const timer = setTimeout(() => setShown((count) => count + 1), 1400)
return () => clearTimeout(timer)
}, [shown])
const thinking = shown < thoughts.length
return (
<div className="w-full max-w-lg space-y-3">
<ChainOfThought
thinking={thinking}
thoughts={thoughts.slice(0, shown).map((thought, at) => ({
...thought,
status: thinking && at === shown - 1 ? "active" : "done",
duration: thinking && at === shown - 1 ? undefined : thought.duration,
}))}
/>
<button
type="button"
onClick={() => setShown(2)}
className="text-muted-foreground hover:text-foreground text-xs underline underline-offset-4"
>
Run it again
</button>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/chain-of-thoughtimport { ChainOfThought } from "mischief-ui/chain-of-thought"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { Check, ChevronRight, X } from "lucide-react" import { cn } from "@/lib/utils" export type ThoughtStatus = "pending" | "active" | "done" | "failed" export type Thought = { id: string label: string detail?: React.ReactNode status?: ThoughtStatusUsage
export function Answer({ steps, running }) {
return <ChainOfThought thoughts={steps} thinking={running} />
}The trace is never announced
Reasoning arrives a token at a time. Put it in a live region and a screen reader reads every revision of every half-finished step, over the top of the answer the person actually asked for. It is the loudest possible way to be helpful.
So the steps are ordinary text, reachable on purpose, and the only thing announced is the summary line: working on it, then thought for four seconds. Each step's status is in its accessible name rather than in the colour of a dot.
It opens and closes itself, once
Open while thinking and folded away when the answer arrives is right, because the reasoning is interesting while there is nothing else to look at and clutter afterwards.
It stops deciding the moment someone touches it. Collapsing a trace under a reader who opened it to read is the rudest thing this component could do, so the first click hands control over for good.
<ChainOfThought thoughts={steps} open={open} onOpenChange={setOpen} />What a step can be
| Status | What it means |
|---|---|
pending | Planned, not started |
active | Running now |
done | Finished, and how long it took |
failed | Tried and did not work |
A step with no status is done. Each one is also written on its element as data-status, so a thread can style around it without lifting the state.
API
thoughtsreadonly Thought[]The steps, in the order they ran.thinkingbooleanStill working. The trace opens itself while this is true.openbooleanHold the disclosure yourself.defaultOpenbooleanStart open, and never decide again.onOpenChange(open: boolean) => voidSomeone opened or closed it....rootPropsHTMLAttributes<HTMLDivElement>Native root attributes.Thought
idstringUnique within the trace.labelstringThe step, short enough to read in one breath.detailReactNodeWhat it found, under the label.status"pending" | "active" | "done" | "failed"Defaults to done.durationnumberSeconds this step took.Accessibility
The disclosure is a button carrying aria-expanded, so the trace can be opened from a keyboard and its state is read out. Only the summary is in a polite live region: streaming reasoning is deliberately kept out of one, because announcing every token would talk over the answer. A step's status reaches a screen reader as words in its accessible name rather than as the colour of its marker, and the summary stops pulsing when reduced motion is preferred.