Skip to main content
Tap Chats, pick a conversation, and it reopens with its stored messages painted back into the transcript as real rows — so the next turn you send lands below them, not above. 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 chats route and paints the screen-chats DOM from a fresh snapshot.
Both lists, always together: a chat that failed to parse becomes a row rather than a conversation that silently vanished.
2

Build the rows

Only rows where 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.
A UI that calls list() and stops turns a carefully reported failure back into a conversation that silently disappeared. The chats screen renders both lists so a corrupt file is a visible row, not a gap.

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.
The section’s contents are cleared and replaced with a single <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}.
The prefix does two jobs:
The bug this replaces appended stored messages as raw <p> nodes outside render/nodes, with render reset to empty. The next turn then reconciled from nothing and inserted its rows at index 0 — above the restored history — while the manual <p> nodes could never be updated. Holding history as real Rows keeps it in the same coordinate system the stream appends to, and makes it survive the next turn’s reconcile.

Common Patterns

A reopened chat and a fresh one share the same publish path — only history differs.
For a fresh chat 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

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

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.