Chat UI Patterns
Overview
A streaming chat has two state machines the UI must not confuse: the run (idle → running → finished | errored | cancelled) and the message under construction (which the client already buffers for you). Wire the UI to the client's buffers and lifecycle callbacks, and keep the in-progress reply out of the live region until it's final. Visual direction is the frontend-design skill's job; protocol semantics are ag-ui-protocol's. This skill is the interaction contract between them.
Quick reference — concern → contract
| Concern |
Contract |
| Streamed text |
Render textMessageBuffer from onTextMessageContentEvent; commit on onTextMessageEndEvent. Same for tools: toolCallBuffer / partialToolCallArgs. |
| Sending |
agent.addMessage({ id: randomUUID(), role: "user", content }) then agent.runAgent(). runAgent has no messages param; the id you mint is the one the server sees, so mint once and keep it. |
| Tool status |
onToolCallStartEvent → running · onToolCallEndEvent → args complete, still running · onToolCallResultEvent → done. A call with no result at run end is in onRunFinishedEvent: ({ outcome, pendingToolCallIds }) — outcome is the string "success" | "interrupt" | "cancelled", and the ids are a top-level param, not nested under it. |
| Stop |
agent.abortRun(). The transport then synthesizes RUN_ERROR {code: "abort"} → onRunErrorEvent fires; branch on event.code === "abort" → stopped, anything else → error. runAgent() resolves, it does not reject. |
| Partial reply after stop |
The partial assistant message stays in agent.messages and is re-sent as history on the next run. Either keep it and mark it "stopped" in the UI, or agent.setMessages(agent.messages.filter(m => m.id !== partialId)). Decide; don't leave it implicit. |
| Errors |
Subscribe to both onRunErrorEvent (server-reported, incl. abort) and onRunFailed (transport/HTTP). One UI state, one Retry that calls runAgent() again — the user message is already in history, don't re-add it. |
| Regenerate |
Drop the last assistant message via setMessages, then runAgent(). |
| Composer keys |
Enter sends, Shift+Enter newlines, and neither fires while e.nativeEvent.isComposing (IME). Escape stops a running run. Autosize: height = 'auto' then min(scrollHeight, max). |
| While running |
Composer stays editable; Send becomes Stop; Enter is a no-op. If Send and Stop are separate elements that mount/unmount, move focus explicitly — an unmounted focused button drops focus to <body>. |
| Scroll |
Track "at bottom" with a px threshold (scrollHeight - scrollTop - clientHeight <= 48), recomputed in requestAnimationFrame on scroll. Auto-scroll on new content only when at bottom; force it on send. Otherwise show a jump-to-latest affordance. |
| Screen readers |
Committed messages live in role="log" aria-live="polite" aria-relevant="additions". The in-progress reply is a sibling after the log element, never a child of it — a node mounting inside the region is announced with whatever content it has at that instant (the first token), later text changes are not announced under "additions", and React keeps the same DOM node when the key is unchanged, so a draft placed inside is read once half-empty and never again. On end, the message mounts inside the log as a fresh node and is announced whole, once. A separate role="status" region narrates run state ("Searching…", "Stopped", "Error: …"). |
| Motion |
Gate transforms on useReducedMotion() (Ferdinand: hooks/useReducedMotion; framer-motion also exports one); keep opacity fades; CSS keyframes get @media (prefers-reduced-motion: reduce). |
| Markdown |
Model output is untrusted. Check what the project already renders markdown with before adding anything (Ferdinand: react-markdown + remark-gfm, raw HTML is escaped by default — don't add rehype-raw). Restrict link protocols; open external links in a new tab. |
| Overlays on mobile |
Reuse the project's sheet + focus trap (Ferdinand: components/ui/MobileSheet.tsx, hooks/useFocusTrap.ts; pass restoreFocusRef when the opener is captured during render). |
Worked example — subscriber → state → render
const agent = useMemo(() => new HttpAgent({ url, headers, threadId }), [url, threadId]);
const [draft, setDraft] = useState<{ id: string; text: string } | null>(null); // in-progress reply
const [run, setRun] = useState<"idle" | "running" | "stopped" | "error">("idle");
useEffect(() => agent.subscribe({
onRunStartedEvent: () => setRun("running"),
onTextMessageContentEvent: ({ event, textMessageBuffer }) =>
setDraft({ id: event.messageId, text: textMessageBuffer }),
onTextMessageEndEvent: () => setDraft(null), // agent.messages now holds it; log re-renders it
onToolCallStartEvent: ({ event }) => setTool(event.toolCallId, event.toolCallName, "running"),
onToolCallResultEvent: ({ event }) => setTool(event.toolCallId, undefined, "done"),
onRunErrorEvent: ({ event }) => setRun(event.code === "abort" ? "stopped" : "error"),
onRunFailed: () => setRun("error"),
onRunFinishedEvent: () => setRun("idle"),
onMessagesChanged: ({ messages }) => setLog([...messages]), // agent.messages is mutated in place — copy it
}).unsubscribe, [agent]);
const send = (text: string) => {
agent.addMessage({ id: randomUUID(), role: "user", content: text });
stickToBottom.current = true;
void agent.runAgent();
};
const stop = () => agent.abortRun();
// render — the streaming message is already in agent.messages (pushed on TEXT_MESSAGE_START),
// so exclude it from the live region until it's final
const committed = draft ? log.filter(m => m.id !== draft.id) : log;
<Log role="log" aria-live="polite" aria-relevant="additions" ref={logRef}>
{committed.map(m => <Bubble key={m.id} message={m} />)}
</Log>
{draft && <Bubble key={draft.id} streaming message={{ id: draft.id, role: "assistant", content: draft.text }} />}
<VisuallyHidden role="status" aria-live="polite">{statusText(run)}</VisuallyHidden>
agent.messages is the single source of truth; onMessagesChanged fires on every applied frame (per token during streaming), so memoize Bubble on id + content. The draft bubble renders outside the region while streaming; when onTextMessageEndEvent clears draft, the same message mounts inside the log as a new node and is announced once.
Common mistakes
- Concatenating deltas yourself. The client already accumulates
textMessageBuffer; a second accumulator drifts on reconnect or replay.
- Marking a tool "done" on
TOOL_CALL_END. That's the end of the arguments stream. The tool hasn't run yet.
- Treating the abort
RUN_ERROR as an error. Users see "Something went wrong" every time they press Stop.
- Streaming inside the live region. Per-token announcement with
aria-relevant="text", or silence forever with "additions". Neither is what you want.
- Putting the draft inside the log "because it's the same node that will exist after commit." That sameness is the bug: nothing new mounts at the end, so the finished reply is never read. The draft is a sibling; the commit is a mount.
- Building an
AbortController the agent never sees. HttpAgent owns its own; abortRun() is the API. Passing abortController via runAgent({ abortController }) is allowed but unnecessary.
- Rendering model markdown as plain text to avoid the question. The question is which sanitizer the project already uses, not whether to render. In a project with no renderer yet, name that as the open decision and leave one render slot — don't present
white-space: pre-wrap as the answer.
1---2name: chat-ui-patterns3description: Use when building or reviewing a streaming chat UI in React — message list, composer, stop button, tool-call status — especially over @ag-ui/client 1.0.0. Also use for symptoms like "the page jumps to the bottom while I'm reading", "screen reader reads every token" or "never reads the finished reply", "stop button doesn't stop", "Enter sends mid-IME-composition", "tool shows done while it's still running", or a partial reply getting re-sent as history.4---56# Chat UI Patterns78## Overview910A streaming chat has two state machines the UI must not confuse: the **run** (idle → running → finished | errored | cancelled) and the **message under construction** (which the client already buffers for you). Wire the UI to the client's buffers and lifecycle callbacks, and keep the in-progress reply out of the live region until it's final. Visual direction is the `frontend-design` skill's job; protocol semantics are `ag-ui-protocol`'s. This skill is the interaction contract between them.1112## Quick reference — concern → contract1314| Concern | Contract |15|---|---|16| Streamed text | Render `textMessageBuffer` from `onTextMessageContentEvent`; commit on `onTextMessageEndEvent`. Same for tools: `toolCallBuffer` / `partialToolCallArgs`. |17| Sending | `agent.addMessage({ id: randomUUID(), role: "user", content })` then `agent.runAgent()`. `runAgent` has no `messages` param; the id you mint is the one the server sees, so mint once and keep it. |18| Tool status | `onToolCallStartEvent` → running · `onToolCallEndEvent` → args complete, **still running** · `onToolCallResultEvent` → done. A call with no result at run end is in `onRunFinishedEvent: ({ outcome, pendingToolCallIds })` — `outcome` is the string `"success" \| "interrupt" \| "cancelled"`, and the ids are a top-level param, not nested under it. |19| Stop | `agent.abortRun()`. The transport then synthesizes `RUN_ERROR {code: "abort"}` → `onRunErrorEvent` fires; branch on `event.code === "abort"` → *stopped*, anything else → *error*. `runAgent()` resolves, it does not reject. |20| Partial reply after stop | The partial assistant message stays in `agent.messages` and is **re-sent as history on the next run**. Either keep it and mark it "stopped" in the UI, or `agent.setMessages(agent.messages.filter(m => m.id !== partialId))`. Decide; don't leave it implicit. |21| Errors | Subscribe to both `onRunErrorEvent` (server-reported, incl. abort) and `onRunFailed` (transport/HTTP). One UI state, one Retry that calls `runAgent()` again — the user message is already in history, don't re-add it. |22| Regenerate | Drop the last assistant message via `setMessages`, then `runAgent()`. |23| Composer keys | Enter sends, Shift+Enter newlines, and neither fires while `e.nativeEvent.isComposing` (IME). Escape stops a running run. Autosize: `height = 'auto'` then `min(scrollHeight, max)`. |24| While running | Composer stays editable; Send becomes Stop; Enter is a no-op. If Send and Stop are separate elements that mount/unmount, move focus explicitly — an unmounted focused button drops focus to `<body>`. |25| Scroll | Track "at bottom" with a px threshold (`scrollHeight - scrollTop - clientHeight <= 48`), recomputed in `requestAnimationFrame` on scroll. Auto-scroll on new content only when at bottom; force it on send. Otherwise show a jump-to-latest affordance. |26| Screen readers | Committed messages live in `role="log" aria-live="polite" aria-relevant="additions"`. The in-progress reply is a **sibling after the log element, never a child of it** — a node mounting inside the region is announced with whatever content it has at that instant (the first token), later text changes are not announced under `"additions"`, and React keeps the same DOM node when the key is unchanged, so a draft placed inside is read once half-empty and never again. On end, the message mounts inside the log as a fresh node and is announced whole, once. A separate `role="status"` region narrates run state ("Searching…", "Stopped", "Error: …"). |27| Motion | Gate transforms on `useReducedMotion()` (Ferdinand: `hooks/useReducedMotion`; framer-motion also exports one); keep opacity fades; CSS keyframes get `@media (prefers-reduced-motion: reduce)`. |28| Markdown | Model output is untrusted. Check what the project already renders markdown with before adding anything (Ferdinand: `react-markdown` + `remark-gfm`, raw HTML is escaped by default — don't add `rehype-raw`). Restrict link protocols; open external links in a new tab. |29| Overlays on mobile | Reuse the project's sheet + focus trap (Ferdinand: `components/ui/MobileSheet.tsx`, `hooks/useFocusTrap.ts`; pass `restoreFocusRef` when the opener is captured during render). |3031## Worked example — subscriber → state → render3233```tsx34const agent = useMemo(() => new HttpAgent({ url, headers, threadId }), [url, threadId]);35const [draft, setDraft] = useState<{ id: string; text: string } | null>(null); // in-progress reply36const [run, setRun] = useState<"idle" | "running" | "stopped" | "error">("idle");3738useEffect(() => agent.subscribe({39 onRunStartedEvent: () => setRun("running"),40 onTextMessageContentEvent: ({ event, textMessageBuffer }) =>41 setDraft({ id: event.messageId, text: textMessageBuffer }),42 onTextMessageEndEvent: () => setDraft(null), // agent.messages now holds it; log re-renders it43 onToolCallStartEvent: ({ event }) => setTool(event.toolCallId, event.toolCallName, "running"),44 onToolCallResultEvent: ({ event }) => setTool(event.toolCallId, undefined, "done"),45 onRunErrorEvent: ({ event }) => setRun(event.code === "abort" ? "stopped" : "error"),46 onRunFailed: () => setRun("error"),47 onRunFinishedEvent: () => setRun("idle"),48 onMessagesChanged: ({ messages }) => setLog([...messages]), // agent.messages is mutated in place — copy it49}).unsubscribe, [agent]);5051const send = (text: string) => {52 agent.addMessage({ id: randomUUID(), role: "user", content: text });53 stickToBottom.current = true;54 void agent.runAgent();55};56const stop = () => agent.abortRun();5758// render — the streaming message is already in agent.messages (pushed on TEXT_MESSAGE_START),59// so exclude it from the live region until it's final60const committed = draft ? log.filter(m => m.id !== draft.id) : log;61<Log role="log" aria-live="polite" aria-relevant="additions" ref={logRef}>62 {committed.map(m => <Bubble key={m.id} message={m} />)}63</Log>64{draft && <Bubble key={draft.id} streaming message={{ id: draft.id, role: "assistant", content: draft.text }} />}65<VisuallyHidden role="status" aria-live="polite">{statusText(run)}</VisuallyHidden>66```6768`agent.messages` is the single source of truth; `onMessagesChanged` fires on every applied frame (per token during streaming), so memoize `Bubble` on `id` + `content`. The draft bubble renders outside the region while streaming; when `onTextMessageEndEvent` clears `draft`, the same message mounts inside the log as a new node and is announced once.6970## Common mistakes7172- **Concatenating deltas yourself.** The client already accumulates `textMessageBuffer`; a second accumulator drifts on reconnect or replay.73- **Marking a tool "done" on `TOOL_CALL_END`.** That's the end of the *arguments* stream. The tool hasn't run yet.74- **Treating the abort `RUN_ERROR` as an error.** Users see "Something went wrong" every time they press Stop.75- **Streaming inside the live region.** Per-token announcement with `aria-relevant="text"`, or silence forever with `"additions"`. Neither is what you want.76- **Putting the draft inside the log "because it's the same node that will exist after commit."** That sameness is the bug: nothing new mounts at the end, so the finished reply is never read. The draft is a sibling; the commit is a mount.77- **Building an `AbortController` the agent never sees.** `HttpAgent` owns its own; `abortRun()` is the API. Passing `abortController` via `runAgent({ abortController })` is allowed but unnecessary.78- **Rendering model markdown as plain text to avoid the question.** The question is which sanitizer the project already uses, not whether to render. In a project with no renderer yet, name that as the open decision and leave one render slot — don't present `white-space: pre-wrap` as the answer.