22 / Agent UI
Thinking State
A status row for work in progress, with a live elapsed timer and optional reasoning behind a disclosure.
"use client"
import { DemoVariants } from "@/components/demos/demo-variants"
import { RestartButton } from "@/components/demos/restart-button"
import {
useScriptedTimeline,
type TimelineStep,
} from "@/components/demos/use-scripted-timeline"
import { StreamingText } from "mischief-ui/streaming-text"
import {
ThinkingState,
type ThinkingStatus,
} from "mischief-ui/thinking-state"
const steps: [TimelineStep<ThinkingStatus>, ...TimelineStep<ThinkingStatus>[]] =
[
{ state: "thinking", holdMs: 4200 },
{ state: "done", holdMs: 0 },
]
const reasoning =
"The index is created before the backfill runs, so any row still holding a null email will collide. Reordering the two statements is enough."
function LiveRun() {
const { state, elapsedMs, isFinished, restart, runId } =
useScriptedTimeline(steps)
return (
<div className="grid gap-4">
<ThinkingState
key={runId}
status={state}
elapsedMs={elapsedMs}
reasoning={<StreamingText key={runId} text={reasoning} speed={70} />}
/>
{isFinished ? <RestartButton onClick={restart} /> : null}
</div>
)
}
export function ThinkingStateDemo() {
return (
<DemoVariants
label="Thinking state"
variants={[
{ id: "live", label: "Live run", render: () => <LiveRun /> },
{
id: "reasoning",
label: "Reasoning open",
render: () => (
<ThinkingState
status="done"
elapsedMs={4200}
reasoning={<p>{reasoning}</p>}
defaultOpen
/>
),
},
{
id: "error",
label: "Failed",
render: () => <ThinkingState status="error" elapsedMs={1900} />,
},
]}
/>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/thinking-stateimport { ThinkingState } from "mischief-ui/thinking-state"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 { ChevronRight, Loader, Sparkles, TriangleAlert } from "lucide-react"import { cn } from "@/lib/utils" export type ThinkingStatus = "idle" | "thinking" | "done" | "error" export type ThinkingStateProps = Omit< React.HTMLAttributes<HTMLDivElement>, "children"> & { status?: ThinkingStatus label?: React.ReactNodeUsage
export function Status({ startedAt }: { startedAt: number }) {
return (
<ThinkingState
status="thinking"
startedAt={startedAt}
reasoning={<StreamingText source={reasoningStream} />}
/>
)
}The four states
The component shows one of four things, and each is announced politely as it changes so a reader who is not watching still learns that the answer has started or finished.
| Status | Shows |
|---|---|
idle | Nothing is happening. Render it or do not, as you prefer. |
thinking | The working indicator, and a live duration if startedAt is set. |
done | doneLabel, and the final duration. |
error | errorLabel in place of the label. |
Pass startedAt and the duration counts up on its own; pass elapsedMs and that fixed figure is shown instead. The second is what you want when replaying a conversation, where a live counter would start again from zero on every render of an old message.
Showing the reasoning
reasoning goes behind a disclosure that starts closed, because the point of this component is to say that work is happening without burying the answer underneath the working. Someone curious can open it; nobody has to scroll past it.
Think about what you put in there. Intermediate reasoning is often less careful than the final answer, and once it is on screen it can be screenshotted and quoted as though it were the conclusion. A summary of the steps is usually more useful, and more defensible, than the raw trace.
API
status"idle" | "thinking" | "done" | "error"The current phase. Defaults to "thinking".label, doneLabel, errorLabelReactNodeCopy for each phase.startedAtnumberEpoch milliseconds. Drives a timer that ticks while thinking.elapsedMsnumberA fixed duration, used instead of the timer when supplied.showElapsedbooleanShows the duration. Defaults to true.reasoningReactNodeOptional detail behind a disclosure. Compose Streaming Text here for live reasoning.open, defaultOpen, onOpenChangeboolean, boolean, (open: boolean) => voidControls the reasoning disclosure.Accessibility
The root carries aria-busy while thinking and drops it once the work settles. Reasoning uses a native button with aria-expanded and aria-controls rather than a details element, so it can animate and stay predictable. The spinner and label stop animating under reduced motion.