> ## Documentation Index
> Fetch the complete documentation index at: https://praison.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# 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.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant App
    participant Repo as ChatRepository
    App->>Repo: list()
    Repo-->>App: [good, good, good]
    App->>Repo: listUnreadable()
    Repo-->>App: [bad]
```

## Quick Start

<Steps>
  <Step title="Load the readable chats">
    ```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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.
  </Step>

  <Step title="Report the unreadable ones">
    ```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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.
  </Step>
</Steps>

***

## 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`).

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
// 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](/docs/features/mobile/storage-and-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.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    Ids[📄 listIds] --> Read{🔍 readable?}
    Read -->|yes| Keep[✅ in list]
    Read -->|corrupt| Skip[⚠️ listUnreadable]
    Read -->|vanished| Drop[🚫 dropped, not corrupt]

    classDef ids fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef branch fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef keep fill:#10B981,stroke:#7C90A0,color:#fff
    classDef warn fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef drop fill:#6366F1,stroke:#7C90A0,color:#fff

    class Ids ids
    class Read branch
    class Keep keep
    class Skip warn
    class Drop drop
```

| 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](/docs/features/mobile/storage-and-secrets)). Pinned by `"a storage failure while the chat list loads stays LOCAL, not fatal"`. |

<Note>
  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()`.
</Note>

***

## 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.

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
setChat(mintChatId()); // a real id, not the "unassigned" placeholder
```

<Warning>
  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.
</Warning>

***

## 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.

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
// repository.ts — the sort every chat list is read in.
return summaries.sort((a, b) => b.updated - a.updated);
```

<Warning>
  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.
</Warning>

***

## 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 `…`.

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
// 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.

<Note>
  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"`.
</Note>

<Warning>
  `[...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"`.
</Warning>

***

## Reopened messages paint through the reconciler

Reopening a chat now paints its stored messages as real `Row`s through the reconciler — not as untracked `<p>` nodes appended outside the render state.

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
// 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.

<Warning>
  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 `Row`s with a `history:{index}:{role}` id fixes both. See [History & Reopen](/docs/features/mobile/history-and-reopen).
</Warning>

***

## User interaction flow

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

***

## Best Practices

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

***

## Related

<CardGroup cols={2}>
  <Card title="Storage & Secrets" icon="database" href="/docs/features/mobile/storage-and-secrets">
    Where chats persist as opaque strings.
  </Card>

  <Card title="Errors & Recovery" icon="triangle-exclamation" href="/docs/features/mobile/errors-and-recovery">
    What each failure looks like on the phone.
  </Card>

  <Card title="History & Reopen" icon="clock-rotate-left" href="/docs/features/mobile/history-and-reopen">
    The chats list and reopening a stored conversation.
  </Card>
</CardGroup>
