feat: add backend session branching support

Add the backend contract and session-domain support for
'Branch from here':
- new branchSession IPC method and sessions:branch channel
- branchOrigin metadata on SessionRecord
- session branching helper that truncates the transcript at a
  chosen user message and clears runtime state
- AryxAppService branching flow with scratchpad directory support
- persistence normalization and regression coverage

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-29 18:24:05 +02:00
co-authored by Copilot
parent a670817870
commit 7aae1b2cd5
10 changed files with 337 additions and 13 deletions
+18
View File
@@ -62,6 +62,7 @@ import {
} from '@shared/domain/approval';
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
import {
branchSessionRecord,
duplicateSessionRecord,
querySessions as queryWorkspaceSessions,
renameSessionRecord,
@@ -703,6 +704,23 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async branchSession(sessionId: string, messageId: string): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId);
const pattern = this.requirePattern(workspace, session.patternId);
const branch = branchSessionRecord(session, pattern, createId('session'), messageId, nowIso());
if (isScratchpadProject(branch.projectId)) {
branch.cwd = undefined;
}
await this.ensureScratchpadSessionDirectory(branch);
workspace.sessions.unshift(branch);
workspace.selectedProjectId = branch.projectId;
workspace.selectedPatternId = branch.patternId;
workspace.selectedSessionId = branch.id;
return this.persistAndBroadcast(workspace);
}
async renameSession(sessionId: string, title: string): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId);
+4
View File
@@ -3,6 +3,7 @@ import type { BrowserWindow } from 'electron';
import { ipcChannels } from '@shared/contracts/channels';
import type {
BranchSessionInput,
CancelSessionTurnInput,
CreateSessionInput,
DismissSessionMcpAuthInput,
@@ -135,6 +136,9 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi
ipcMain.handle(ipcChannels.duplicateSession, (_event, input: DuplicateSessionInput) =>
service.duplicateSession(input.sessionId),
);
ipcMain.handle(ipcChannels.branchSession, (_event, input: BranchSessionInput) =>
service.branchSession(input.sessionId, input.messageId),
);
ipcMain.handle(ipcChannels.renameSession, (_event, input: RenameSessionInput) =>
service.renameSession(input.sessionId, input.title),
);
+2 -1
View File
@@ -6,7 +6,7 @@ import { isScratchpadProject, mergeScratchpadProject } from '@shared/domain/proj
import { normalizeDiscoveredToolingState } from '@shared/domain/discoveredTooling';
import { normalizeProjectCustomizationState } from '@shared/domain/projectCustomization';
import { normalizeSessionRunRecords } from '@shared/domain/runTimeline';
import type { SessionRecord } from '@shared/domain/session';
import { normalizeSessionBranchOrigin, type SessionRecord } from '@shared/domain/session';
import {
normalizeSessionToolingSelection,
normalizeWorkspaceSettings,
@@ -81,6 +81,7 @@ export class WorkspaceRepository {
const sessions = await Promise.all((stored.sessions ?? []).map(async (session): Promise<SessionRecord> => {
const normalizedSession: SessionRecord = {
...session,
branchOrigin: normalizeSessionBranchOrigin(session.branchOrigin),
runs: normalizeSessionRunRecords(session.runs),
tooling: normalizeSessionToolingSelection(session.tooling),
approvalSettings: normalizeSessionApprovalSettings(session.approvalSettings),
+1
View File
@@ -47,6 +47,7 @@ const api: ElectronApi = {
ipcRenderer.invoke(ipcChannels.updateSessionApprovalSettings, input),
createSession: (input) => ipcRenderer.invoke(ipcChannels.createSession, input),
duplicateSession: (input) => ipcRenderer.invoke(ipcChannels.duplicateSession, input),
branchSession: (input) => ipcRenderer.invoke(ipcChannels.branchSession, input),
renameSession: (input) => ipcRenderer.invoke(ipcChannels.renameSession, input),
setSessionPinned: (input) => ipcRenderer.invoke(ipcChannels.setSessionPinned, input),
setSessionArchived: (input) => ipcRenderer.invoke(ipcChannels.setSessionArchived, input),
+1
View File
@@ -31,6 +31,7 @@ export const ipcChannels = {
updateSessionApprovalSettings: 'sessions:update-approval-settings',
createSession: 'sessions:create',
duplicateSession: 'sessions:duplicate',
branchSession: 'sessions:branch',
renameSession: 'sessions:rename',
setSessionPinned: 'sessions:set-pinned',
setSessionArchived: 'sessions:set-archived',
+6
View File
@@ -58,6 +58,11 @@ export interface DuplicateSessionInput {
sessionId: string;
}
export interface BranchSessionInput {
sessionId: string;
messageId: string;
}
export interface RenameSessionInput {
sessionId: string;
title: string;
@@ -174,6 +179,7 @@ export interface ElectronApi {
updateSessionApprovalSettings(input: UpdateSessionApprovalSettingsInput): Promise<WorkspaceState>;
createSession(input: CreateSessionInput): Promise<WorkspaceState>;
duplicateSession(input: DuplicateSessionInput): Promise<WorkspaceState>;
branchSession(input: BranchSessionInput): Promise<WorkspaceState>;
renameSession(input: RenameSessionInput): Promise<WorkspaceState>;
setSessionPinned(input: SetSessionPinnedInput): Promise<WorkspaceState>;
setSessionArchived(input: SetSessionArchivedInput): Promise<WorkspaceState>;
+40
View File
@@ -36,6 +36,13 @@ export interface ChatMessageRecord {
attachments?: ChatMessageAttachment[];
}
export interface SessionBranchOrigin {
sourceSessionId: string;
sourceMessageId: string;
sourceMessageIndex: number;
branchedAt: string;
}
export interface SessionRecord {
id: string;
projectId: string;
@@ -47,6 +54,7 @@ export interface SessionRecord {
status: SessionStatus;
isPinned?: boolean;
isArchived?: boolean;
branchOrigin?: SessionBranchOrigin;
interactionMode?: InteractionMode;
cwd?: string;
messages: ChatMessageRecord[];
@@ -62,6 +70,38 @@ export interface SessionRecord {
runs: SessionRunRecord[];
}
function normalizeOptionalString(value?: string): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
export function normalizeSessionBranchOrigin(
branchOrigin?: Partial<SessionBranchOrigin>,
): SessionBranchOrigin | undefined {
const sourceSessionId = normalizeOptionalString(branchOrigin?.sourceSessionId);
const sourceMessageId = normalizeOptionalString(branchOrigin?.sourceMessageId);
const branchedAt = normalizeOptionalString(branchOrigin?.branchedAt);
const sourceMessageIndex = branchOrigin?.sourceMessageIndex;
if (
!sourceSessionId
|| !sourceMessageId
|| !branchedAt
|| typeof sourceMessageIndex !== 'number'
|| !Number.isInteger(sourceMessageIndex)
|| sourceMessageIndex < 0
) {
return undefined;
}
return {
sourceSessionId,
sourceMessageId,
sourceMessageIndex,
branchedAt,
};
}
export function resolveSessionTitle(
session: Pick<SessionRecord, 'title' | 'titleSource'>,
pattern: PatternDefinition,
+71 -11
View File
@@ -1,6 +1,6 @@
import type { PatternDefinition } from '@shared/domain/pattern';
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
import type { ChatMessageRecord, SessionRecord, SessionStatus } from '@shared/domain/session';
import { resolveSessionTitle, type ChatMessageRecord, type SessionBranchOrigin, type SessionRecord, type SessionStatus } from '@shared/domain/session';
import type { WorkspaceState } from '@shared/domain/workspace';
export type SessionQueryMatchField = 'title' | 'message' | 'project' | 'pattern';
@@ -158,21 +158,32 @@ export function renameSessionRecord(session: SessionRecord, title: string, updat
};
}
export function duplicateSessionRecord(
function cloneBranchOrigin(branchOrigin?: SessionBranchOrigin): SessionBranchOrigin | undefined {
return branchOrigin ? { ...branchOrigin } : undefined;
}
function cloneChatMessageRecord(message: ChatMessageRecord): ChatMessageRecord {
return {
...message,
pending: false,
attachments: message.attachments?.map((attachment) => ({ ...attachment })),
};
}
function createDerivedSessionRecord(
session: SessionRecord,
sessionId: string,
duplicatedAt: string,
createdAt: string,
): SessionRecord {
return {
...session,
id: sessionId,
title: `${session.title} (Copy)`,
titleSource: 'manual',
createdAt: duplicatedAt,
updatedAt: duplicatedAt,
createdAt,
updatedAt: createdAt,
status: 'idle',
isPinned: false,
isArchived: false,
branchOrigin: cloneBranchOrigin(session.branchOrigin),
lastError: undefined,
sessionModelConfig: session.sessionModelConfig ? { ...session.sessionModelConfig } : undefined,
tooling: session.tooling
@@ -188,11 +199,60 @@ export function duplicateSessionRecord(
: undefined,
pendingApproval: undefined,
pendingApprovalQueue: undefined,
pendingUserInput: undefined,
pendingPlanReview: undefined,
pendingMcpAuth: undefined,
runs: [],
messages: session.messages.map((message): ChatMessageRecord => ({
...message,
pending: false,
})),
messages: [],
};
}
export function duplicateSessionRecord(
session: SessionRecord,
sessionId: string,
duplicatedAt: string,
): SessionRecord {
return {
...createDerivedSessionRecord(session, sessionId, duplicatedAt),
title: `${session.title} (Copy)`,
titleSource: 'manual',
messages: session.messages.map(cloneChatMessageRecord),
};
}
export function branchSessionRecord(
session: SessionRecord,
pattern: PatternDefinition,
sessionId: string,
messageId: string,
branchedAt: string,
): SessionRecord {
const sourceMessageIndex = session.messages.findIndex((message) => message.id === messageId);
if (sourceMessageIndex < 0) {
throw new Error(`Message ${messageId} not found in session ${session.id}.`);
}
const sourceMessage = session.messages[sourceMessageIndex];
if (!sourceMessage) {
throw new Error(`Message ${messageId} not found in session ${session.id}.`);
}
if (sourceMessage.role !== 'user') {
throw new Error('Only user messages can be used as a branch point.');
}
const branchedMessages = session.messages.slice(0, sourceMessageIndex + 1).map(cloneChatMessageRecord);
return {
...createDerivedSessionRecord(session, sessionId, branchedAt),
title: resolveSessionTitle(session, pattern, branchedMessages),
messages: branchedMessages,
branchOrigin: {
sourceSessionId: session.id,
sourceMessageId: messageId,
sourceMessageIndex,
branchedAt,
},
};
}