- Support multiple branching responses per conversation while remaining backward compatible with the current linear model.
- Minimize disruption to UI logic, Dexie storage, and backend streaming/jobs.
- Avoid data duplication and keep branch operations efficient and safe (low risk of corruption).
- Keep export/import simple and resilient.
- No server-side persistence changes right now (branches live in Dexie, as today).
- No immediate large refactors of streaming/job code; aim for additive changes that do not break current flows.
- Dexie tables:
chats
: root entities for conversations.messages
: linear log of{ id, chatId, createdAt, role, content, reasoning?, model?, promptLabel? }
.folders
: sidebar organization.systemPrompts
: preset library (may link to chats; per‑chat settings override possible).notes
: global notes plugin.
- UI maintains a single linear
messages
array per chat. Reruns/regenerations mutate the tail of the list (truncate then stream new assistant message). - Backend is stateless:
/api/jobs
streams assistant deltas; the client persists to Dexie.
Limitations: no notion of branching. Regenerations overwrite the tail; alternatives cannot coexist.
- Allow multiple alternative assistant replies to a prior point in the conversation (fork at any message).
- Allow users to name/select branches and continue a branch independently.
- Present a linear view of the currently selected path so most UI remains unchanged.
- Backward compatible: existing chats remain usable without any branching changes.
- Keep performance acceptable for long conversations.
- Preserve streaming behavior, message editing, deletion, export/import.
- Messages carry a
branchId
. Forking creates a new branch ID and subsequent messages adopt it. - Pros: simple grouping/querying; easy to filter by branch.
- Cons: shared pre‑branch messages either need duplication (bad) or special handling across branches; renames/moves can get tricky; handling of edits to shared ancestors is complex.
- Each message gets a
parentId
to form a tree per chat. A branch is a named pointer to a leaf message (leafMessageId
). The current path is built by walkingparentId
from leaf to root and reversing. - Pros: no duplication of pre‑branch content; natural for forking/regenerating; easy to compute linear view per selected leaf; migrations are straightforward (set parent to previous message for legacy linear threads).
- Cons: requires path building for display and for request payloads; ancestor edits impact all descendants unless we adopt copy‑on‑write rules.
- Store the whole chat tree as one JSON blob.
- Pros: conceptually unified; single read/write.
- Cons: high risk of corruption, hard to do partial updates/streams, large document rewrites, difficult conflict handling, and complicated migrations. Not recommended.
Recommendation: Option B (parent‑pointer tree with named branch views).
- Store signature with improved indexing:
++id, [chatId+parentId], [chatId+deleted], createdAt, role, content, reasoning, model
- New fields:
parentId: number | null
— points to the immediate previous message on the selected path;null
for root.pathCache?: string
— JSON array of message IDs from root to this message (performance optimization).depth?: number
— 0‑based depth from root for sorting/UX and cycle detection.turnId?: string
— grouping id for user/assistant turn when generating multiple alternatives.deleted?: boolean
— soft‑delete to avoid cascading hard deletes.isShared?: boolean
— cache flag: true if this message has multiple child branches (blocks editing).
- Backward compat: if
parentId
is null/undefined for all messages in a chat, assume the legacy linear order bycreatedAt
and treat each message's parent as the previous message when building views. - Integrity constraints:
parentId
cannot equalid
(prevent self-reference)parentId
must exist in samechatId
if not nulldepth
must beparent.depth + 1
(cycle prevention)
- Purpose: named pointers to leaves in the message tree (for selection/switching UI).
- Store signature:
++id, [chatId+name], leafMessageId, createdAt
- Fields:
chatId: number
leafMessageId: number
— the tip of a branch/path.name: string
— e.g., "main", "alt‑1", or user‑provided name.baseMessageId?: number
— where this branch diverged (informational; helps UI).createdAt: number
updatedAt?: number
archived?: boolean
- Integrity constraints:
name
must be unique perchatId
leafMessageId
must exist and belong to samechatId
- Add
activeLeafMessageId?: number
— last selected branch tip for this chat. - Add
migrationStatus?: 'pending' | 'complete' | 'failed'
— prevents concurrent migrations. - Add
migrationVersion?: number
— tracks which migration was applied. - Add
branchingEnabled?: boolean
— feature flag per chat for gradual rollout. - No other breaking changes to existing fields.
Type sketch (for reference only; not code):
type Message = {
id: number;
chatId: number;
parentId: number | null;
createdAt: number;
role: 'user' | 'assistant' | 'system';
content: string;
reasoning?: string;
model?: string | null;
promptLabel?: string | null;
rootId?: number;
depth?: number;
turnId?: string;
deleted?: boolean;
};
type Branch = {
id: number;
chatId: number;
leafMessageId: number;
name: string;
baseMessageId?: number;
parentBranchId?: number | null;
createdAt: number;
updatedAt?: number;
archived?: boolean;
};
Indexing guidance:
messages
:chatId
,parentId
,createdAt
indices support path building and children lookups.branches
:chatId
index for listing branches per chat.
- Lock Migration: Check for
migrationStatus = 'pending'
in any chat. If found, wait or abort to prevent concurrent migrations. - Backup Validation: Ensure export functionality works before starting migration.
-
Schema Update: Add Dexie version 11 with new fields. Use Dexie transactions for all operations.
-
Chat-by-Chat Migration (atomic per chat):
await db.transaction('rw', db.chats, db.messages, async () => { // Mark migration start await db.chats.update(chatId, { migrationStatus: 'pending', migrationVersion: 11 }); // Load messages in createdAt order const messages = await db.messages.where('chatId').equals(chatId).sortBy('createdAt'); // Set parentId chain for (let i = 0; i < messages.length; i++) { const parentId = i === 0 ? null : messages[i-1].id; const depth = i; const pathCache = JSON.stringify(messages.slice(0, i+1).map(m => m.id)); await db.messages.update(messages[i].id, { parentId, depth, pathCache, isShared: false // will be computed later }); } // Mark migration complete await db.chats.update(chatId, { migrationStatus: 'complete' }); }); -
Compute Shared Flags: After all chats migrated, compute
isShared
flags by counting children. -
Validation: Verify each chat's message chain is valid (no cycles, proper depths).
-
Rollback on Failure: If any step fails, mark
migrationStatus: 'failed'
and provide recovery tools.
- Failed Migration Detection: On app start, check for
migrationStatus: 'failed'
chats. - Automatic Retry: Attempt re-migration with exponential backoff.
- Manual Recovery: Provide UI to export failed chat data and reimport after manual fix.
currentLeafMessageId: number | null
— determines which path is shown.branchList: Branch[]
— list of named branch tips for the current chat.streamingMessageId: number | null
— ID of message currently being streamed to (prevents conflicts).cachedMessagePath: Message[]
— cached linear view to avoid recomputation.branchingEnabled: boolean
— per-chat feature flag.
// Use pathCache when available, fall back to traversal
function buildMessagePath(leafMessageId: number): Message[] {
const leaf = messages.find(m => m.id === leafMessageId);
if (!leaf) return [];
// Try cached path first
if (leaf.pathCache) {
try {
const ids = JSON.parse(leaf.pathCache);
const path = ids.map(id => messages.find(m => m.id === id)).filter(Boolean);
if (path.length === ids.length) return path; // cache hit
} catch {}
}
// Fallback: traverse parents (with cycle detection)
const path = [];
let current = leaf;
const visited = new Set();
while (current && !visited.has(current.id)) {
visited.add(current.id);
path.unshift(current);
current = current.parentId ? messages.find(m => m.id === current.parentId) : null;
}
return path;
}
-
Fork at message:
- Check if
branchingEnabled
for chat - Validate that base message exists and user has permission
- Generate unique branch name (check for conflicts)
- Use transaction to create branch + update
activeLeafMessageId
- Check if
-
Regenerate alternative:
- Check if parent message is not currently being streamed to
- Create new assistant message with proper
parentId
- Update parent's
isShared
flag if needed - Set
streamingMessageId
to prevent conflicts
-
Switch branches:
- Cancel any active streaming to old branch
- Update
currentLeafMessageId
and rebuild cached path - Update chat's
activeLeafMessageId
in DB
async function deleteBranch(branchId: number) {
const branch = await db.branches.get(branchId);
if (!branch) return;
// Check for orphaned messages
const wouldOrphan = await checkForOrphans(branch.leafMessageId);
if (wouldOrphan.length > 0) {
// Option 1: Block deletion
throw new Error(`Cannot delete branch: ${wouldOrphan.length} messages would become orphaned`);
// Option 2: Auto-create recovery branch
// await createRecoveryBranch(wouldOrphan);
}
await db.branches.update(branchId, { archived: true });
}
- Block editing shared ancestors: If
message.isShared === true
, show "Cannot edit shared message" error. - Soft delete only: Never hard-delete messages, only set
deleted: true
. - Validate permissions: Check if user can edit (not shared, not currently streaming).
- Preserve integrity: After any edit, recompute
isShared
flags for affected messages.
// Before starting stream
if (this.streamingMessageId && this.streamingMessageId !== assistantMessageId) {
throw new Error('Another message is currently being generated');
}
this.streamingMessageId = assistantMessageId;
// In applyJobDelta
if (ctx.assistantMessageId !== this.streamingMessageId) {
console.warn('Stream target mismatch, ignoring delta');
return; // Ignore stale deltas
}
// After stream completion
this.streamingMessageId = null;
// In jobs.js - before creating assistant placeholder
const payload = {
// ... existing fields
expectedLeafMessageId: this.currentLeafMessageId, // Add validation context
branchingEnabled: this.branchingEnabled
};
// Client validates before starting job
if (this.streamingMessageId) {
throw new Error('Another message is being generated');
}
// Build messages from current branch path only
const messages = buildMessagePath(this.currentLeafMessageId);
const chatMessages = messages.map(m => ({ role: m.role, content: m.content }));
// Add system prompt at the beginning (not part of tree structure)
const systemPrompt = getCurrentSystemPrompt();
const finalMessages = systemPrompt
? [{ role: 'system', content: systemPrompt }, ...chatMessages]
: chatMessages;
// In applyJobDelta - add safety checks
this.applyJobDelta = async (jobId, parsed) => {
const ctx = (this._jobCtx || {})[jobId];
if (!ctx) return;
// Validate we're still on the expected branch
if (this.currentLeafMessageId !== ctx.expectedLeafMessageId) {
console.warn('Branch switched during streaming, cancelling job');
this.jobManager.cancelJob(jobId);
return;
}
// Validate the target message still exists and is streamable
const targetMessage = await this.db.messages.get(ctx.assistantMessageId);
if (!targetMessage || targetMessage.deleted) {
console.warn('Target message deleted during stream');
return;
}
// Apply delta as before...
const choice = Array.isArray(parsed.choices) ? parsed.choices[0] : null;
const delta = choice?.delta || {};
if (delta.content && ctx.chatId === this.currentChatId) {
// ... existing content application logic
}
};
// Enhanced job context for safety
this._jobCtx[jobId] = {
chatId: this.currentChatId,
assistantMessageId,
assistantCreatedAt,
expectedLeafMessageId: this.currentLeafMessageId, // Track expected branch
branchPath: this.cachedMessagePath.map(m => m.id), // Snapshot for validation
startTime: Date.now()
};
- Extend export to include:
messages.parentId
,rootId
,depth
,turnId
,deleted
, and thebranches
table. - On import, if
parentId
is missing, reconstruct the linear chain; branches can be omitted or reconstructed later.
- Prevent cycles: enforce at write time that
parentId
cannot create a loop. - Copy‑on‑write for ancestor edits to avoid cross‑branch mutations.
- Deletion policy: prefer soft‑delete; if hard delete is requested on a node with children, either block or cascade with explicit user confirmation.
- Multi‑tab concurrency: use Dexie transactions for branch creation and for streaming writes to avoid races (especially when concurrently generating alternatives).
- Depth/cache fields (
rootId
,depth
) are hints. If missing or stale, recompute via parent traversal; never rely solely on them.
- Path building is O(depth). Typical chats are shallow; acceptable. Cache the computed path in memory and update incrementally as streaming appends new nodes.
- Index by
parentId
for quick children lookups (e.g., to list alternatives under the same parent/user turn). - Avoid duplicating shared ancestors; the parent‑pointer design keeps storage small and writes minimal.
- Ship Dexie v11 schema (no UI behavior change yet). Migrate
parentId
linearly. - Add hidden path builder and ensure existing flows still work when linear.
- Introduce branch creation/switching UI behind a feature flag.
- Update
sendMessage
/rerun*
to useparentId
when the flag is enabled. - Add branch management UI (rename, delete/archive) and export/import extensions.
- Remove feature flag after validation.
- Migration:
- Old data loads; linear view identical to pre‑migration.
- Export/import round‑trip with and without
parentId
.
- Branching:
- Fork at any message; both branches are navigable.
- Regenerate alternative responses; multiple assistant siblings appear under a user message.
- Streaming writes go to the correct placeholder.
- Deleting/Editing ancestors behaves per policy (copy‑on‑write or blocked).
- Performance:
- Large chats path compute remains responsive.
- No race conditions under concurrent streams in separate branches.
- Not recommended. While it simplifies the conceptual model, it complicates partial updates, streaming, and recovery. It also increases the blast radius of corruption and makes concurrency and diffing difficult. The parent‑pointer design offers a good balance of simplicity, safety, and flexibility while keeping backward compatibility.
Problem: If a user deletes a branch that was the only path to certain messages, those messages become orphaned and unreachable. Fix: Before deleting a branch, check if any messages would become orphaned. Either:
- Block deletion if orphans would be created
- Auto-create a new branch pointing to orphaned subtrees
- Add a "Find Orphaned Messages" recovery tool
Problem: If multiple tabs are open during v10→v11 migration, they could interfere with each other. Fix: Use Dexie transaction locks and add a migration status flag to prevent concurrent migrations.
Problem: If user switches branches while assistant is streaming, deltas could write to the wrong message.
Fix: Include expectedLeafMessageId
in job context and validate on each delta. Cancel stream if mismatch detected.
Problem: Editing shared ancestors with copy-on-write creates complex reattachment logic that could break parent chains. Fix: Initially, block editing of shared ancestors entirely. Add copy-on-write later as a separate feature with extensive testing.
Problem: Path building becomes O(depth) which could be slow for very long conversations.
Fix: Add pathCache
field to store pre-computed paths, invalidated on parent changes.
Problem: User could create multiple branches with the same name. Fix: Enforce unique branch names per chat in Dexie schema constraints.
Problem: If creating a branch partially fails (branch created but message creation fails), state becomes inconsistent. Fix: Use Dexie transactions for all branch operations to ensure atomicity.
Problem: Current index plan may not cover all query patterns efficiently.
Fix: Add compound index [chatId, parentId]
and [chatId, deleted]
for faster queries.
Problem: Plan mentions preventing cycles but doesn't specify how. Fix: Implement depth-limited parent traversal during writes to detect cycles.
Problem: System messages (like preset instructions) need special handling in branched conversations. Fix: Always place system messages at the absolute root with no parent, before any user messages.
++id, [chatId+parentId], [chatId+deleted], createdAt, role, content, reasoning, model
parentId
cannot equalid
(self-reference)parentId
must exist in samechatId
if not null- Branch names must be unique per
chatId
// Add to chats table
migrationStatus?: 'pending' | 'complete' | 'failed'
migrationVersion?: number
- Adopt a parent‑pointer message tree per chat and represent branches as named pointers to leaf messages.
- Migrate legacy linear chats by assigning
parentId
to previous message. - Keep backend/jobs unchanged; client builds the path for requests and display.
- Provide strong integrity and UX policies for editing/deleting ancestors and for managing branches.
- Address critical streaming, orphaning, and migration safety issues before implementation.