Reactive components

Drop-in UI for durable workflows, presence/multiplayer, and AI chat — bound to backend primitives the framework already has, never polling.

UI over the framework's durable + reactive backend. Each has a headless hook (the escape hatch) + a styled component. Components from @voltro/web, hooks from @voltro/client.

Workflow-aware UI

No other framework has a UI vocabulary for durable execution. Bind to a workflow run id; the timeline + wait-states push reactively (no status-poll endpoint to wire). useWorkflowRunState('app', runId) is the headless aggregator over the EXISTING run/steps/events subscriptions → { status, steps, currentStep, waitingFor, error, traceId, cancel, resume, signal, update }.

import { WorkflowProgress, WorkflowStatusBadge, ApprovalControls } from '@voltro/web'
import { useWorkflowRunState } from '@voltro/client'

const run = useWorkflowRunState('app', runId)
<WorkflowProgress api="app" runId={runId} />       {/* live step timeline */}
<WorkflowStatusBadge api="app" runId={runId} />    {/* status + failing step + trace hint */}

// Approve/Reject a parked awaitSignal — renders ONLY while the run waits on it:
<ApprovalControls
  when={run.waitingFor?.name === 'approval'}
  onApprove={() => run.signal('approval', { approved: true })}
  onReject={() => run.signal('approval', { approved: false })}
/>

Signal/update payloads are schema-typed (declared on the workflow's messages metadata) — don't pass stringly-typed blobs.

Reactive multiplayer

@voltro/plugin-presence ships ephemeral "who's online" per channel. Combined with the reactive engine, live-collaboration primitives become drop-ins — prop-driven so the kit stays plugin-agnostic. Feed them the roster from usePresence (@voltro/plugin-presence/web):

import { PresenceAvatars, EditingIndicator } from '@voltro/web'
import { usePresence, useTyping } from '@voltro/plugin-presence/web'

const members = usePresence(`doc:${docId}`, { meta: { name, field: 'title' } })
<PresenceAvatars members={members} max={5} />
<EditingIndicator members={members} selfKey={myId} field="title" />

const typing = useTyping(`thread:${threadId}`, { selfKey: myId })  // { active, isTyping, start, stop }

Live cursors ride an ephemeral broadcast lane, NEVER the durable presence table — don't write cursor positions at 60fps to a swept table.

AI chat — <AgentChat>

A complete chat over an agent's synthesized *.send + *.messages — durable + reactive, so reconnect/reload-surviving for free. Render from the persisted parts (text / reasoning / tool / source / file) so reload === live.

import { AgentChat, AgentStream, AppAgent } from '@voltro/web'
import { useAgentChat } from '@voltro/client'

const threadId = useRef(`thread_${crypto.randomUUID().replace(/-/g, '')}`).current
<AgentChat api="app" agent="support" threadId={threadId} />

// Headless — render your own bubbles from chat.messages[i].parts:
<AgentStream api="app" agent="support" threadId={threadId}>
  {(chat) => chat.messages.map((m) => /* … */)}
</AgentStream>

<AppAgent> is the end-user "do it for me" agent: <AgentChat> + a tools disclosure (it acts AS the logged-in subject, so its ceiling is the subject's permissions). Don't hand-wire useResumableAgentStream + a bubble list for a standard chat — <AgentChat> is the supported path; reach for the resumable hook directly only for a TRANSIENT (non-persisted) run over a *.stream.ts.

useAgentChat(apiName, agent, { threadId })

The headless core <AgentChat> renders over. It opens no transport of its own — it composes the agent's SYNTHESIZED pair: a useSubscription on <agent>.messages (the persisted thread, including the live streaming: true row) plus a useAction on <agent>.send. Because the feed is a reactive query, reconnect- and reload-survival come for free.

const chat = useAgentChat('app', 'support', { threadId })

await chat.send('Where is my order?')
await chat.send('Retry', { locale: 'en' })   // extra fields merge into the send input
chat.regenerate()                            // re-run the last user prompt
Field Meaning
messages The thread, sorted by order then stepOrder. Each message has id, role, content, streaming, and the persisted parts.
streaming true while any message row is still being written.
loading true until the first snapshot arrives.
error The subscription error, or the last send failure.
send(prompt, extra?) Appends the user turn and drives the assistant turn.
regenerate() Re-sends the last user prompt as a fresh turn; undefined if there is none.
sending true while a send is in flight.

threadId is required — mint one per chat (a useRef'd uuid) and keep it stable across renders, since it is the subscription key for the whole thread. Render from message.parts (text / reasoning / tool / source / file) rather than content so a reloaded thread and a live one look identical.