Advanced: chat
Chat for a thread between people and AIChat for a conversation with a model. Different components, not variants — which is why they get their own page.
Chat
When to use: a message thread — support, internal chat, document comments, a service history.
Groups by author and by day, marks the current user's side, shows delivery state and who is typing, and brings the composer along when you pass onSend.
import { Chat, Avatar, type ChatMessage } from "tempest-react-sdk";
import { useState } from "react";
export function Support({ me }: { me: { id: string } }) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
/** Optimistic insert: the message shows up before the server confirms. */
const send = async (text: string) => {
const id = crypto.randomUUID();
setMessages((current) => [
...current,
{ id, body: text, authorId: me.id, sentAt: Date.now(), status: "sending" },
]);
await api.post("/messages", { body: { id, text } });
setMessages((current) =>
current.map((m) => (m.id === id ? { ...m, status: "sent" } : m)),
);
};
return (
<Chat
messages={messages}
currentUserId={me.id}
onSend={send}
onRetry={(m) => resend(m.id)}
renderAvatar={(m) => <Avatar name={m.authorName ?? m.authorId} size="sm" />}
/>
);
}
| Prop | Type | Default | What it does |
|---|---|---|---|
messages |
ChatMessage[] |
— | The thread, oldest first. Never reordered. |
currentUserId |
string |
— | Author treated as "own": side, colour, delivery ticks. |
onSend |
(text: string) => void \| Promise<void> |
— | Renders the composer. Receives the trimmed text. |
onRetry |
(message: ChatMessage) => void |
— | Enables the retry control on a "failed" message. |
onSendError |
(error: unknown) => void |
— | Called when onSend rejects. The draft stays in the field. |
typing |
string[] |
[] |
Who is typing. One, two or a count is phrased for you. |
renderAvatar |
(message) => ReactNode |
— | Avatar for the first message of each run. |
header |
ReactNode |
— | Bar above the thread, inside the panel. |
groupWindowMs |
number |
300000 |
Gap that still keeps messages in one run. |
locale |
"pt-BR" \| "en" |
"pt-BR" |
Labels ("Today", "You", "Sending"…). |
emptyState |
ReactNode |
<EmptyState/> |
Empty thread. |
composerDisabled |
boolean |
false |
No permission, archived thread, offline. |
ChatMessage = { id, body, authorId, authorName?, sentAt, status?, data? } · status ∈ "sending" | "sent" | "read" | "failed".
The component is presentational and controlled, like the rest of the SDK: it takes a list and emits intent. Where messages come from (REST, the SDK's createWebSocket, an SSE stream) and how the optimistic insert is done stay with the app, because those differ per backend.
It only jumps to the bottom if you were already at the bottom
A thread that always scrolls to the newest message yanks whoever is reading history, every time anyone types. So the jump happens only when the reader was already down there (with 48px of slack for a partially visible last row) — the rule every chat app converges on. Verified in the browser: reading history at the top, three messages arrived and the position did not move.
A run breaks on author, on day and on a gap
Repeating the avatar and the name on every line of a five-message burst turns a conversation into a list of receipts. But a reply an hour later is a new beat even when nobody else spoke — joining it to the earlier burst would put one timestamp on messages an hour apart. groupWindowMs is that limit.
Failure state is not decoration
Without "failed" + onRetry, the user re-types what is already on screen. The failed bubble keeps its text readable (border and meta in red, not the whole background) precisely because re-reading the message is what somebody does before deciding to resend.
The thread is role=\"log\" with aria-live=\"polite\", and keyboard-reachable
A new message is announced without stealing focus. The container has tabIndex={0} because a scroll area with nothing focusable inside is unreachable by keyboard — the same problem the scroll fix solved for Table. Delivery state is text (VisuallyHidden), not just a glyph: "✓✓" is not read out.
It doubles as a comment thread
Same component without currentUserId and without typing: everyone on one side, a name per run. That is why "who am I" is a prop rather than an own field on every message — in a document comment thread nobody wants to annotate 200 messages.
ChatComposer
Exported separately for a custom layout (a composer pinned to the footer of a route, say). A textarea that grows with its content, Enter sends, Shift+Enter breaks the line.
| Prop | Type | Default | What it does |
|---|---|---|---|
onSend |
(text: string) => void \| Promise<void> |
— | Receives the trimmed text. Clears the field only if it does not reject. |
onError |
(error: unknown) => void |
— | Error from onSend. The draft is preserved either way. |
actions |
ReactNode |
— | Before the send button — attach, emoji. |
maxRows |
number |
6 |
Largest height, in lines. |
sendLabel |
string |
locale | Button label. |
It is uncontrolled, on purpose
A chat draft changes on every keystroke, and lifting that into app state re-renders the whole thread per character — the one place where "controlled by default" costs something visible. Apps that need the draft (a persisted composer, a slash-command menu) read it from onChange or drive it through the ref (focus(), setValue()).
IME: Enter while composing does not send
While composing Japanese or Korean, Enter confirms the candidate word. Sending there posts half a word and eats the confirmation — hence the isComposing check.
AIChat
When to use it: a conversation with a model — a copilot inside your app, a support assistant, conversational search. This is the shape ChatGPT, Claude and DeepSeek converged on.
Role-based turns (user / assistant / system), Markdown answers with code blocks, reasoning in its own block, a streaming caret, per-turn actions (copy, regenerate, edit, 👍/👎) and a composer that turns into a stop button while the answer is arriving.
AIChat and Chat are different components, not variants
A human thread is addressed by author and cares about delivery state. A model transcript is addressed by role, has no delivery state at all, and needs three things a human thread never does: partial output, reasoning separate from the answer, and re-asking. Folding both into one variant would mix two data models into the same props and leave authorId/delivery ticks dead on the LLM path.
Start with the minimum — a list and an onSend:
import { AIChat, type AIChatMessage } from "tempest-react-sdk";
import { useState } from "react";
export function Copilot() {
const [turns, setTurns] = useState<AIChatMessage[]>([]);
const ask = async (text: string) => {
setTurns((current) => [
...current,
{ id: crypto.randomUUID(), role: "user", content: text },
]);
const answer = await fetch("/api/ask", {
method: "POST",
body: JSON.stringify({ prompt: text }),
}).then((r) => r.json());
setTurns((current) => [
...current,
{ id: crypto.randomUUID(), role: "assistant", content: answer.text },
]);
};
return <AIChat messages={turns} onSend={ask} />;
}
That already gives you the transcript, the Markdown, the composer, Enter/Shift+Enter, the scroll that follows the answer, and the copy action. What is missing is streaming — and that is where the component starts to look like a product.
Streaming, from scratch
The SDK does not make the call for you: "how do I stream from my backend" has a different answer per provider. What it does is render the state. The contract is simple — keep rewriting the content of the last turn, and keep streaming: true on it until you are done:
import { AIChat, type AIChatMessage } from "tempest-react-sdk";
import { useRef, useState } from "react";
export function StreamingCopilot() {
const [turns, setTurns] = useState<AIChatMessage[]>([]);
const [pending, setPending] = useState(false);
const abort = useRef<AbortController | null>(null);
/** Rewrites the last turn on every chunk — the component follows the text itself. */
const write = (id: string, text: string) =>
setTurns((current) =>
current.map((t) => (t.id === id ? { ...t, content: text } : t)),
);
const ask = async (prompt: string) => {
const answerId = crypto.randomUUID();
setTurns((current) => [
...current,
{ id: crypto.randomUUID(), role: "user", content: prompt },
]);
setPending(true);
abort.current = new AbortController();
const response = await fetch("/api/stream", {
method: "POST",
body: JSON.stringify({ prompt }),
signal: abort.current.signal,
});
setPending(false);
setTurns((current) => [
...current,
{ id: answerId, role: "assistant", content: "", streaming: true },
]);
const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
write(answerId, buffer);
}
} catch (error) {
if ((error as Error).name !== "AbortError") throw error;
} finally {
setTurns((current) =>
current.map((t) => (t.id === answerId ? { ...t, streaming: false } : t)),
);
}
};
return (
<AIChat
messages={turns}
pending={pending}
onSend={ask}
onStop={() => abort.current?.abort()}
composerFooter={<small>May be wrong — check numbers before deciding.</small>}
/>
);
}
What you get for free in that snippet:
| You did | The component does |
|---|---|
pending while the request is out |
Shows the three dots and already swaps Send for Stop |
streaming: true on the last turn |
Draws the ▍ caret at the end of the text and hides that turn's actions |
Rewrites content per chunk |
Scrolls to follow — only if the reader was already at the bottom |
onStop |
Stop button in the send slot, and Escape in the field aborts too |
streaming: false at the end |
Caret goes away, actions come back, and a screen reader hears "Response complete" |
If your backend speaks SSE, use the SDK's createEventStream
The loop above is fetch + ReadableStream because that is the common path for LLM APIs. For a real text/event-stream endpoint, the SDK's sse already handles reconnection and Last-Event-ID — the write() loop is the same.
Reasoning (extended thinking / R1)
A turn with reasoning gets a collapsible block above the answer:
{
id: "a1",
role: "assistant",
content: "There are 12 orders.",
reasoning: "Filtered by overdue delivery date and status != delivered…",
}
While only the reasoning has arrived, the block opens itself
If the turn has streaming: true and content is still empty, the reasoning block mounts open — it is the only content there is, and hiding it would leave the screen frozen behind a blinking caret. Once the answer lands the block stays open (collapse it if you want); defaultReasoningOpen opens all of them, which is what an audit screen wants.
Per-turn actions
| Action | Appears on | Prop that enables it |
|---|---|---|
| Copy | every turn | always (copies the raw Markdown, not the HTML) |
| Regenerate | only the newest assistant turn | onRegenerate |
| 👍 / 👎 | assistant turns | onFeedback |
| Edit | user turns | onEditSubmit |
| Retry | a turn carrying error |
onRetry |
<AIChat
messages={turns}
onRegenerate={(turn) => reask(turn)}
onFeedback={(turn, vote) => track("answer_rated", { id: turn.id, vote })}
onEditSubmit={(turn, text) => {
truncateAfter(turn.id); // your app decides what goes
return ask(text);
}}
votes={savedVotes} // optional: votes loaded from your database
/>
Regenerate shows up on the newest assistant turn only — on purpose
Re-asking a turn in the middle throws away every turn after it. That is a different operation ("branch here") and needs its own confirmation; offering the same button for both invites losing half a conversation in one click.
Editing does not decide what to delete
onEditSubmit hands you the turn and the new text. Truncating the transcript is the app's call, because "drop everything after" and "create a branch" are different products and the SDK should not pick for you.
Suggested prompts and the empty state
<AIChat
messages={[]}
onSend={ask}
suggestions={["Summarise the latest report", "Which orders are late?"]}
/>
On an empty conversation the suggestions sit at the bottom of the transcript area; clicking one sends it straight away. They disappear on the first turn. Without onSend they are not rendered (there would be nowhere to send them) and the EmptyState shows instead — or your emptyState.
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
messages |
AIChatMessage[] |
— | The transcript, oldest first. Never reordered. |
onSend |
(text: string) => void \| Promise<void> |
— | Renders the composer. Receives the trimmed prompt. |
onStop |
() => void |
— | Aborts the turn in flight. Swaps send for stop; Escape aborts too. |
pending |
boolean |
false |
Request is out, nothing back yet. |
onRegenerate |
(message) => void |
— | Enables "regenerate" on the newest assistant turn. |
onEditSubmit |
(message, text) => void \| Promise<void> |
— | Enables "edit" on user turns. |
onFeedback |
(message, vote) => void |
— | Enables 👍/👎. vote ∈ "up" \| "down". |
onRetry |
(message) => void |
— | Enables retry on a turn carrying error. |
onSendError |
(error: unknown) => void |
— | Error from onSend or onEditSubmit. Draft preserved. |
votes |
Record<string, AIChatVote> |
— | Votes the app owns. Without it the pressed state is local. |
suggestions |
string[] |
[] |
Prompts offered on an empty conversation. |
renderAvatar |
(message) => ReactNode |
— | Avatar per turn. |
renderContent |
(message) => ReactNode |
— | Replaces the body — a tool-call card, a chart, a citation list. |
showSystem |
boolean |
false |
Show "system" turns. |
defaultReasoningOpen |
boolean |
false |
Open every reasoning block. |
showLineNumbers |
boolean |
false |
Line numbers in fenced code. |
header |
ReactNode |
— | Bar above the transcript, inside the panel. |
composerActions |
ReactNode |
— | Before the send button — attach, model picker. |
composerFooter |
ReactNode |
— | Under the field — token count, disclaimer. |
composerDisabled |
boolean |
false |
No credits, conversation archived, offline. |
maxRows |
number |
8 |
Largest composer height, in lines. |
locale |
"pt-BR" \| "en" |
"pt-BR" |
Labels ("Stop", "Reasoning", "You"…). |
emptyState |
ReactNode |
<EmptyState/> |
Empty conversation. |
AIChatMessage = { id, role, content, reasoning?, streaming?, error?, createdAt?, model?, attachments?, data? } · role ∈ "user" \| "assistant" \| "system".
AIChatAttachment = { id, name, size?, url?, mimeType? } — with url it renders a thumbnail, without it a chip with name and size.
Decisions worth knowing
The answer is Markdown, the prompt is plain text
A model emits Markdown by contract. A person who typed compute 2 * 3 * 4 did not mean to open an emphasis span — and seeing your own prompt rewritten is unsettling. So a user turn is white-space: pre-wrap and an assistant turn goes through Markdown (which already uses CodeBlock for fenced blocks). Want Markdown in the prompt too? renderContent.
The answer is the document, not a bubble
An assistant turn takes the full width with no bubble; a user turn is a narrow bubble pushed to the end of the row. Wrapping the answer in a bubble would cap its width, fight the tables and code blocks inside it, and make a long answer look like a shouted message. The prompt is short and needs to be told apart at a glance, which a bubble does better than anything else.
The transcript is not aria-live — and that is accessibility, not an oversight
A live region over streaming text makes a screen reader read the answer again on every token: unusable. So the role="log" carries no aria-live, and the two moments that matter ("Generating a response", "Response complete") are announced by a separate role="status". The turn in flight carries aria-busy, and the finished answer is read from the log at the reader's own pace. jsdom's axe cannot catch this class of mistake — it was a design decision, verified in a real browser.
Scrolling follows the answer only if you were already at the bottom
Same rule as Chat, and it weighs more here: a transcript that always jumps to the newest text would yank the reader dozens of times per second while streaming. When you are not at the bottom, a round button appears to take you back — the jump never happens unasked.
The scroll effect's dependency is not the list
Streaming appends to the last turn. An app that mutated that object in place — or that re-rendered from a store holding the same array — would keep the same dependency while the text grows, and the view would stop following the answer. Hence tailSignature() (exported): array length + tail identity + tail text length cover both shapes.
Only the growing turn re-parses
Markdown parses in its own render, and React skips re-rendering a child whose element is referentially identical. Holding that element across renders is what keeps a fifty-turn transcript from re-parsing every finished answer on every token of the newest one.
Stop takes the send slot, not a button next to it
The one button under your thumb is always the one you want next: send while idle, abort while the answer is coming. Two buttons side by side would mean aiming at the right one mid-stream.
An action hidden behind :hover does not exist on touch
The action row appears on hover and on keyboard focus, and is always visible where there is no hover at all (@media (hover: none)). Without that, on a phone the first tap would land on whatever is underneath. Verified with an emulated touch device: hover: none and pointer: coarse both true, row at opacity: 1.
The buttons measure 28×28 — above the 24×24 floor of WCAG 2.5.8, below the 44×44 of 2.5.5, and there are four of them side by side. Under pointer: coarse an ::after hit-slop takes the real target to 44×44 without moving a pixel of what you see, the same trick Button uses for its icon-only sizes. Growing the padding instead would space the row out on desktop, where the pointer is precise and the row should stay quiet.
Responsive: from a phone to a TV
Measured in a browser at 360×640, 390×844, 740×360 (phone in landscape), 768×1024, 1440×900, 1920×1080 and 3840×2160. At every width: zero horizontal overflow on the page and in the transcript, composer always visible, tables and code blocks scrolling inside their own box.
What changes with width:
| Range | What happens |
|---|---|
| up to 480px | transcript gap and padding tighten, the user bubble and the editor go to max-width: 100% |
| 480px – 768px | the reading column follows the available width |
| 768px and up | the column caps at 48rem and centres; the rest becomes margin |
The column width is a knob: --tempest-ai-chat-width
A capped column is the right answer from a phone up to a 1920 desktop — prose past roughly 90 characters per line is measurably harder to track back to the start of the next line, and letting an answer run the full width of a wide monitor makes it worse, not more useful.
From 2560 up the trade flips: 768px in the middle of a living-room screen is mostly empty room, and only the app knows how far away its user is sitting. Hence a knob rather than a constant:
:root {
--tempest-ai-chat-width: 72rem; /* default 48rem */
}
One value moves the turns, the thinking row, the suggestions and the composer together.
Type size is not solved here
The font is the same at 360px and at 4K. Scaling type for a TV is a decision for typography.css and density.css — a component-local font ramp would fight the tokens every app themes through. If you target TVs, raise --tempest-text-* at the root (or use [data-tempest-density="spacious"]) alongside --tempest-ai-chat-width.
AIChatComposer and AIChatTurn
Exported separately for apps that build their own layout — a composer pinned to the footer of a route, a side-by-side diff of two answers. Same relevant props as the panel, and AIChatComposer is uncontrolled for the same reason as ChatComposer: a draft changes on every keystroke, and lifting that into app state re-renders the whole transcript per character — with a streaming answer above, that is visible.
| Exported helper | What it is for |
|---|---|
visibleTurns({ messages, showSystem }) |
The list the panel actually renders. |
isGenerating(messages) |
Whether any turn is streaming. |
lastAssistantId(messages) |
Which turn gets "regenerate". |
tailSignature(messages) |
An effect dependency that changes when the tail grows. |
aiChatStrings(locale) · roleLabel(role, strings) · turnTime(ts, locale) |
Labels, to reuse in your own layout. |
Recap
- Conversation:
Chatfor a thread between people (author, delivery, typing) andAIChatfor a conversation with a model (role, streaming, reasoning, re-asking). They are different components, not variants. - All share the same controlled/uncontrolled patterns, expose keyboard A11y, and import from
tempest-react-sdk.