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.messages
array per chat. Reruns/regenerations mutate the tail of the list (truncate then stream new assistant message)./api/jobs
streams assistant deltas; the client persists to Dexie.Limitations: no notion of branching. Regenerations overwrite the tail; alternatives cannot coexist.
branchId
. Forking creates a new branch ID and subsequent messages adopt it.parentId
to form a tree per chat. A branch is a named pointer to a leaf message (leafMessageId
). The current path is built by walking parentId
from leaf to root and reversing.Recommendation: Option B (parent‑pointer tree with named branch views).
++id, [chatId+parentId], [chatId+deleted], createdAt, role, content, reasoning, model
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).parentId
is null/undefined for all messages in a chat, assume the legacy linear order by createdAt
and treat each message's parent as the previous message when building views.parentId
cannot equal id
(prevent self-reference)parentId
must exist in same chatId
if not nulldepth
must be parent.depth + 1
(cycle prevention)++id, [chatId+name], leafMessageId, createdAt
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
name
must be unique per chatId
leafMessageId
must exist and belong to same chatId
activeLeafMessageId?: number
— last selected branch tip for this chat.migrationStatus?: 'pending' | 'complete' | 'failed'
— prevents concurrent migrations.migrationVersion?: number
— tracks which migration was applied.branchingEnabled?: boolean
— feature flag per chat for gradual rollout.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.migrationStatus = 'pending'
in any chat. If found, wait or abort to prevent concurrent migrations.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.
migrationStatus: 'failed'
chats.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:
branchingEnabled
for chatactiveLeafMessageId
Regenerate alternative:
parentId
isShared
flag if neededstreamingMessageId
to prevent conflictsSwitch branches:
currentLeafMessageId
and rebuild cached pathactiveLeafMessageId
in DBasync 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 });
}
message.isShared === true
, show "Cannot edit shared message" error.deleted: true
.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()
};
messages.parentId
, rootId
, depth
, turnId
, deleted
, and the branches
table.parentId
is missing, reconstruct the linear chain; branches can be omitted or reconstructed later.parentId
cannot create a loop.rootId
, depth
) are hints. If missing or stale, recompute via parent traversal; never rely solely on them.parentId
for quick children lookups (e.g., to list alternatives under the same parent/user turn).parentId
linearly.sendMessage
/rerun*
to use parentId
when the flag is enabled.parentId
.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:
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 equal id
(self-reference)parentId
must exist in same chatId
if not nullchatId
// Add to chats table
migrationStatus?: 'pending' | 'complete' | 'failed'
migrationVersion?: number
parentId
to previous message.