Skip to main content
Before this landed the transcript had seven assistant-side row kinds — text, reasoning, tool, approval, error, notice, dropped — and every one of them describes something the assistant did. A real tap on Send produced a transcript containing only the reply: the conversation was drawn with one side missing. The user row is that missing eighth kind. The row carries no tone. Tone is the vocabulary for “did this work”, and whether a message was understood is the reply’s business, not the message’s. What it carries instead is state, which is about storage and nothing else.

Quick Start

1

A turn already knows what it is answering

beginTurn is the one sanctioned way to put a prompt on a turn. initialTurn keeps prompt: "", so a chat nobody has spoken in draws no bubble.
2

The view model emits the user row first

The user row is pushed above everything the assistant produced — including reasoning — because a question that renders below its own answer is not a conversation.
3

Name the speaker without labelling the paragraph

The speaker is rendered as visually-hidden content ahead of the message; note is null until the turn ends unstored.
On screen the row is a right-aligned bubble (bg = rgb(31,122,99)), opens with a visually-hidden “You said:”, and shows an optional “Not saved” note only when the turn ended without being written.

Three storage states

Three states, not a boolean, because “not saved yet” and “finished and never saved” are different facts and only one of them is worth telling anyone about.
isPersisted is the sanctioned reader of end.userIndex, and it is the same predicate that decides whether Fork and Delete are offered. Two copies of that rule is one copy that gets it wrong — and the wrong one would tell the user their message is filed away when a reopen will not find it.
The caveat is only ever on an unstored row. sent and stored carry no note: a warning hung on every message is a warning nobody reads, and a row that said “not saved” for the whole of a normal streaming turn would be crying wolf for the seconds that matter least.

When the row appears

The row is drawn when a run is actually issued to an engine — beginTurn(prompt) in the controller — not when Send is tapped. Seeding the prompt when the run is issued — rather than when Send is tapped — is what ties the row to a run that was really sent. See Composer Behavior.

The row updates in place

The row id is the constant string "user" for the whole turn. There is exactly one prompt per turn, so the id is stable across every publish — which is what lets the reconciler update the row when it goes from sent to stored rather than removing and re-inserting it.
A remove/insert pair would be the flicker, and it would also drop the user’s text selection mid-stream. A sent → stored transition is a single in-place update: no flicker, no duplicate.

Reopen preserves the role

historyRows maps a stored message to a row by its role: a user message becomes a user row, an assistant message a text row. A reopened chat paints both speakers rather than flattening them into one voice.
A stored message is stored by definition — it was read back off the disk it is asking about. StoredMessage.role has been "user" | "assistant" all along; the fix is that the role stopped being discarded. See History & Reopen.

A finished turn no longer vanishes

A completed turn used to be removed by the very reconcile that drew the next question — so your “You said:” bubble disappeared the moment you spoke again. The fix promotes a finished turn into a history prefix when the next turn begins.
Promotion happens when the next turn begins, not when the current one ends: at the moment a turn ends it is still the live turn, still carrying its tool cards, its usage and its error row, and moving it early would either duplicate those rows or drop them at the instant the answer lands.
Ids are re-namespaced per turn (t0:, t1:, …) from the moment they are built. Both turns of a two-turn chat produce text:0, and a shared id is how a keyed renderer paints one row’s content into another row’s node — so two turns can never both claim text:0. Keying up front also means promotion changes no id at all, so it emits no ops: the rows already on screen stay exactly where they are.

Accessibility

A user row is distinguished on screen by which edge it hugs and what colour it is. Both are CSS; neither reaches the accessibility tree. So the row carries three independent signals, and a screen-reader user relies on the third:
accessibleName returns null for a user row on purpose — for the same reason it is null on a text row. An aria-label replaces an element’s content in the accessibility tree, so labelling your message would cost you the ability to navigate your own words by word, sentence or character and hand you one unbrowsable blob instead. The speaker is announced by userRowNames as visually-hidden content, which is additive rather than replacing.

The prompt survives the engine’s first start

The prompt is not an event. No engine reports it back — start carries only ids — so a reducer over the event stream can never learn it. It is seeded by whoever issues the run, and it must survive the engine’s first frame:
Without that one line the user’s message is painted the instant the run is issued and then erased by the engine’s first frame — a flicker that reads as “my message was there and then it wasn’t”. Seeding the prompt in beginTurn and carrying it across start is also what makes the row honest: a turn that exists has been handed to an engine, so a prompt on screen is a prompt that was actually sent.

History & Reopen

Role-preserving historyRows and the prefixed row id.

i18n & A11y

speakerUser, userNotStored, and why aria-label is null here.

Engines

Where end.userIndex comes from and how isPersisted reads it.

Errors & Recovery

The “Not saved” caveat on an unstored message.

Composer

Why the row appears when the run is issued, not when Send is tapped.