Chat
useChat, useComposer, stop, errors, thread list, routing.
Split the transcript from the composer. useChat() re-renders when the thread moves. useComposer() re-renders on every keystroke.
function Chat() {
const chat = Assistant.useChat();
const composer = Assistant.useComposer();
return (
<>
{chat.error ? <p>Something went wrong.</p> : null}
{chat.messages.map((message) => (
<article key={message.id} data-role={message.role}>
{message.parts.map((part, i) => {
if (part.type === "text") return <p key={i}>{part.text}</p>;
if (isPaiToolPart(part)) {
return (
<Assistant.Tool
key={part.toolCallId}
message={message}
part={part}
/>
);
}
return null;
})}
</article>
))}
<form
onSubmit={(event) => {
event.preventDefault();
void composer.submit();
}}
>
<textarea
value={composer.text}
onChange={(event) => composer.setText(event.target.value)}
/>
<button
type="submit"
disabled={!composer.canSubmit || chat.isRunning}
>
Send
</button>
</form>
{chat.isRunning ? (
<button type="button" onClick={() => void chat.stop()}>
Stop
</button>
) : null}
</>
);
}Render parts, not a single content string. Text, tools, and attachments are all parts of the same message.
composer.canSubmit is false while a send is in flight or an upload has not finished. chat.isRunning is true while the agent or a local command is active.
Send without the composer
Canned prompts and suggestion chips should not subscribe to keystrokes. Use useComposerActions() (writes the draft) or chat.send("…").
function Suggestion({ text }: { text: string }) {
const chat = Assistant.useChat();
return (
<button type="button" onClick={() => void chat.send(text)}>
{text}
</button>
);
}Loading and errors
chat.loading is true until the first snapshot arrives. An empty messages array plus loading means "not loaded yet," not "no messages."
Error and watch fields only — not the full ChatController. That list is on useChat().
| Field | Meaning |
|---|---|
chat.loading | Waiting for the first snapshot |
chat.watchStatus | "inactive" / "connecting" / "ready" / "reconnecting" / "failed" |
chat.watchError | Terminal live-stream failure |
chat.error | A local command or observation threw |
chat.state.error | Durable safe run failure (code, message, optional diagnostic id) |
Show chat.state.error?.message for a failed turn and offer Retry. Give support the diagnostic id.
{chat.state.error ? (
<button type="button" onClick={() => void chat.retry()}>
Retry
</button>
) : null}regenerate({ messageId }) re-runs from a chosen assistant message. Full map: Errors.
Thread list and routing
function Sidebar() {
const threads = Assistant.useThreads();
const client = Assistant.useClient();
return (
<ul>
{threads.items.map((thread) => (
<li key={thread.threadId}>
<a href={`/chat/${thread.threadId}`}>{thread.title ?? "Untitled"}</a>
</li>
))}
<button
type="button"
onClick={() => {
window.location.assign(`/chat/${client.newThreadId()}`);
}}
>
New chat
</button>
</ul>
);
}| Pattern | When |
|---|---|
Route /chat/:threadId | Saved conversations |
newThreadId() on New chat | ChatGPT-style sidebar |
| Create the id on first submit | Avoid empty threads |
Empty ids are fine. The server row appears on the first send.
Rename and delete from the same hook:
await threads.rename(thread.threadId, "Q3 report");
await threads.delete(thread.threadId);Busy sends and attachments
A second send while a run is active throws ThreadBusyError.
<input
type="file"
onChange={(event) => {
if (event.target.files) void composer.attachFiles(event.target.files);
}}
/>attachFiles uploads and stages. See Files.