ActivityMark
What an agent is doing, as a character and a label.
One source for both halves
The tool name picks an animation state and a label at the same time. The state comes from the engine's stateForTool() table — the same mapping the Claude Code and Cursor adapters use — so a host that already speaks the emotion protocol and a host that only has a tool name cannot disagree about what the face should show.
Unknown tools fall back to a generic working state rather than rendering nothing, so a new tool degrades gracefully.
Adding tools
Groups
Showing every member at once turns a four-person group into a wall of characters, so the row cycles through them instead — one character wide, while still telling you who is involved.
Language
Labels come from the locale provider, so the text follows the surrounding language without a prop.
Facts instead of a tool name
activityMode="tool" is the default: the caller names a tool and both halves of the row follow. activityMode="facts" reads the shared mark commands registry instead, so a row and the roster avatar for the same agent cannot disagree about what it is doing. facts publishes { running, composing, activity } for the agent while the row is mounted — compared by value, so an inline object is fine — and surface says where the row is being drawn. A conversation row is chat, where composing is the whole point; on an avatar surface the same facts resolve to idle, because twenty faces flicking between two states every few hundred milliseconds is noise. The resolution is characterStateForRoster(facts, surface) from the engine.
Label, bubble, visibility
showLabel shows the label always, only on hover, or never — and never keeps it in the accessibility tree, because the label is the only part of the row a screen reader can use. bubble draws the label as a speech bubble rather than bare text, for a row sitting next to the character rather than under it.
visible={false} unmounts with the exit animation rather than instantly. Unmounting on the same tick as the flag flips makes the row vanish instead of leave, which reads as a glitch in a conversation that is otherwise continuous.
Poking
onPoke is called when the character is clicked, and its presence turns the mark into a real button — without it, a click is nothing, because a tab stop per row of a transcript for a flourish a screen-reader user cannot see is not worth it. A poke also goes through the shared commands, so every other mark for the agent reacts; a mirrored mark that cannot move alone hops its wrapper instead.
Presence hooks
Two hooks the component uses are exported for hosts that render their own row.
useActivityPresence(visible, { exitMs }) returns { mounted, leaving }: mounted stays true through the exit animation and leaving marks the frames in which to apply the leaving class. The default exitMs is ACTIVITY_EXIT_MS (140 ms), and drops to zero under reduced motion. The transition is applied during render rather than in an effect, so the exit is on screen for the very frame that hides the row.
useSpawnEntrance(agentId) returns { isFirst, state }: state is 'spawning' while the arrival plays and then null, so the caller falls back to its real state. Which agents have been introduced is remembered at module level, not in context — a row that unmounts when you scroll past it and remounts when you scroll back should not arrive twice. resetSpawnEntrances() forgets every introduction, for tests and session resets.
Accessibility
The label is real text, so the state of the agent is readable without seeing the animation. This is the pattern to copy whenever a character carries meaning — see accessibility.
API
| Prop | Type | Default | Description |
|---|---|---|---|
agent* | AgentLike | — | — |
activityMode | ActivityMode | 'tool' | Where the animation state comes from. Defaults to `tool`. |
bubble | boolean | false | Draw the label as a speech bubble rather than as bare text. |
className | string | — | — |
entering | boolean | false | Play the arrival animation. |
face | "cute" | "eyes" | "line" | — | Face style, overriding what the agent identity resolves to. |
facts | RosterFacts | — | Facts to publish for this agent while the row is mounted, so every other mark for it follows along. Compared by value, not identity. |
inkCss | string | — | CSS colour for the mark's ink, instead of the agent's palette colour. |
lineWeight | number | — | Stroke width multiplier for the character. |
nose | "slant" | "angular" | "hook" | "long" | "curve" | "bow" | null | — | Nose, overriding the identity. `null` draws no nose. |
onPoke | ((agent: AgentLike) => void) | — | Called when the character is clicked. Its presence also makes the mark a real button. |
pokeLabel | string | — | Accessible name for the poke control. |
showLabel | ActivityLabelMode | 'always' | Show the label always, only while the row is hovered, or never. |
sizePx | number | ACTIVITY_MARK_PX | — |
style | CSSProperties | — | Applied to the row, not to the character. |
surface | RosterSurface | 'chat' | Surface used to derive the state from facts. A conversation row is `chat`. |
text | string | — | Override the label text. |
tool | string | null | — | Tool the agent is running. Drives both the label and the animation. |
visible | boolean | true | Unmount with the exit animation rather than instantly. |
GroupActivityMark
| Prop | Type | Default | Description |
|---|---|---|---|
members* | AgentLike[] | — | — |
className | string | — | — |
cycleMs | number | GROUP_CYCLE_MS | — |
face | "cute" | "eyes" | "line" | — | Face style for whichever member is showing, overriding their identity. |
lineWeight | number | — | Stroke width multiplier for the character. |
nose | "slant" | "angular" | "hook" | "long" | "curve" | "bow" | null | — | Nose for whichever member is showing. `null` draws no nose. |
onPoke | ((agent: AgentLike) => void) | — | — |
showLabel | ActivityLabelMode | — | — |
sizePx | number | GROUP_MARK_PX | — |
style | CSSProperties | — | Merged over the ink custom properties the rotation animates, so a host can position the cluster without losing the crossfade. |
tool | string | null | — | — |
Next
- ActivityTicker — a line of rolling verbs
- Chat stream — a status line with a face