Quick Start
1
Load the readable chats
list() returns every chat it could read, newest first. A single corrupt file drops out silently here — it does not truncate the list.2
Report the unreadable ones
listUnreadable() returns the ids of every corrupt file, so the app can name an accurate count instead of losing conversations in silence.What a relaunch preserves
“Your conversations are saved” (the i18n string on the crash screen, unchanged by the native-store work) means one precise thing: every completed turn survives an app kill, a device reclaim, or an OS eviction. A turn that was in flight at the moment of the crash is lost — by design, not by accident.A completed turn survives
session.record(prompt, answer) writes the question and the answer together as one write (core/src/chat/session.ts — [...base.messages, user, assistant]). After it returns, a second createApp over the same bytes rebuilds the transcript — the relaunch the next launch actually performs.
The relaunch is proved real by a fresh-store control — a store that forgets makes the crash screen a lie -- the control — which gives the second launch an empty store and requires the conversation to be gone. Without it, “the transcript comes back” would be satisfied by a store that never lost anything. Proved once over the web adapter and once over createTauriStorage (the SAME proof over the tauri adapter, against a store that outlives it), both carrying the guarantee across a real uncaught error fired through installCrashHandler (app/src/crash-recovery.test.ts).
An in-flight turn is lost, by design
Becausesession.record writes the question and the answer in a single write, a crash cannot leave a persisted transcript with a dangling question and no reply. The crash-screen string says nothing about the turn that was mid-flight; the boundary is deliberate. Pinned by a conversation written before a crash comes back after the relaunch (web and Tauri), the fresh-store control, and a turn still in flight when the app dies is NOT claimed to be saved.
Cross-linked from Storage & Secrets → How Session Persistence Works, which shows the sequence but does not state the boundary.
How It Works
list() reads each id in isolation, so a failure on one file cannot stop the ones after it.
A future
schemaVersion is refused as too_new, not truncated — reading a newer file with an older client and dropping the fields it does not understand would turn a version skew into data loss on the next write. too_new counts as unreadable for listUnreadable().Chat ids are never the placeholder
Every turn the app sends carries a fresh chat id frommintChatId(), never the sentinel "unassigned".
"unassigned" is a value controller.ts uses internally to mean nobody has said which conversation this is — it must never reach an engine that keys history by chat_id, because doing so silently merges conversations. A “New chat” action now mints a real id.
Chat list ordering
repo.list() returns chats newest-first by updated. The updated timestamp advances on every recorded turn — not only when the chat is first created — so the conversation the user is actively in stays at the top of the list.
How a chat gets its title
A chat’s title is the first user message, capped at 60 graphemes. Exactly 60 graphemes is kept whole; 61 or more is truncated with a trailing….
Intl.Segmenter, not code units, so an emoji or a combined character counts as one. The <= boundary is the whole contract: 60 stays whole, 61 truncates.
A title of exactly 60 graphemes is not truncated; one character over the maximum is. Pinned by
"a title of exactly the maximum length is not truncated" and "…one character over the maximum IS truncated".Reopened messages paint through the reconciler
Reopening a chat now paints its stored messages as realRows through the reconciler — not as untracked <p> nodes appended outside the render state.
User interaction flow
1
A crash corrupts one write
The user reopens the app after a crash truncated a chat mid-write. The conversation list still appears complete — every other chat is intact and ordered newest-first.
2
The app surfaces the count
listUnreadable() returns the one broken id, so the app shows a subtle recovery banner naming the count. No conversation vanishes without the user being told.Best Practices
Always pair list() with listUnreadable()
Always pair list() with listUnreadable()
list() alone hides corrupt files by design. Call listUnreadable() on the same screen so a lost conversation is surfaced with a count rather than disappearing in silence.Treat absent and corrupt differently
Treat absent and corrupt differently
A file that vanished between
listIds() and read() is not corrupt — do not warn on it. Only ids returned by listUnreadable() are broken.Never trust an id-less file
Never trust an id-less file
A chat missing its
id is reported as unreadable, not loaded with id: undefined. Saving such a chat would write to chats/undefined and collide with every other id-less file.Related
Storage & Secrets
Where chats persist as opaque strings.
Errors & Recovery
What each failure looks like on the phone.
History & Reopen
The chats list and reopening a stored conversation.

