Skip to main content

Following Tasks (Read-Only)

<Chat> and useChat send a message and stream the response — they own the turn. Sometimes you instead want to watch a task that is already running (or already finished): a background job, a task another user or surface started, or the same task rendered a second time next to your chat.

<TaskView> and useTaskStreaming do exactly that. They attach to a task, replay its history, and follow the live tail — rendering with the same renderers <Chat> uses, but with no composer and no tool interaction. Under the hood this is the A2A tasks/resubscribe stream: the server replays the task's event log, streams the live tail, then sends a terminal frame when the task is done.


Send-then-follow

You always follow a task you have an Agent for — either you just started it, or you resolved the agent with useAgent. A task id alone is not enough (A2A streams are per-agent).

import { useAgent, TaskView } from '@distri/react';

function Runner() {
const { agent } = useAgent({ agentIdOrDef: 'coder' });
const [taskId, setTaskId] = useState<string | null>(null);

const start = async () => {
const task = await agent!.invoke({ message: buildMessage('Summarize the repo') });
setTaskId((task as { id?: string }).id ?? null);
};

return (
<>
<button onClick={start}>Run in background</button>
{/* Streams the task's progress read-only — no composer. */}
<TaskView agent={agent} taskId={taskId} />
</>
);
}

You can also follow a pre-existing task — just pass its id.


Three ways to consume it

1. <TaskView> — turnkey, with renderer customization

<TaskView> exposes the same renderer surface as <Chat>: pass toolRenderers to customize how specific tools render, switch density with rendering, and so on.

<TaskView
agent={agent}
taskId={taskId}
threadId={threadId}
initialMessages={history} // seed persisted history for old/terminal tasks
rendering="rich" // 'minimal' (default) | 'rich'
toolRenderers={{
my_tool: ({ toolCall }) => <MyToolCard call={toolCall} />,
}}
showContextRow // read-only todos + context dial footer (no composer)
emptyState={<p>Waiting for activity…</p>}
/>

2. useTaskStreaming + <ChatMessageList> — your shell, our renderers

Own the layout but reuse Distri's message / tool / fork rendering. Wrap the returned store in ChatStoreContext and drop in <ChatMessageList>.

import { useTaskStreaming, ChatMessageList, ChatStoreContext } from '@distri/react';

function FollowPanel({ agent, taskId }) {
const { store, messages, isStreaming, isTerminal, reconnect } = useTaskStreaming({ agent, taskId });
return (
<ChatStoreContext.Provider value={store}>
<header>{isStreaming ? 'Running…' : isTerminal ? 'Done' : 'Idle'}
{isTerminal && <button onClick={reconnect}>Replay</button>}
</header>
<ChatMessageList messages={messages} rendering="rich" />
</ChatStoreContext.Provider>
);
}

3. useTaskStreaming alone — fully custom view

Ignore Distri's renderers entirely and render messages however you like, reading reactive slices straight off the store.

const { store, messages, isTerminal } = useTaskStreaming({ agent, taskId });
const todos = useStore(store, (s) => s.todos);

useTaskStreaming reference

const {
store, // ChatStore backing the view
messages, // [...initialMessages, ...live] display transcript
isStreaming, // task is running
isTerminal, // task reached its terminal state (or the stream closed cleanly)
error, // last stream error
reconnect, // force a fresh replay from the server log
stop, // abort the subscription
} = useTaskStreaming({
agent, // Agent that owns the task (required)
taskId, // task to follow; null disables
initialMessages, // optional pre-fetched history to seed the transcript
enabled, // default true
onError,
});

Semantics

  • Idempotent (re)connecttasks/resubscribe always replays the full log from the start, so every connect first clears and replays. Text deltas are never doubled.
  • Terminal detection — the server closes the stream on the task's own terminal event; isTerminal flips true and reconnection stops.
  • Transient errors — a dropped connection on a still-running task reconnects with a short bounded backoff.
  • Read-only — never sends, never completes tools or inline hooks. Inline-hook and external-tool events render as read-only status.

Core primitive

Framework-agnostic, in @distri/core:

for await (const event of agent.resubscribe(taskId)) {
// decoded DistriChatMessage — the read-only twin of agent.invokeStream()
}