Chat Recovery
A corrupt chat file never hides the ones after it, and unreadable files are surfaced explicitly.
One unreadable chat is reported, never allowed to truncate the list behind it.
Quick Start
- Load the readable chats
ts import { createChatRepository } from "praisonai-mobile/core/chat/repository"; const repo = createChatRepository(storage); const chats = await repo.list();list()returns every chat it could read, newest first. A single corrupt file drops out silently here — it does not truncate the list. - Report the unreadable ones
ts const broken = await repo.listUnreadable(); if (broken.length > 0) showRecoveryBanner(broken.length);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.
| At the moment the app dies | On next launch |
|---|---|
Turn is complete (session.record returned) | The transcript comes back |
| Turn is in flight (question sent, answer not persisted) | The turn is gone — no dangling question, no phantom reply |
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).
// crash-recovery.test.ts — the boundary, both adapters.
await app.session.record({ prompt: "hello" }, "hi there");
crash(); // a real uncaught error through installCrashHandler
const relaunched = createApp({ storage /* same bytes */ });
// relaunched.session.current().messages === [user, assistant]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.
| Guarantee | Behaviour |
|---|---|
| Per-file isolation | One unparseable chat does not truncate the list. Every readable chat after it still appears in list(). |
| Corrupt files are counted | listUnreadable() reports every corrupt file, so the app can show an accurate “some conversations couldn’t be loaded” affordance. |
A missing id is unreadable | A file with no id (or a non-string id) is reported as unreadable — not loaded with id: undefined, not silently dropped. This stops a save writing to chats/undefined. |
Non-array messages | A file whose messages field is a string, object, or null loads with messages: []. Without this, a stray "hi" spreads as four one-character messages and the indices end reports address the wrong ones — Fork and Delete then act on junk. Pinned by "a chat whose messages field is not an array loads as empty, not as itself". |
Missing / non-string title | Loads as "Untitled", not as a blank row. Pinned by "a chat with no title shows as Untitled, not as nothing". |
| Vanished ≠ corrupt | A file deleted between listIds() and read() (another tab, an eviction) is not reported as unreadable — absent is not corrupt. It simply drops out of list(). |
| Legacy chats keep opening | A chat with no engineId loads as engineId: "unknown" rather than failing. |
| List-load storage failure is local | A StoragePort reject raised while the chat list is being rebuilt is caught in the route handler and replaces the list section with a role="alert" warning row — the app-wide chrome, the current chat, and the composer are not touched. Contrasts with a boot-time storage reject, which is fatal (see Storage & Secrets). Pinned by "a storage failure while the chat list loads stays LOCAL, not fatal". |
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.
setChat(mintChatId()); // a real id, not the "unassigned" placeholderChat 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.
// repository.ts — the sort every chat list is read in.
return summaries.sort((a, b) => b.updated - a.updated);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 ….
// session.ts — the boundary is points.length <= 60.
const points = [...new Intl.Segmenter().segment(firstMessage)];
const title = points.length <= 60 ? firstMessage : shortened(points, 60);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.
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.
// main.ts — the open-chat intent seeds the reconciler with history.
const chat = app.session.current();
history = chat === null ? [] : historyRows(chat.messages);
const seeded = reconcile(render, history);
applyOps(transcript, nodes, seeded.ops, strings);
render = seeded.next;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.
User interaction flow
- 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.
- 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()
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
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
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.