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.
<!-- It stores nothing: you assign the conversations, it renders the rows and tells you what was picked. -->
<aparte-conversation-list active-id="c1" style="max-width: 20rem"></aparte-conversation-list>
<script>
const day = 864e5;
document.querySelector('aparte-conversation-list').conversations = [
{ id: 'c0', title: 'Release checklist', updatedAt: Date.now() - 3 * day, pinnedAt: Date.now() },
{ id: 'c1', title: 'Deploy checklist', updatedAt: Date.now() },
{ id: 'c2', title: 'Rename the segment types', updatedAt: Date.now() - day },
{ id: 'c3', title: 'Tokens, not selectors', updatedAt: Date.now() - 4 * day },
{ id: 'c4', title: 'The first release', updatedAt: Date.now() - 20 * day },
];
</script>Write a storage adapter
Section titled “Write a storage adapter”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.
Register a manager
Section titled “Register a manager”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.
| Read | Gives |
|---|---|
conversations, activeConversations, archivedConversations | the lists, the last two newest first |
activeId, active | the 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.
| Framework | Helper |
|---|---|
| React | useConversationManager(), a hook |
| Vue | useConversationManager(), a composable |
| Svelte | createConversationManager(), stores |
| Angular | ConversationManagerService, provided in root |
Open a conversation
Section titled “Open a conversation”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);
}const [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>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.
Show the list
Section titled “Show the list”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:
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.
Large histories
Section titled “Large histories”<!-- loading until your store answers: a list that is not there yet is not an empty list. -->
<aparte-conversation-list loading style="max-width: 20rem"></aparte-conversation-list>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.
Save the branches and the title
Section titled “Save the branches and the title”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:
import type { AparteChatViewport } from '@aparte/core';
declare const viewport: AparteChatViewport;
localStorage.setItem('chat', JSON.stringify(viewport.exportTree())); // every version, as dataand 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.
What comes back after a reload
Section titled “What comes back after a reload”| On a saved message | After a reload |
|---|---|
your fields: id, content, segments and their meta | as you saved them |
messageId and index on a segment | worked out again from the list |
| timings | as saved, and never invented when absent |
| a reply that was streaming | finished, isStreaming: false |
a tool call awaiting-approval, or pending in a finished reply | aborted, unless it carries a server loop’s deferred.ticket |
| the parts core read from a Markdown reply | not 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).