Persist chat conversations (IndexedDB, SQLite, REST)
Everything so far lives in memory — reload the page and the thread is gone. aparté’s
persistence subsystem closes that gap. A AparteConversationManager holds the list of
conversations and notifies listeners on every change, but never touches storage directly —
that’s the job of an AparteStorageAdapter you implement against any backend
(localStorage, IndexedDB, SQLite WASM, your own REST API). A conversationId binding
loads/persists the active thread; <aparte-conversation-list> renders a sidebar to
switch between them.
1. Implement an AparteStorageAdapter
Section titled “1. Implement an AparteStorageAdapter”The contract has three required methods — together they’re the minimum viable adapter:
interface AparteStorageAdapter { loadAll(): Promise<AparteConversation[]>; // all conversations, full payload, newest first save(conv: AparteConversation): Promise<void>; // upsert (create or update) delete(id: string): Promise<void>; // permanent delete // + optional split-storage extensions: archive?/unarchive?, loadMeta?/loadFull?, // pin?/unpin?, rename?, and loadAttachments? for persisted attachment blobs.}An AparteConversation carries id, title, createdAt, updatedAt, a flat messages
array (the active path — always kept for sidebar previews and compat), an optional tree
(full branch topology, see Conversations & branching),
and optional archivedAt / pinnedAt / schemaVersion (current version is
2; treat undefined as legacy data).
The current value is exported as APARTE_CONVERSATION_SCHEMA_VERSION, so an adapter
can compare it against what it stored and migrate instead of guessing.
Here’s a complete adapter over localStorage, implementing the three required methods plus
the optional archive / unarchive pair:
import type { AparteConversation, AparteStorageAdapter } from '@aparte/core';
const KEY = 'aparte:conversations';
function readAll(): AparteConversation[] { try { return JSON.parse(localStorage.getItem(KEY) ?? '[]'); } catch { return []; }}function writeAll(convs: AparteConversation[]): void { localStorage.setItem(KEY, JSON.stringify(convs));}
export class LocalStorageAdapter implements AparteStorageAdapter { async loadAll(): Promise<AparteConversation[]> { return readAll().sort((a, b) => b.updatedAt - a.updatedAt); }
async save(conv: AparteConversation): Promise<void> { const all = readAll(); const i = all.findIndex((c) => c.id === conv.id); if (i >= 0) all[i] = conv; else all.push(conv); writeAll(all); }
async delete(id: string): Promise<void> { writeAll(readAll().filter((c) => c.id !== id)); }
// Optional — without them, archiving falls back to a plain save(). async archive(id: string): Promise<void> { this._setArchived(id, Date.now()); } async unarchive(id: string): Promise<void> { this._setArchived(id, undefined); }
private _setArchived(id: string, archivedAt: number | undefined): void { const all = readAll(); const conv = all.find((c) => c.id === id); if (conv) { conv.archivedAt = archivedAt; writeAll(all); } }}An IndexedDB (or SQLite WASM) adapter follows the same shape — every method is async
precisely so any backend fits. Richer backends can additionally implement loadMeta() /
loadFull(id) (fast sidebar listing vs. lazy full payload), pin / unpin / rename,
and loadAttachments(msgId) for attachment blobs — all optional, consulted only when
present. Anything beyond the conversation — a memory of user facts, a settings store, a
gallery of every artifact ever produced — is your app’s own table: extend the interface
in your code rather than expecting the library to carry your schema.
2. Register a AparteConversationManager
Section titled “2. Register a AparteConversationManager”AparteConversationManager owns the in-memory list, mutates it, and calls your adapter — your
app never calls the adapter directly.
import { aparteGlobalConfig, AparteConversationManager } from '@aparte/core';import { LocalStorageAdapter } from './local-storage-adapter';
const manager = new AparteConversationManager(new LocalStorageAdapter());await manager.init(); // hydrates the list from the adapteraparteGlobalConfig.setConversationManager(manager); // registers it for every <aparte-*> componentRunning several independently-configured chats on one page? Call setConversationManager
on each chat’s own AparteConfig instance (passed as config) instead of the global
singleton.
Useful reads once registered: manager.conversations, manager.activeConversations /
manager.archivedConversations (newest first), manager.activeId, manager.active, and
manager.subscribe(listener) (returns an unsubscribe fn; fires after every mutation).
Mutations: createNew(title?), delete(id), archive(id), unarchive(id).
Optional bounded history:
new AparteConversationManager(adapter, { retention: { maxMessages: 200 } }) trims a persisted
conversation to its last N messages on every write — storage only, the live session in
the DOM is never truncated.
3. Load and persist the active thread — conversationId
Section titled “3. Load and persist the active thread — conversationId”Each wrapper’s <AparteChat> exposes a conversationId binding. Setting it loads that
conversation’s messages (and branch tree, if any) via the registered manager; setting it to
null deselects. Sending the first message while conversationId is unset lazily creates a
conversation and reports the new id back, so you can sync a URL/router.
There is no binding without a wrapper — the pair is a wrapper convenience over
AparteConversationController, which is the next section on this page.
controller.bind();void controller.setConversationId('abc-123'); // or null to deselectconst [conversationId, setConversationId] = useState<string | null>(null);
<AparteChat conversationId={conversationId} onConversationCreated={setConversationId} /><AparteChat :conversation-id="conversationId" @conversation-created="conversationId = $event" /><AparteChat {conversationId} on:conversationCreated={(e) => (conversationId = e.detail)} /><aparte-chat [conversationId]="conversationId" (conversationCreated)="conversationId = $event"></aparte-chat>Without a wrapper, drive the same lifecycle yourself with AparteConversationController —
it’s exactly what the wrappers use internally:
import { AparteConversationController } from '@aparte/core';
const chat = document.querySelector('aparte-chat')!;const viewport = chat.viewport!;
const controller = new AparteConversationController({ hostId: 'main-chat', host: chat, getMessages: () => viewport.getMessages(), setMessages: (m) => viewport.setMessages(m), appendMessage: (m) => viewport.appendMessage(m), clearMessages: () => viewport.clearMessages(), setLoading: (on) => viewport.setLoading(on), // optional — the viewport draws the wait exportTree: () => viewport.exportTree(), // optional — enables branch persistence importTree: (t) => viewport.importTree(t), // optional});controller.bind();void controller.setConversationId('abc-123'); // or null to deselectA conversation on its way
Section titled “A conversation on its way”A store takes its time. When your adapter implements loadMeta() and loadFull(id),
the manager reads the list through the first and fetches a conversation’s messages
through the second the first time it is opened (manager.ensureFull(id), and
manager.isLoaded(id) says whether it has). While that fetch runs, the controller calls
the binding’s setLoading(true): <aparte-chat-viewport> draws two skeleton turns,
marks its scroll surface aria-busy and names the wait for a screen reader, and
aparte-chat[center-empty] does not count it as empty — the welcome never shows over a
conversation that is loading. The composer is disabled for the duration — a message sent
into a transcript that has not arrived would be overwritten by it — and comes back as
it was (a disabled you set stays). Two quick clicks are safe: a reply that lands for a
conversation the user has already left is dropped. ensureFull() resolves false when the store
has no such record — the row then does not count as loaded, and the controller goes home rather
than keep an empty transcript active. A metadata change (updateTitle, pin, archive) on a
conversation whose messages are not in memory goes through the adapter’s own hook when it has one
(rename(), pin(), archive()…) and otherwise fetches the messages first, so a row the list came
with is never written back without them; updateMessages() on such a row is refused with a warning.
A metadata change leaves updatedAt alone — a rename, a pin and an archive keep the conversation’s
place in the list, which sorts on the last message rather than on the last edit.
The list has the same word for its own wait: set loading on
<aparte-conversation-list> while manager.init() runs, clear it when you assign
conversations.
The framework wrappers draw the same wait themselves — the controller sets it while it
fetches — and each has a loading prop (an input in Angular) for a wait of your own.
4. Render a sidebar
Section titled “4. Render a sidebar”<aparte-conversation-list> is a display primitive: set its conversations property and
active-id attribute. Each row is its title plus a ⋯ menu: rename, pin/unpin,
archive/unarchive, delete (which asks first). The rows are grouped by date as soon as any
item carries updatedAt; set flat to render them flat.
Add manage and the menu writes through to the manager you registered in step 2 — those
gestures need no code of yours at all:
const list = document.querySelector('aparte-conversation-list')!;
function render() { list.conversations = manager.activeConversations; list.setAttribute('active-id', manager.activeId ?? '');}manager.subscribe(render);render();
// The two lines that finish the row menu — the first is step 2's, repeated here so the// pair is visible together.aparteGlobalConfig.setConversationManager(manager);list.setAttribute('manage', '');// Selecting a conversation is owned by the `conversationId` binding (or a window-level// `aparte-conversation-select` event that every bound controller listens for), not the list.When you own the persistence, leave manage off: the list then only announces the
intent, and every gesture is yours to carry out. The events all bubble, with
detail: { id } — rename adds title. They fire under manage too, first and
cancelable, so preventDefault() on one takes that single gesture back and leaves the
rest to the list.
list.addEventListener('aparte-conversation-rename', (e) => manager.updateTitle(e.detail.id, e.detail.title));list.addEventListener('aparte-conversation-pin', (e) => manager.pin(e.detail.id));list.addEventListener('aparte-conversation-unpin', (e) => manager.unpin(e.detail.id));list.addEventListener('aparte-conversation-archive', (e) => manager.archive(e.detail.id));list.addEventListener('aparte-conversation-unarchive', (e) => manager.unarchive(e.detail.id));list.addEventListener('aparte-conversation-delete', (e) => manager.delete(e.detail.id));Each wrapper ships a reactive helper around the same manager — call its init(adapter)
once instead of constructing AparteConversationManager by hand (step 2 is done for you), then
bind conversations / activeConversations / archivedConversations to the list and
createNew / addMessage / updateMessages / updateTitle / pin / unpin / delete /
archive / unarchive to actions:
| Wrapper | Helper |
|---|---|
| React | useConversationManager() — hook, plain state |
| Vue | useConversationManager() — composable, refs/computed |
| Svelte | createConversationManager() — stores |
| Angular | ConversationManagerService — injectable (providedIn: 'root') |
Switching the active conversation stays owned by the conversationId binding — the helpers
deliberately don’t expose a select().
5. What survives a round trip
Section titled “5. What survives a round trip”Your adapter stores AparteMessage objects verbatim, segments included, and hands them
back. One exception, and it is core’s own: the segments core derived by splitting a
markdown reply on display are not written back — neither in messages nor in the branch
tree. They are core’s reading of your content, so they are read again on the next open
and a block grammar you register later applies to conversations already saved.
Core then adopts the segments you did store rather than treating them as new — and the difference is worth knowing, because two of the fields on a segment are facts and two are measurements.
| on a restored segment | what happens | why |
|---|---|---|
id, type, content, … | yours, untouched | it is your data |
messageId, index | recomputed from the array being joined | derivable facts. A stored value can only contradict the list it lands in — and no protocol persists either; Anthropic’s block index exists solely inside the streaming envelope, as a position |
meta.aparte.startedAt / endedAt | not restored, and not invented | a span is something the client measured while the turn ran. A measurement nobody took is absent |
isStreaming | forced to false | a persisted stream is dead. Restored as streaming it would render a caret for ever, and the next completed turn would stamp it a brand-new end |
meta.* (yours) | round-trips as stored | your half of the bag |
Nothing is lost that was ever there. Most backends store messages and have never
heard of a segment — content parts carry no timestamp in any wire format — so there is
usually nothing to lose. What changed is that core no longer fills the gap with now: a
conversation from three weeks ago used to come back claiming every segment had started
that second, and only on some of the load paths, so the same stored thread produced
different numbers depending on whether you were in native or framework-managed mode and
on whether a tree had been saved.
A request that outlived its page is closed for you. A tool_call persisted as
awaiting-approval comes back as aborted. The loop that awaited the decision went with
the page, so nothing can answer it — and aborted rather than rejected because nobody
refused anything. Every load path shares one normalisation, so this holds whichever of
them your adapter uses.
One thing core still cannot fix for you. A tool_call persisted as pending comes
back with a spinner and an open span, because the handler that was running is likewise
gone. If your adapter can be interrupted mid-turn, normalise that status on save.
See its element page for the exact property and
attribute signatures, and
Conversations & branching for exportTree() /
importTree(), which this subsystem persists as the tree field.