# Chat UI Patterns

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

- Skill: `andreasbloomquist/chat-ui-patterns` (Agent Skill)
- Install (CLI): `npx skillmds@latest add andreasbloomquist/chat-ui-patterns`
- Raw SKILL.md: https://api.skillmd.com/api/skills/andreasbloomquist/chat-ui-patterns/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: andreasbloomquist (https://skillmd.com/u/andreasbloomquist)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/andreasbloomquist/chat-ui-patterns

---


# 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

```tsx
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.

