PAIPAI

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().

FieldMeaning
chat.loadingWaiting for the first snapshot
chat.watchStatus"inactive" / "connecting" / "ready" / "reconnecting" / "failed"
chat.watchErrorTerminal live-stream failure
chat.errorA local command or observation threw
chat.state.errorDurable 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>
  );
}
PatternWhen
Route /chat/:threadIdSaved conversations
newThreadId() on New chatChatGPT-style sidebar
Create the id on first submitAvoid 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.

Next

On this page