session.list() and session.open() are reachable from the running app for the first time — the chat list is the path back into a stored transcript.
Quick Start
1
Open the chat list
The top bar carries a Chats button. Tapping it pushes the Both lists, always together: a chat that failed to parse becomes a row rather than a conversation that silently vanished.
chats route and paints the screen-chats DOM from a fresh snapshot.2
Build the rows
kind === "chat" carry data-action="open-chat" and data-chat-id. Unreadable rows are shown but inert.3
Reopen a conversation
Tapping an
open-chat row calls session.open(chatId), reloads the stored messages, and repaints them through the reconciler as real Rows.The chats screen
buildChatsScreen renders one snapshot: an empty state, an all-unreadable warning, or a list of rows.
An empty list and a list that is empty because everything in it failed to parse are not the same screen. The first is a new install; the second is data loss.
buildChatList distinguishes them so the UI can too — see Chat Recovery.Unreadable rows are shown, not hidden
repository.listUnreadable() feeds the ids of every corrupt file into the same view. Those rows sort to the top and carry no open-chat intent — a tap on an unreadable row has nowhere useful to go, and intents.ts refuses a missing chatId anyway.
When the list load itself fails
What happens if storage fails while I’m on the chats screen? You keep the conversation you were in. A failed chat list stays a local failure — the screen you were on stays reachable, and the back gesture keeps you in the conversation you were in.session.list() and repository.listUnreadable() are async calls against StoragePort. The same throws bootOrFail catches at boot can be raised here too:
The list rebuild runs in a floating async block inside the route handler. A rejection there is caught inside that block, so it never escalates to the global crash handler
mount() installs at step 0. Only the chats section repaints — the top bar, the current chat, and the composer stay live.
<p class="row row-notice" role="alert"> carrying strings.crashed. role="alert" announces it through the screen reader; data-tone="warning" styles it distinctly from a data-loss error tone.
The previous conversation is retained on the chat screen and reachable via the back gesture and the top bar — nothing about the open chat depends on the list load succeeding.
The guarantee is pinned by
"a storage failure while the chat list loads stays LOCAL, not fatal" in app/src/main.test.ts. It boots the app, fails the next storage read while entering the chats route, and asserts the chat screen’s composer is still reachable and the fatal “could not start” screen never appeared.How It Works
Reopening resets the live render state, then seeds the reconciler with the stored history before any new turn arrives. The reopen handler runs a fixed sequence:history lives in the mount closure and is prepended to every reconcile in publish. A follow-up turn’s rows land below it, and because history is inside the render state, a reconcile never emits remove for rows it did not know about. New chat resets history = [].History rows carry a prefixed id
historyRows maps each stored message to a Row whose id is history:{index}:{role}.
Common Patterns
A reopened chat and a fresh one share the same publish path — onlyhistory differs.
history is [], so this is a no-op prepend; for a reopened chat it keeps the restored conversation above the turn now streaming and inside the render state.
Best Practices
Fetch a fresh snapshot on every visit
Fetch a fresh snapshot on every visit
The
chats builder calls session.list() + listUnreadable() each time the route is entered, so a chat created since the list was last seen appears and one deleted is gone.Render both lists, never just list()
Render both lists, never just list()
list() alone hides corrupt files by design. Pair it with listUnreadable() on the same screen so a lost conversation is surfaced as a row with a count rather than disappearing in silence.Paint restored messages through the reconciler
Paint restored messages through the reconciler
Reopened messages are real
Rows seeded with reconcile(emptyRender, history), not raw nodes. That is what keeps them in the same coordinate system the next turn’s stream appends to.Give history rows a namespaced id
Give history rows a namespaced id
The
history:{index}:{role} prefix keeps restored rows stable across re-open and out of collision range of a live turn’s text:N ids.Related
Chat Recovery
How
list() and listUnreadable() keep a corrupt file visible.Follow & Jump
Stick-to-bottom and the jump-to-latest affordance.
Route Focus
Where focus lands when a route pushes or pops.
Overview
The top bar, the retained chat screen, and New chat.

