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.
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>.
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.
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. 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 fromsession.remove → repository.remove → storage.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.remove → storage.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.
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.
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.
The first tap on Delete never deletes
The first tap on Delete never deletes
Two taps on the same row, in that order. A tap on a different row moves the arming rather than deleting two.
Deleting the open chat clears it off screen
Deleting the open chat clears it off screen
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.
A storage refusal is spoken, not silent
A storage refusal is spoken, not silent
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.
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 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 acrossstreamEvents 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.
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.
The 24,000-character truncation budget
A long conversation eventually outgrows any context window, sotruncateHistory bounds the restored history to HISTORY_CHAR_BUDGET — 24,000 characters, roughly the last ~6,000 tokens.
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.
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 — 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.Restore the conversation onto the model every turn
Restore the conversation onto the model every turn
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.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.

