Skip to main content
One unreadable chat is reported, never allowed to truncate the list behind it.

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

Because session.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 from mintChatId(), 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.
A pre-existing test only asserted two ids differ, and the launch id is real — so shipping "unassigned" on turn two passed the differ-check while merging every “new chat” turn into one conversation on the engine. The regression drives three turns across two “New chat” taps and asserts (a) none of the sent ids is "unassigned", and (b) three conversations produced three distinct ids.

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.
A chat whose updated is pinned to its first message sinks to the bottom of the list the moment any other chat is touched. updated must be refreshed on every session.record(prompt, answer), or the “recently used” list stops being recently-used — an untouched month-old chat sits at the top while the one you are typing in drops out of sight.

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 .
The cap counts graphemes via 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".
[...line] iterates by code point, so a title cut just past its cap never lands mid-surrogate. Reaching for line.split("") splits UTF-16 code units, and a first message of 80 👍s ends up sliced through a surrogate pair — the chat list row ends in a forever. Pinned by "a title is cut on a code POINT boundary, never mid-emoji".

Reopened messages paint through the reconciler

Reopening a chat now paints its stored messages as real Rows through the reconciler — not as untracked <p> nodes appended outside the render state.
Because the restored messages live in the same render state the stream appends to, the next turn you send lands below them, and a reconcile never removes history it did not emit.
The defect this replaces reset render to empty and appended stored messages as raw <p> elements. The next turn then reconciled from an empty render state and inserted its rows at index 0 — above the restored conversation — while the manual nodes could never be updated. Holding history as real Rows with a history:{index}:{role} id fixes both. See History & Reopen.

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

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.
A file that vanished between listIds() and read() is not corrupt — do not warn on it. Only ids returned by listUnreadable() are broken.
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.

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.