Skip to content

Commit 7ccc28b

Browse files
committed
Add branching pattern
1 parent 100634f commit 7ccc28b

File tree

2 files changed

+280
-0
lines changed

2 files changed

+280
-0
lines changed
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
---
2+
title: "Branching conversations"
3+
sidebarTitle: "Branching conversations"
4+
description: "Build ChatGPT-style conversation trees with edit, regenerate, undo, and branch switching using hydrateMessages, chat.history, and actions."
5+
---
6+
7+
Most chat UIs treat conversations as linear sequences. But real conversations branch — users edit previous messages, regenerate responses, undo exchanges, and explore alternative paths. This pattern shows how to build a branching conversation system using `hydrateMessages`, `chat.history`, and custom actions.
8+
9+
## Data model
10+
11+
The standard approach (used by ChatGPT, Open WebUI, LibreChat, and others) stores messages as a tree with parent pointers:
12+
13+
```ts
14+
// Each message is a node in the tree
15+
type ChatNode = {
16+
id: string;
17+
chatId: string;
18+
parentId: string | null; // null for root
19+
role: "user" | "assistant";
20+
message: UIMessage; // the full AI SDK message
21+
createdAt: Date;
22+
};
23+
```
24+
25+
A conversation is a tree of nodes. The **active branch** is resolved by walking from a leaf node up through `parentId` pointers to the root, then reversing:
26+
27+
```
28+
root
29+
├── user: "Hello"
30+
│ └── assistant: "Hi there!"
31+
│ ├── user: "What's the weather?" ← branch A
32+
│ │ └── assistant: "It's sunny!"
33+
│ └── user: "Tell me a joke" ← branch B (active)
34+
│ └── assistant: "Why did the..."
35+
```
36+
37+
Switching branches means changing which leaf is "active" — the same tree, different path.
38+
39+
## Backend setup
40+
41+
### Store: tree operations
42+
43+
Define helpers that read and write the node tree. Adapt to your database:
44+
45+
```ts
46+
// Resolve the active path: walk from leaf to root, reverse
47+
async function getActiveBranch(chatId: string): Promise<UIMessage[]> {
48+
const nodes = await db.chatNode.findMany({ where: { chatId } });
49+
const byId = new Map(nodes.map((n) => [n.id, n]));
50+
51+
// Find active leaf (most recently created leaf node)
52+
const childIds = new Set(nodes.map((n) => n.parentId).filter(Boolean));
53+
const leaves = nodes.filter((n) => !childIds.has(n.id));
54+
const activeLeaf = leaves.sort((a, b) => b.createdAt - a.createdAt)[0];
55+
if (!activeLeaf) return [];
56+
57+
// Walk to root
58+
const path: UIMessage[] = [];
59+
let current: ChatNode | undefined = activeLeaf;
60+
while (current) {
61+
path.unshift(current.message);
62+
current = current.parentId ? byId.get(current.parentId) : undefined;
63+
}
64+
return path;
65+
}
66+
67+
// Append a message as a child of the current leaf
68+
async function appendMessage(chatId: string, message: UIMessage): Promise<void> {
69+
const branch = await getActiveBranch(chatId);
70+
const parentId = branch.length > 0 ? branch[branch.length - 1]!.id : null;
71+
72+
await db.chatNode.create({
73+
data: { id: message.id, chatId, parentId, role: message.role, message, createdAt: new Date() },
74+
});
75+
}
76+
```
77+
78+
### Agent: hydration + actions
79+
80+
```ts
81+
import { chat } from "@trigger.dev/sdk/ai";
82+
import { streamText } from "ai";
83+
import { openai } from "@ai-sdk/openai";
84+
import { z } from "zod";
85+
86+
export const myChat = chat.agent({
87+
id: "branching-chat",
88+
89+
// Load the active branch from the DB on every turn.
90+
// The frontend's message array is ignored — the tree is the source of truth.
91+
hydrateMessages: async ({ chatId, trigger, incomingMessages }) => {
92+
if (trigger === "submit-message" && incomingMessages.length > 0) {
93+
await appendMessage(chatId, incomingMessages[incomingMessages.length - 1]!);
94+
}
95+
return getActiveBranch(chatId);
96+
},
97+
98+
actionSchema: z.discriminatedUnion("type", [
99+
// Edit a previous user message — creates a sibling node in the tree
100+
z.object({ type: z.literal("edit"), messageId: z.string(), text: z.string() }),
101+
// Switch to a different branch by selecting a leaf node
102+
z.object({ type: z.literal("switch-branch"), leafId: z.string() }),
103+
// Undo the last user + assistant exchange
104+
z.object({ type: z.literal("undo") }),
105+
]),
106+
107+
onAction: async ({ action, chatId }) => {
108+
switch (action.type) {
109+
case "edit": {
110+
// Find the original message's parent, create a sibling with new content
111+
const original = await db.chatNode.findUnique({ where: { id: action.messageId } });
112+
if (!original) break;
113+
114+
const newId = generateId();
115+
await db.chatNode.create({
116+
data: {
117+
id: newId,
118+
chatId,
119+
parentId: original.parentId, // same parent = sibling
120+
role: "user",
121+
message: { id: newId, role: "user", parts: [{ type: "text", text: action.text }] },
122+
createdAt: new Date(),
123+
},
124+
});
125+
// Active branch now resolves through the new sibling (most recent leaf)
126+
break;
127+
}
128+
129+
case "switch-branch": {
130+
// Mark this leaf as the most recently accessed so getActiveBranch picks it
131+
await db.chatNode.update({
132+
where: { id: action.leafId },
133+
data: { createdAt: new Date() },
134+
});
135+
break;
136+
}
137+
138+
case "undo": {
139+
// Remove the last two nodes (user + assistant) from the active branch
140+
const branch = await getActiveBranch(chatId);
141+
if (branch.length >= 2) {
142+
const lastTwo = branch.slice(-2);
143+
await db.chatNode.deleteMany({
144+
where: { id: { in: lastTwo.map((m) => m.id) } },
145+
});
146+
}
147+
break;
148+
}
149+
}
150+
151+
// Reload the (now modified) active branch into the accumulator
152+
const updated = await getActiveBranch(chatId);
153+
chat.history.set(updated);
154+
},
155+
156+
onTurnComplete: async ({ chatId, responseMessage }) => {
157+
// Persist the assistant's response as a new node
158+
if (responseMessage) {
159+
await appendMessage(chatId, responseMessage);
160+
}
161+
},
162+
163+
run: async ({ messages, signal }) => {
164+
return streamText({
165+
model: openai("gpt-4o"),
166+
messages,
167+
abortSignal: signal,
168+
});
169+
},
170+
});
171+
```
172+
173+
## Frontend
174+
175+
### Sending actions
176+
177+
Wire up edit, undo, and branch switching to the transport:
178+
179+
```tsx
180+
function MessageActions({ message, chatId }: { message: UIMessage; chatId: string }) {
181+
const transport = useTransport();
182+
const [editing, setEditing] = useState(false);
183+
const [editText, setEditText] = useState("");
184+
185+
if (message.role !== "user") return null;
186+
187+
return (
188+
<div>
189+
{editing ? (
190+
<form onSubmit={() => {
191+
transport.sendAction(chatId, { type: "edit", messageId: message.id, text: editText });
192+
setEditing(false);
193+
}}>
194+
<input value={editText} onChange={(e) => setEditText(e.target.value)} />
195+
<button type="submit">Save</button>
196+
</form>
197+
) : (
198+
<button onClick={() => { setEditText(getMessageText(message)); setEditing(true); }}>
199+
Edit
200+
</button>
201+
)}
202+
</div>
203+
);
204+
}
205+
```
206+
207+
### Branch navigation
208+
209+
To show the `< 2/3 >` sibling switcher, query the tree for siblings at each fork point. This is a frontend concern — the backend exposes the data, the UI navigates it.
210+
211+
```tsx
212+
function BranchSwitcher({ message, chatId, siblings }: {
213+
message: UIMessage;
214+
chatId: string;
215+
siblings: { id: string; createdAt: string }[];
216+
}) {
217+
const transport = useTransport();
218+
if (siblings.length <= 1) return null;
219+
220+
const currentIndex = siblings.findIndex((s) => s.id === message.id);
221+
222+
return (
223+
<div>
224+
<button
225+
disabled={currentIndex === 0}
226+
onClick={() => {
227+
// Find the leaf of the previous sibling's subtree
228+
transport.sendAction(chatId, {
229+
type: "switch-branch",
230+
leafId: siblings[currentIndex - 1]!.id,
231+
});
232+
}}
233+
>
234+
&lt;
235+
</button>
236+
<span>{currentIndex + 1}/{siblings.length}</span>
237+
<button
238+
disabled={currentIndex === siblings.length - 1}
239+
onClick={() => {
240+
transport.sendAction(chatId, {
241+
type: "switch-branch",
242+
leafId: siblings[currentIndex + 1]!.id,
243+
});
244+
}}
245+
>
246+
&gt;
247+
</button>
248+
</div>
249+
);
250+
}
251+
```
252+
253+
<Note>
254+
The sibling data (which messages share the same parent) needs to come from your database — query it when loading the chat or include it as client data. The agent only returns the active branch via `hydrateMessages`.
255+
</Note>
256+
257+
## How it works
258+
259+
| Operation | What happens |
260+
|-----------|-------------|
261+
| **Send message** | `hydrateMessages` appends the new message as a child of the current leaf, returns the active path |
262+
| **Edit message** | `onAction` creates a sibling node with the same parent. The new node becomes the latest leaf, so `hydrateMessages` resolves through it. LLM responds to the edited history |
263+
| **Regenerate** | Same as edit — create a new assistant sibling. The AI SDK's `regenerate()` handles this via `trigger: "regenerate-message"` |
264+
| **Undo** | `onAction` removes the last two nodes. `chat.history.set()` updates the accumulator. LLM responds to the earlier state |
265+
| **Switch branch** | `onAction` updates which leaf is "active". `hydrateMessages` loads the new path. LLM responds to the switched context |
266+
267+
## Design notes
268+
269+
- **Messages are immutable** — edits create siblings, not mutations. This preserves full history for analytics and auditing.
270+
- **The tree lives in your database** — the agent loads a linear path from it via `hydrateMessages`. The agent itself doesn't know about the tree structure.
271+
- **`hydrateMessages` + `onAction` + `chat.history`** are the three primitives. Hydration loads the active path, actions modify the tree, and `chat.history.set()` syncs the accumulator after tree modifications.
272+
- **Frontend owns navigation** — the `< 2/3 >` UI, sibling queries, and branch switching triggers are client-side concerns. The backend just processes actions and returns responses.
273+
274+
## See also
275+
276+
- [`hydrateMessages`](/ai-chat/backend#hydratemessages) — backend-controlled message history
277+
- [Actions](/ai-chat/backend#actions) — custom actions with `actionSchema` and `onAction`
278+
- [`chat.history`](/ai-chat/backend#chat-history) — imperative history mutations
279+
- [Database persistence](/ai-chat/patterns/database-persistence) — basic persistence pattern (linear)

docs/docs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@
105105
"ai-chat/patterns/sub-agents",
106106
"ai-chat/patterns/version-upgrades",
107107
"ai-chat/patterns/database-persistence",
108+
"ai-chat/patterns/branching-conversations",
108109
"ai-chat/patterns/code-sandbox"
109110
]
110111
},

0 commit comments

Comments
 (0)