85 / Agent UI
Presence Field
An ambient backdrop that carries what the assistant is doing. It changes colour and pace with the state, and settles into each one rather than snapping to it.
thinking
The room changes with the assistant, and settles rather than snaps.
"use client"
import * as React from "react"
import {
PresenceField,
type AgentPresence,
} from "mischief-ui/presence-field"
const states: AgentPresence[] = [
"idle",
"thinking",
"streaming",
"done",
"error",
]
export function PresenceFieldDemo() {
const [state, setState] = React.useState<AgentPresence>("thinking")
return (
<div className="grid w-full max-w-xl gap-4">
<PresenceField
state={state}
className="border-border rounded-[var(--radius)] border"
>
<div className="px-8 py-16 text-center">
<p className="text-sm font-semibold capitalize">{state}</p>
<p className="text-muted-foreground mt-1 text-xs">
The room changes with the assistant, and settles rather than snaps.
</p>
</div>
</PresenceField>
<div
role="group"
aria-label="Assistant state"
className="border-border bg-background/70 mx-auto inline-flex flex-wrap justify-center gap-1 rounded-full border p-1"
>
{states.map((option) => (
<button
key={option}
type="button"
aria-pressed={state === option}
className={
state === option
? "bg-foreground text-background rounded-full px-3 py-1.5 text-xs font-semibold capitalize"
: "text-muted-foreground hover:text-foreground rounded-full px-3 py-1.5 text-xs font-semibold capitalize"
}
onClick={() => setState(option)}
>
{option}
</button>
))}
</div>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/presence-fieldimport { PresenceField } from "mischief-ui/presence-field"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 { RenderSurface, createQuadProgram, useThemeColors, type QuadProgram, type SurfaceColor,} from "@/registry/default/render-surface/render-surface"import { cn } from "@/lib/utils" export type AgentPresence = "idle" | "thinking" | "streaming" | "done" | "error" Usage
export function Thread({ status }) {
return (
<PresenceField state={status} className="rounded-xl">
<Conversation>{/* messages */}</Conversation>
<ThinkingState status={status} />
</PresenceField>
)
}A second channel, never the only one
This is the one rule that matters here. A colour behind a thread is not a status: it cannot be read out, it is invisible to anyone who cannot distinguish the two colours you chose, and it says nothing at all to a reader who has motion turned off.
Put it behind a thread whose state is already written down. The thinking state component says what is happening in words; this says the same thing in the room around it. Take the words away and you have a page that changes colour for no stated reason.
<PresenceField state={status}>
<Conversation>{messages}</Conversation>
<ThinkingState status={status} />
</PresenceField>It settles rather than switches
Both the colour and the pace are eased toward whatever the current state calls for, on every frame, rather than being set when the state changes. A thread that finishes drifts down to rest over about a second instead of cutting to a new colour.
That easing is why the states are not simply four different shaders. There is one field, and the state moves it.
| State | Pace | Colour |
|---|---|---|
idle | Very slow | The quiet colour |
thinking | Steady | The active colour |
streaming | Quickest, and quicker again with activity | The active colour |
done | Almost still | The active colour, faint |
error | Unsettled | The fault colour |
Keeping the middle quiet
The field is brightest at the edges and weakest in the middle, because the middle is where the thread is. Text stays on an almost plain background while the movement happens around it.
API
state"idle" | "thinking" | "streaming" | "done" | "error"What the assistant is doing. Defaults to "idle".basestringThe colour it settles to. Defaults to "--background".activestringWhile it is working. Defaults to "--primary".faultstringWhen something went wrong. Defaults to "--destructive".quietstringWhile it is idle. Defaults to "--muted-foreground".activitynumberNought to one, for how much is arriving. Only read while streaming.Accessibility
The canvas is decoration and is hidden from assistive technology, deliberately: the state belongs to the component that states it in words. Under reduced motion one frame is drawn and the field never moves, which is exactly why it must not be the only signal. Children are ordinary markup above it.