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
reasoning — because a question that renders below its own answer is not a conversation.3
Name the speaker without labelling the paragraph
note is null until the turn ends unstored.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.
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 rowid 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.
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.
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.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:
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.
Related
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.

