Skip to content

Save conversations and list them in a sidebar

Keep conversations across reloads in any store — localStorage, IndexedDB, SQLite or your API — open one from a list or a URL, and get back what was saved.

On this page
Open
The list of saved conversations, grouped by date, each with its menu.
ts
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(conversations: AparteConversation[]): void {
  localStorage.setItem(KEY, JSON.stringify(conversations));
}

export class LocalStorageAdapter implements AparteStorageAdapter {
  async loadAll(): Promise<AparteConversation[]> {
    return readAll();
  }

  async save(conversation: AparteConversation): Promise<void> {
    const all = readAll();
    const i = all.findIndex((c) => c.id === conversation.id);
    if (i >= 0) all[i] = conversation; else all.push(conversation);
    writeAll(all);
  }

  async delete(id: string): Promise<void> {
    writeAll(readAll().filter((c) => c.id !== id));
  }
}

Your adapter does all the storage, against any store, and every method returns a promise. Three methods are required — loadAll, save (create or update) and delete. The optional ones let a store do a change in place rather than through save: archive and unarchive, pin and unpin, rename, and the pair loadMeta and loadFull for large histories. An AparteConversation holds its id, title, createdAt, updatedAt, the messages on screen, the branch tree, and archivedAt, pinnedAt and schemaVersion, which an adapter compares with APARTE_CONVERSATION_SCHEMA_VERSION to migrate old records. Anything else your app keeps is a table of its own.

ts
import { aparteGlobalConfig, AparteConversationManager } from '@aparte/core';
import { LocalStorageAdapter } from './local-storage-adapter';

const manager = new AparteConversationManager(new LocalStorageAdapter(), {
  retention: { maxMessages: 200 },   // optional: what a save keeps
});
await manager.init();
aparteGlobalConfig.setConversationManager(manager);

The manager holds the list and calls your adapter; your app talks to the manager. init() reads the store once. retention saves the last 200 messages of a conversation and the branches under them; the conversation on screen stays whole. A chat with its own configuration gets its own manager through that configuration’s setConversationManager.

ReadGives
conversations, activeConversations, archivedConversationsthe lists, the last two newest first
activeId, activethe conversation open in the chat
subscribe(listener)a call after every change; returns the function that stops it

createNew, delete, archive, unarchive, pin, unpin and updateTitle change the list. A rename, a pin or an archive leaves updatedAt alone, so the conversation keeps its date; updatedAt moves when its messages change — not when a conversation is opened and left, and not when the reader moves between the versions of a reply, which is saved without dating it. The conversations reference lists every method and type.

In a framework, the package’s helper builds and registers the manager: call its init(adapter) once, or init(adapter, config) for a chat with its own config. A third argument takes the manager’s options — init(adapter, aparteGlobalConfig, { retention: { maxMessages: 200 } }), or a titleProvider — and the helper’s manager is the manager itself once init has run, for what the helper does not mirror (setTitleProvider, ensureFull). Read it as cm.manager after init: a copy destructured before (const { manager } = …) stays null in Vue and Svelte.

FrameworkHelper
ReactuseConversationManager(), a hook
VueuseConversationManager(), a composable
SveltecreateConversationManager(), stores
AngularConversationManagerService, provided in root
ts
import { AparteConversationController } from '@aparte/core';

const chat = document.querySelector('aparte-chat');
const viewport = chat?.viewport;

if (chat && viewport) {
  chat.id ||= 'main-chat';   // the id the chat's events carry as targetId
  const controller = new AparteConversationController({
    hostId: chat.id,
    host: chat,
    getMessages: () => viewport.getMessages(),
    setMessages: (messages) => viewport.setMessages(messages),
    appendMessage: (message) => viewport.appendMessage(message),
    clearMessages: (options) => viewport.clearAll(options),
    setLoading: (loading) => viewport.setLoading(loading),
    exportTree: () => viewport.exportTree(),
    importTree: (tree) => viewport.importTree(tree),
  }, { onConversationCreated: (id) => history.replaceState(null, '', `#${id}`) });

  controller.bind();
  void controller.setConversationId(location.hash.slice(1) || null);
}

