Installation
npx shadcn@latest add @pixeldosa/live-status-lineRequires `cn` at `@/lib/utils`, the `pd-status-swap` keyframe from @pixeldosa/pixeldosa-theme, and @pixeldosa/agent-presence, which it composes for the indicator. Installing this pulls Agent Presence in automatically.
Usage
"use client";
import { LiveStatusLine } from "@/components/ui/live-status-line";
<LiveStatusLine
state={run.state}
status="Reading"
detail="invoices/2026-q3.csv"
startedAt={run.startedAt}
onCancel={run.abort}
/>
Features
- It replaces, it doesn't accumulate. That's the whole distinction from its
neighbours — and it's why this is cheap enough to leave in a toolbar or sidebar
footer permanently. History belongs to
Reasoning StreamandChain-of-Thought Timeline. - Composes
Agent Presencefor the indicator rather than drawing its own dots, so the turn-taking grammar holds here for free — asuggestingorawaitingApprovalstatus stops moving without this component knowing anything about it. - Layout can't jitter. A minimum row height means changing text length never reflows the container, and the detail truncates before the verb does — so "Reading" stays legible when a long path can't.
- The elapsed counter ticks itself, rather than making you re-render once a second for a display detail.
- Swaps once per real change — keyed on the status text, so a re-render with unchanged text never re-animates.
Variants
Working, with an object
Readinginvoices/2026-q3.csv
Thinking
Planning the reconciliation
Your turn — it stops
Inherited from Agent Presence: the indicator holds still because nothing proceeds
until you act.
Waiting for your approval
Complete
Reconciled 5 invoices
Examples
In permanent chrome — a sidebar footer or toolbar, where it costs one row:
<footer className="border-t px-3">
<LiveStatusLine state={run.state} status={run.status} startedAt={run.startedAt} onCancel={run.abort} />
</footer>
Per row in a table, where each row has its own agent job:
{rows.map((row) => (
<td key={row.id}>
<LiveStatusLine state={row.state} status={row.status} detail={row.target} />
</td>
))}
Omit startedAt where elapsed time isn't meaningful — a sub-second action doesn't need
a stopwatch:
<LiveStatusLine state="working" status="Saving" />
Props
| Prop | Type | Default | Description |
|---|---|---|---|
state | AgentState | — | Required. Agent Presence's vocabulary. |
status | string | — | Required. The current micro-action. |
detail | string | — | What it's acting on. Truncates before the verb. |
startedAt | number | Date | — | Drives the live elapsed counter. |
onCancel | () => void | — | Shown while the agent is active. |
indicator | "mark" | "line" | "mark" | The agent's drawn head, or three dots for the densest rows. |
All other props forward to the wrapping <div>.
Accessibility
- The status text is announced through
Agent Presence'saria-liveregion — one announcement channel, not two competing ones. - The elapsed counter uses
tabular-nums, so the line doesn't shift as seconds tick. - Stop is a real keyboard-reachable
<button>with its hit area extended to ~44px while staying visually compact. - Under
prefers-reduced-motionthe swap is dropped; the text simply changes, which loses nothing since the information was always the words.
When not to use
The reasoning text itself is Reasoning Stream. A per-artifact generation surface is
Generation Placeholder. The whole turn's history is Chain-of-Thought Timeline. And
this isn't a toast — it describes ongoing work, it doesn't announce a completed event
and disappear.
why it's built this way
Engineering Notes
The defining constraint is that this component replaces rather than accumulates. Accumulation is what makes agent activity surfaces expensive — a growing list needs scroll management, virtualisation at length, and a decision about when to collapse. Holding exactly one line keeps it cheap enough to live permanently in product chrome, and pushes the history question to Reasoning Stream and Chain-of-Thought Timeline where it belongs. It composes Agent Presence's `line` form rather than drawing its own dots, which is the system's compose-don't-reinvent rule applied where it genuinely fits: the turn-taking grammar then holds here for free, including the fact that a `suggesting` or `awaitingApproval` status stops moving. Two layout decisions prevent the jitter this pattern usually has: a minimum row height, so text of varying length cannot reflow the container around it, and truncation applied to the detail rather than the verb, so 'Reading' remains legible when a long filename cannot be. The elapsed counter is the one place this system deliberately breaks its own 'the caller owns async state' convention — every other component takes progress, busy and duration as props — because a live counter would otherwise force the consumer to re-render once a second purely for a display detail. The interval is only started while the agent is actually active, and never when no `startedAt` was supplied.
how it moves, and when it doesn't
Motion Notes
One animation, and it maps to one state change: when the status text is replaced, the new text fades up three pixels over `--pd-duration-fast` with `--pd-ease-decelerate`. Short deliberately — micro-actions can change every second or two, and anything longer would read as churn rather than as replacement. It is keyed on the status string so React's reconciliation fires it exactly once per genuine change; an unchanged status that re-renders does not re-animate, which is the same mechanism Progressive Reveal and Reasoning Stream use. The indicator's own motion is inherited from Agent Presence and is not duplicated here. Under `prefers-reduced-motion` the swap is dropped entirely: text simply changes, which loses nothing, because the information was always the words rather than the transition between them.