|
| 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 | + < |
| 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 | + > |
| 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) |
0 commit comments