A conversation id opens that conversation in the chat, and null closes it. The first message sent with none open creates one and hands you its id, so a URL or a router can follow. From then on, the person’s messages are saved as they are sent, and a reply when the chat hears it end — aparte-message-done, -error or -aborted, which AparteClient and injectTokenStream send, and a plain-HTML loop of your own sends as in Your own loop. A new version is saved as it appears. In plain HTML the controller does this work, and bind() returns the function that stops it; setLoading, exportTree and importTree are optional, and without one it does without the loading state or the branches.

aparte-conversation-select with { id }, sent on window, opens a conversation in every chat bound to a manager — or in one chat, when it also carries that chat’s targetId — and { id: null } starts a new one, so a New chat button needs nothing else: window.dispatchEvent(new CustomEvent('aparte-conversation-select', { detail: { id: null } })). The chat sends aparte-conversation-loaded with { targetId, conversationId } once the messages are on screen. With a framework, let your conversationId follow those selections too — window.addEventListener('aparte-conversation-select', (e) => setConversationId(e.detail.id)) — so that a later null is a change the chat sees.

ts
const list = document.querySelector('aparte-conversation-list');

function render(): void {
  if (!list) return;
  list.conversations = manager.activeConversations;
  list.setAttribute('active-id', manager.activeId ?? '');
}
manager.subscribe(render);
render();
list?.setAttribute('manage', '');

<aparte-conversation-list> draws the rows, grouped by date — flat lists them without the groups — each with a menu to rename, pin, archive or delete it. With manage, the menu acts on the manager you registered. A click on a row sends aparte-conversation-select, which opens it in the chats bound to the manager.

Without manage, the list only announces each gesture, and you carry it out:

ts
list?.addEventListener('aparte-conversation-rename', (e) => manager.updateTitle(e.detail.id, e.detail.title));
list?.addEventListener('aparte-conversation-delete', (e) => manager.delete(e.detail.id));

The others are -pin, -unpin, -archive and -unarchive, each with { id }. Under manage they still come first and can be cancelled, which takes back that one gesture.

Open
The list while the store answers.
ts
import type { AparteConversation, AparteConversationMeta } from '@aparte/core';

interface SplitLoading {
  loadMeta(): Promise<AparteConversationMeta[]>;              // the rows, without their messages
  loadFull(id: string): Promise<AparteConversation | null>;   // one conversation, or null if unknown
}

Give the adapter these two methods, and the manager lists the conversations through the first and fetches a conversation’s messages through the second the first time it opens. Meanwhile the chat draws two placeholder turns, tells a screen reader it is loading, and disables the composer until the messages arrive. Set loading on the list while init() runs, and clear it when you set conversations.

The versions of every answer are saved with the conversation, as its tree, when the binding has exportTree and importTree — the framework packages always do — and opening it brings every version back, picker included. To keep a single conversation yourself, without a manager:

ts
import type { AparteChatViewport } from '@aparte/core';

declare const viewport: AparteChatViewport;

localStorage.setItem('chat', JSON.stringify(viewport.exportTree()));   // every version, as data

and later viewport.importTree(JSON.parse(localStorage.getItem('chat') ?? 'null')), once you have checked there is something to restore.

A conversation’s title comes from its first message, and autoTitle says the manager chose it: editing that message then gives it a new one, while a title typed in later — updateTitle — is never replaced. Conversation titles makes them short and meaningful.

On a saved messageAfter a reload
your fields: id, content, segments and their metaas you saved them
messageId and index on a segmentworked out again from the list
timingsas saved, and never invented when absent
a reply that was streamingfinished, isStreaming: false
a tool call awaiting-approval, or pending in a finished replyaborted, unless it carries a server loop’s deferred.ticket
the parts core read from a Markdown replynot saved: read again from its content

Attachments do not survive the adapter above: JSON.stringify drops each attachment’s blob, and its url dies with the page. Save each blob where binary data fits, such as IndexedDB, as an AparteAttachmentRow with its conversation and message ids, and when you load a conversation give each attachment a new URL.createObjectURL(row.blob) (Attachments).