Skip to content

Conversations & branching

A conversation in aparté isn’t a flat list — it’s a tree. When you retry an answer, aparté doesn’t overwrite the old one: it adds a sibling branch, so every version is kept and navigable. The active path (root → leaf) is what’s rendered; a built-in ‹ 1/2 › picker moves between siblings.

user: "Explain closures"
├─ assistant: "First answer…" ‹ 1/2 ›
└─ assistant: "Regenerated…" ← active

Editing a user message is different — it updates the message in place and regenerates what follows (it does not keep the old version as a branch). See Editing a user message below.

The built-in retry bubble action emits the public event aparte-retry. Handling it creates a new sibling of the answer — a fresh branch under the same parent, with the old answer kept.

The branch picker and its navigation are built in: as soon as a message has more than one sibling, the bubble renders ‹ 1/2 ›, and its prev/next buttons switch the active branch for you (via aparte-branch-navigate, which the viewport handles). You never wire navigation yourself.

The edit bubble action opens an inline editor in place of the message text. It’s the same input as the composer (<aparte-composer-input>), so it behaves identically — autosize, IME, paste, and the same keys:

  • Enter saves · Shift+Enter inserts a newline · Esc cancels.

Saving emits aparte-edit with { messageId, content, targetId }. Unlike retry, the edit does not branch: the user message’s text is replaced in place and the answer(s) below it are regenerated (the previous response is cleared, not kept as a sibling). With AparteClient this is automatic; to wire it yourself, see the manual way below.

If you drive the chat with AparteClient, retry and edit are handled out of the box: the client listens for aparte-retry / aparte-edit. On retry it creates the sibling branch and re-streams the new answer into it; on edit it updates the user message in place, clears the old answer, and re-streams a fresh one. Nothing to write.

Without the client (e.g. a custom loop), handle aparte-retry yourself. Create the sibling with viewport.addSiblingOf(messageId, newMessage) — it returns the new message’s id — then stream into it:

const viewport = document.querySelector('aparte-chat-viewport'); // or chat.viewport
document.addEventListener('aparte-retry', (e) => {
const id = viewport.addSiblingOf(e.detail.messageId, {
id: crypto.randomUUID(),
role: 'assistant',
content: '',
timestamp: Date.now(),
});
// Stream your model's new answer into the branch:
for await (const token of yourModelStream) viewport.appendToken(id, token);
viewport.completeMessage(id);
});

The new branch becomes active and the ‹ 1/2 › picker appears automatically. The old answer isn’t lost — it’s the other sibling, one click away.

For edit, handle aparte-edit: overwrite the user message, drop its now-stale answer, and stream a fresh one. This mirrors what AparteClient does — an in-place update, not a branch:

document.addEventListener('aparte-edit', (e) => {
const { messageId, content } = e.detail;
viewport.updateMessage(messageId, { content }); // replace the user text in place
viewport.truncateResponsesAfter(messageId); // drop the previous answer(s)
const id = viewport.addSiblingOf(messageId, { // a fresh answer under the edited turn
id: crypto.randomUUID(),
role: 'assistant',
content: '',
timestamp: Date.now(),
});
for await (const token of yourModelStream) viewport.appendToken(id, token);
viewport.completeMessage(id);
});

The whole tree — every branch, not just the active path — round-trips through the viewport as a plain, serializable object. exportTree() just hands you the data; where it lives is up to you — the browser in a front-only app, or your own backend otherwise:

const tree = viewport.exportTree(); // plain object — persist it however you like
// front-only (local-first):
localStorage.setItem('chat', JSON.stringify(tree));
// or with a backend:
await fetch('/api/chats/42', { method: 'PUT', body: JSON.stringify(tree) });
// later, to restore:
viewport.importTree(tree);

For multi-conversation storage (list, switch, delete — against localStorage, IndexedDB, or your API), core also ships a ConversationManager + a storage-adapter contract — a topic of its own.

The ‹ 1/2 › control is a render hook: swap it for your own markup with AparteConfig.setSiblingNavRenderer(({ count, index }) => …). See Customization.


See the generated Elements reference for the exact signatures of addSiblingOf, navigateBranch, exportTree and importTree on <aparte-chat-viewport>.