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.