Edit, regenerate and branch a chat conversation
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…" ← activeEditing 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.
Retry creates branches
Section titled “Retry creates branches”<!-- Retry does not overwrite: it forks a sibling. The picker is what walks them, and it appears on its own as soon as a message has more than one. --><aparte-chat-bubble message-id="a1" data-role="assistant" name="Assistant" content="You could cache the result — it is the same query every time."></aparte-chat-bubble>
<script> // `setSiblings` is a method, not an attribute: the count comes from the tree, // so markup alone can never place this control. document.querySelector('aparte-chat-bubble').setSiblings(2, 0);</script>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.
Editing a user message
Section titled “Editing a user message”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 } — and that is all the
bubble does. The replacement is the handler’s job: the editor closes, and if nobody
writes the new text back, the original text reappears. With
AparteClient it is automatic — the client replaces the message
in place and regenerates the answer(s) below it (the previous response is cleared, not kept
as a sibling). To wire it yourself, see the manual way below.
Unlike retry, the edit does not branch.
The automatic way — AparteClient
Section titled “The automatic way — AparteClient”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.
The manual way
Section titled “The manual way”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.viewportdeclare const yourModelStream: AsyncIterable<string>;
// `async`, because of the `for await` below.document.addEventListener('aparte-retry', async (e) => { const id = viewport.addSiblingOf(e.detail.messageId, { id: crypto.randomUUID(), role: 'assistant', content: '', timestamp: Date.now(), }); if (!id) return;
// 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', async (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(), }); if (!id) return;
for await (const token of yourModelStream) viewport.appendToken(id, token); viewport.completeMessage(id);});Turning it off
Section titled “Turning it off”Both actions are off until you ask for them, so “turning it off” is usually just not opting in. To take one back after the fact:
aparteGlobalConfig.setBubbleActions({ retry: false }); // keep edit, drop retryaparteGlobalConfig.setBubbleActions({ user: ['copy'] }); // user bubbles: copy only, no editorIt applies live — already-rendered bubbles rebuild their action bar — and a bar left with nothing in it is not rendered at all, so no empty row remains.
The branch picker needs no switch: it appears only when a message actually has a
sibling, so a chat that never retries never shows it. If you want it styled away or
replaced, that’s setSiblingNavRenderer.
While a reply streams, the picker’s arrows — and retry and edit on every message — are
disabled: the transcript is read-only except for Stop, because a swap or a retry
mid-stream would change the path under the reply being written. The viewport carries
data-busy while it streams, if you want to reflect the same state in controls of your own.
Persistence
Section titled “Persistence”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 AparteConversationManager + a storage-adapter contract — a
topic of its own.
Customizing the picker
Section titled “Customizing the picker”The ‹ 1 / 2 › control is a render hook: swap it for your own markup with
aparteGlobalConfig.setSiblingNavRenderer(({ count, index }) => …). See
Customization.
See the <aparte-chat-viewport> page for the exact
signatures of addSiblingOf, navigateBranch, exportTree and importTree.