PixelDosa UI

Agent Presence

An agent's live state expressed visually rather than described — motion means the machine is busy, stillness means it's your turn.

Queued
Queued
Queued
Queued

Installation

bash
npx shadcn@latest add @pixeldosa/agent-presence

Requires `cn` at `@/lib/utils`, @pixeldosa/agent-figure for the `figure` and `mark` forms (installed automatically), and the `pd-orb-spin` / `pd-cell-pulse` keyframes supplied by @pixeldosa/pixeldosa-theme's css block. The state vocabulary maps directly onto AG-UI events, OpenAI Responses lifecycle events, AI SDK stream parts, LangGraph interrupts and MCP elicitation — see the docs page for the mapping table.

Usage

"use client";

import { AgentPresence } from "@/components/ui/agent-presence";

export function AgentBar({ run }: { run: AgentRun }) {
  return (
    <AgentPresence
      state={run.state}
      label={run.tool ? `Searching ${run.tool}` : undefined}
      form="line"
      size="sm"
      onCancel={run.abort}
    />
  );
}

Features

  • Motion means the machine is busy. Stillness means it's your turn. Machine states animate; hand-off states stop dead and sit brighter. It's learnable in about three interactions, after which nobody reads the label to know whether they're needed.
  • Rotation speed is the state — 24s per revolution when queued, 4s when working. The same object communicates urgency through tempo, which is what lets states morph into each other rather than cut between separate animations.
  • The vocabulary is taken, not invented — every state maps to a real protocol event. See the mapping below.
  • Reduced motion loses nothing. Each state holds a visually distinct static configuration, so the grammar still works with every keyframe disabled.

Variants

The machine's turn — these move

Queued
Thinking
Choosing an action
Searching invoices
Responding

Your turn — these hold perfectly still

The inversion: every other library pulses the thing that wants attention. Here the thing that wants attention is the only thing not moving.

Has a suggestion
Needs your input
Waiting for your approval

Terminal states

Done
Failed
Stopped

Figure and mark

The agent, drawn. Each state takes an Agent Figure pose, and the poses for your turn hold still. mark is the head alone, for inline use.

Searching invoices
Waiting for your approval
Thinking
Needs your input

Orb (deprecated)

form="orb" still renders, so existing screens keep working, but it will be removed in a later drop. A glowing sphere reads as a mind rather than as work. For hero surfaces, empty states and agent panels, use Agent Figure, which draws the agent and shows what it's doing.

Line

For status bars, table rows, and anywhere the agent is a footnote rather than the subject.

Thinking
Needs your input

Mapping from your agent stack

The state vocabulary is deliberately protocol-shaped, so adopting it is a mapping rather than a rewrite. The component itself takes no AI dependency — you own the stream, you map its events to a state.

PixelDosa UI stateAG-UIOpenAI ResponsesAI SDKLangGraph / MCP
queued—queued——
thinking—reasoning deltareasoning part—
decidingTOOL_CALL_START → TOOL_CALL_ARGSfunction call args deltatool-call streaming—
workingTOOL_CALL_END → awaiting resulttool call in progresstool-call awaiting result—
streamingTEXT_MESSAGE_CONTENToutput_text.deltatext part—
doneRUN_FINISHEDcompletedfinish—
failedRUN_ERRORfailederror—
asking———MCP elicitation
awaitingApproval———LangGraph interrupt()

A minimal AI SDK adapter is about this long:

function toAgentState(status: string, parts: UIMessagePart[]): AgentState {
  if (status === "submitted") return "queued";
  const last = parts.at(-1);
  if (last?.type === "reasoning") return "thinking";
  if (last?.type?.startsWith("tool-")) return "working";
  if (status === "streaming") return "streaming";
  if (status === "error") return "failed";
  return "done";
}

Examples

Naming the tool matters — working alone is a spinner with extra steps:

<AgentPresence state="working" label={`Reading ${file.name}`} onCancel={run.abort} />

Indicator only, where the surrounding UI already says what's happening. The label is still announced to screen readers:

<AgentPresence state="thinking" form="line" size="sm" hideLabel />

Props

PropTypeDefaultDescription
stateAgentState—Required. One of the eleven states above.
form"figure" | "mark" | "field" | "line" | "orb""field"figure and mark draw the agent with Agent Figure. orb is deprecated.
size"sm" | "default" | "lg""default"
labelstringper stateOverride the default. For working, name the tool.
hideLabelbooleanfalseVisually hides the label; still announced.
onCancel() => void—Shown throughout the machine's turn.

Accessibility

  • The label is the real signal, in an aria-live="polite" region. The indicator is aria-hidden and supplementary — never the only way state is communicated.
  • Hand-off states are distinguished by weight and colour token as well as stillness, so the grammar doesn't depend on perceiving motion.
  • Stop is a real keyboard-reachable <button> with its hit area extended to ~44px while staying visually compact.
  • Under prefers-reduced-motion every keyframe is disabled and each state still holds a distinct static configuration — the same information without the movement.

When not to use

The reasoning text is Reasoning Stream. Per-artifact generation progress is Generation Placeholder. The post-result action strip is AI Action Toolbar. This component says what the agent is doing — not what it produced, or what you can do about it.

why it's built this way

Engineering Notes

The load-bearing decision is that the eleven states split into two classes with opposite visual treatments rather than into eleven variations of one treatment. Every surveyed implementation renders 'thinking' and 'waiting for your approval' in the same register — something moving plus a line of text — but those are opposites: one means relax, the other means nothing proceeds until you act. Encoding that as motion-versus-stillness makes the most important question on the surface ('is it my turn?') answerable pre-attentively. The vocabulary itself is taken from protocol events rather than invented: `deciding` exists because AG-UI streams TOOL_CALL_ARGS before execution, so choosing an action is distinguishable from performing it; `queued` exists because OpenAI Responses emits it and accepted-but-not-started is not the same as working; `asking` maps to MCP elicitation and `awaitingApproval` to LangGraph's interrupt(). The orb uses a Fibonacci spiral distribution rather than the flat ring of dots most AI orbs use, which is what makes it read as a volume rather than a circle, and it is rendered with CSS 3D transforms inside a preserve-3d container so perspective supplies depth scaling for free — no per-frame JavaScript and no canvas. All three forms animate via CSS keyframes with per-element negative delays rather than a requestAnimationFrame loop; an earlier component in this system was rebuilt for exactly this reason, since re-rendering hundreds of React nodes per frame to nudge a transform is what makes these indicators stutter.

how it moves, and when it doesn't

Motion Notes

Rotation speed is the state: 24s per revolution when queued, 12s thinking, 7s deciding, 4s working — so the same object communicates urgency through tempo rather than through a different animation per state, which is what lets states morph rather than cut. Terminal machine states (`done`, `failed`, `cancelled`) drop rotation entirely and settle to a smaller resting scale. Hand-off states stop dead: no keyframe is applied at all, and the dots transition to full scale and full opacity over `--pd-duration-slow`, so the arrival of the user's turn is a settling rather than a switch. Only `transform` and `opacity` animate, so every form composites on the GPU regardless of point count. Under `prefers-reduced-motion` every keyframe is disabled and each state still holds a visually distinct static configuration — differing scale and opacity per state — so the grammar survives with zero movement; the reduced-motion state is the same information without the motion, not a removal of the signal.