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. Each chat row is a <div> holding two controls: an Open button carrying the title and a muted .chat-updated span, and a Delete button on the right. The row is a <div> rather than a <button> because a <button> cannot legally contain another <button>.
The open button’s aria-label is strings.chatUpdated(title, updatedLabel) — one string builds both the visible time and the spoken time, so the two cannot drift. An unreadable row has no update time and keeps the bare chatRowName. The updatedLabel was computed on every visit by buildChatList(summaries, unreadableIds, Date.now()) all along; the row is now what finally renders it, so a list sorted by recency shows why one “Untitled” sorts above another.
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. The same LOCAL-not-fatal rule now also covers a rejection raised by the title lookup done for the delete announcement — see Delete a conversation.

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 = [].

Delete a conversation

Deleting a chat closes the loop from session.removerepository.removestorage.remove — all three were implemented and contract-tested with no app caller, so until now a stored conversation could be opened but never removed. Deletion is two taps and the state machine disarms itself the moment the route changes. The delete-chat intent handler runs a fixed sequence.
1

First tap arms

The delete button re-labels to strings.actionConfirmDelete (“Confirm”) and its aria-label becomes strings.deleteChatConfirm(title). Nothing is removed yet. This is the only irreversible action in the app, and the delete control sits millimetres from the open control on a touch screen.
2

Second tap on the same row removes

session.remove(chatId) runs the repository.removestorage.remove chain. A tap on a different row moves the arming instead of deleting — the first tap never deletes.
3

Leaving the chats route disarms

Any armed delete is reset on route change, so a first tap in one visit cannot spend itself into a delete on the next.
The assertive announcement uses the chat’s clean title, looked up via session.list in a titleOf helper that degrades to the chat id if the list read rejects. The read stays outside the remove try, so a failed lookup for a nicety cannot escalate to the app-wide crash screen.
A refused storage.removeSecurityError with site data blocked, or QuotaExceededError — is announced through the assertive live region as strings.chatDeleteFailed (“That conversation could not be deleted.”), not swallowed. A delete that quietly did nothing leaves the user believing a conversation is gone when it is still there.
The list rebuilds through the extracted refreshChats(section) helper — the same function the route builder uses on visit — so “the row is gone” and “the list is now empty” (falling back to strings.chatsEmpty) are the same rendering, not two hand-written paths that can drift.
Two taps on the same row, in that order. A tap on a different row moves the arming rather than deleting two.
The transcript, live regions and render state are reset in the same step, so the user is not left typing into a conversation that no longer exists on disk — the next turn would otherwise silently re-create it.
chatDeleteFailed reaches the assertive region; a delete that quietly did nothing leaves the user believing a conversation is gone when it is still there.
The delete-time LOCAL-not-fatal guarantee is pinned by "a delete when the chat list read REJECTS stays LOCAL, not fatal" in app/src/main.test.ts. The same rule that keeps a chat-list load failure local now also covers a rejection raised by the title lookup done for the delete announcement — titleOf degrades to the chat id rather than crashing the app.

History rows carry a prefixed id

historyRows maps each stored message to a Row whose id is history:{index}:{role}, and the role decides the row kind — a reopened chat now paints your messages as yours, not as another block of assistant text.
A user message becomes a { kind: "user", … } row and an assistant message a { kind: "text", … } row — the two speakers are no longer identical. A stored message is state: "stored" by definition: it was read back off the disk the row is asking about. See Transcript User Row for the three storage states. 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.

The model actually gets the conversation

Before PR #4816 the app stored a conversation, rendered it, and showed the model none of it — the follow-up now resolves against the prior turn. Ask the capital of France, get “Paris.” Then ask “And its population?” — before the fix that second question reached the provider with no subject, so the honest reply was a request for clarification. After the fix the engine restores the prior turn onto the agent first, so the follow-up is answerable.

Restore happens on every turn

The engine builds a fresh agent per turn — the model and the API key come from settings and can change between messages — so upstream’s own accumulation across streamEvents calls dies with each agent. Agent.setHistory is therefore called before every stream, the first turn included, so the empty conversation is not a separate code path.
Only completed prior turns are replayed. The current turn’s prompt travels as RunRequest.prompt, never duplicated into history — a model asked the same question twice in one request answers the wrong one about half the time.

History is read from the session store, not an in-process buffer

historyFor(session) reads the same current() chat the transcript scrolls through, so a reopened conversation carries its memory: the messages a user scrolls back through and the messages the next turn remembers are one array.
A history buffered in-process would pass the multi-turn case and fail every relaunch — the case a phone hits daily, because a phone kills backgrounded apps.

The 24,000-character truncation budget

A long conversation eventually outgrows any context window, so truncateHistory bounds the restored history to HISTORY_CHAR_BUDGET — 24,000 characters, roughly the last ~6,000 tokens.
Three deliberate rules, each pinned by a mutation test that names the failure it prevents: The budget counts characters, not tokens: tokenizing would pull a model-specific tokenizer into a webview bundle. It is deliberately an unexposed constant, not a setting — a setting invites a value that is wrong for whichever model the user later picks, and the provider 400 mid-answer that produces is exactly what the budget exists to make impossible.
Truncation is not surfaced in the UI — a known-open gap. When it fires, the transcript on screen is unchanged and complete: nothing is deleted, and scrolling back still shows every message. But the model stops being able to refer to the oldest turns, and the user is not told. Protocol v2 has no event for “context was trimmed” and adding one is a protocol bump, so this is recorded rather than half-done. truncateHistory already returns dropped, so the value a future notice would carry exists — nothing consumes it yet.
Only the in-process engine restores. praisonai-ts gets historyFor(session) and replays locally; remote-http deliberately does not. It POSTs chat_id to a server that keeps its own history for that id, so sending client-side history there would send every prior turn twice — once from the client, once from the server’s store. A doubled conversation is worse than a missing one: silent, growing, and it makes the model contradict itself. A chat answered by the remote engine therefore leaves the local session empty and has no local history to restore.

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.
A fresh agent is built per turn, so Agent.setHistory(truncateHistory(historyFor(session).messages())) runs before every stream — the first, empty turn included. Reading from the session store rather than an in-process buffer is what makes a relaunched chat still remember its own turns.

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.