From 40a80487c40e7a013b209eecbe39a82a469e6103 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 24 Jul 2026 01:49:12 -0500 Subject: [PATCH] fix(desktop): branch a chat on its parent's owning profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit forkBranch called session.create without a profile, so in app-global remote mode (one backend serving every profile) a branch of a non-default-profile chat silently landed on the launch profile — the "session jumps between profiles after branching" bug. New chats already carry their profile (desktopSessionCreateParams, #39993); branching was the remaining create callsite without it. Thread the parent's owning profile through both branch entry points: branchStoredSession already resolved it (#67603) but dropped it before the create; branchCurrentSession now resolves the open chat's profile via the same cache → active → cross-profile ladder as resume. forkBranch swaps the live gateway onto that profile and passes it on session.create, which also makes the optimistic sidebar row's profile stamp correct. Supersedes #40788 — same invariant, rebuilt for the current forkBranch/ branchStoredSession split. Co-authored-by: Dusk1e --- .../hooks/use-session-actions.test.tsx | 68 ++++++++++++++++++- .../hooks/use-session-actions/index.ts | 27 +++++++- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 7648c01a4037..2472dccb1132 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -1102,8 +1102,12 @@ describe('branchStoredSession desktop source tagging', () => { session_id: 'stored-parent' } as never) - const requestGateway = vi.fn(async (method: string) => { + let createParams: Record | undefined + + const requestGateway = vi.fn(async (method: string, params?: Record) => { if (method === 'session.create') { + createParams = params + return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never } @@ -1120,9 +1124,71 @@ describe('branchStoredSession desktop source tagging', () => { expect(ensureGatewayProfile).toHaveBeenCalledWith('work') expect(getSessionMessages).toHaveBeenCalledWith('stored-parent', 'work') + // The create itself must carry the owning profile: in app-global remote + // mode the soft gateway swap alone is not enough — an omitted profile + // lands the branch on the launch (default) profile's state.db. + expect(createParams).toMatchObject({ parent_session_id: 'stored-parent', profile: 'work' }) vi.mocked(getSession).mockReset() }) + + it('creates the branch on the cached parent session profile', async () => { + setSessions([storedSession({ id: 'stored-parent', message_count: 1, profile: 'work' })]) + vi.mocked(getSessionMessages).mockResolvedValue({ + messages: [{ content: 'branch me', role: 'user', timestamp: 1 }], + session_id: 'stored-parent' + } as never) + + let createParams: Record | undefined + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'session.create') { + createParams = params + + return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never + } + + return {} as never + }) + + let branchStoredSession: ((storedSessionId: string) => Promise) | null = null + render( (branchStoredSession = branch)} requestGateway={requestGateway} />) + await waitFor(() => expect(branchStoredSession).not.toBeNull()) + + await expect(branchStoredSession!('stored-parent')).resolves.toBe(true) + + expect(ensureGatewayProfile).toHaveBeenCalledWith('work') + expect(createParams).toMatchObject({ profile: 'work' }) + }) + + it('omits profile for a profile-less parent so single-profile users are unchanged', async () => { + setSessions([storedSession({ id: 'stored-parent', message_count: 1 })]) + vi.mocked(getSessionMessages).mockResolvedValue({ + messages: [{ content: 'branch me', role: 'user', timestamp: 1 }], + session_id: 'stored-parent' + } as never) + + let createParams: Record | undefined + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'session.create') { + createParams = params + + return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never + } + + return {} as never + }) + + let branchStoredSession: ((storedSessionId: string) => Promise) | null = null + render( (branchStoredSession = branch)} requestGateway={requestGateway} />) + await waitFor(() => expect(branchStoredSession).not.toBeNull()) + + await expect(branchStoredSession!('stored-parent')).resolves.toBe(true) + + expect(createParams).toBeDefined() + expect(createParams).not.toHaveProperty('profile') + }) }) // ── Warm-cache mapping integrity (the "open chat A, chat B loads" bug) ───────── diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index a4ac8ec4ad72..95730277cd3f 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -82,6 +82,7 @@ import { patchSessionWorkspace, preserveLocalPendingTurnMessages, reconcileResumeMessages, + resolveSessionProfile, resolveStoredSession, sessionMatchesStoredId, sessionShouldHaveTranscript, @@ -1056,15 +1057,30 @@ export function useSessionActions({ // `parentStoredId` so it nests under its parent, then open it as its own tab // and switch to it — the parent chat stays put (mirrors openNewSessionTile). const forkBranch = useCallback( - async (branchMessages: BranchMessage[], parentStoredId: null | string, cwd?: string): Promise => { + async ( + branchMessages: BranchMessage[], + parentStoredId: null | string, + cwd?: string, + profile?: null | string + ): Promise => { creatingSessionRef.current = true try { + // A branch belongs to its parent's OWNING profile. Swapping the live + // gateway first AND passing `profile` on the create mirrors + // desktopSessionCreateParams/resumeSession: in app-global remote mode + // one backend serves every profile, so an omitted profile silently + // lands the branch on the launch (default) profile — the "session + // jumps between profiles after branching" bug. The swap also makes + // upsertOptimisticSession's $activeGatewayProfile stamp correct. + await ensureGatewayProfile(profile) + // No title: the backend auto-names the branch from its parent's lineage. const branched = await requestGateway('session.create', { cols: 96, source: 'desktop', ...(cwd && { cwd }), + ...(profile ? { profile } : {}), messages: branchMessages.map(({ content, role }) => ({ content, role })), ...(parentStoredId && { parent_session_id: parentStoredId }) }) @@ -1165,7 +1181,12 @@ export function useSessionActions({ clearNotifications() - return forkBranch(branchMessages, selectedStoredSessionIdRef.current, $currentCwd.get().trim()) + // The open chat's owning profile, NOT the picker's / launch profile — + // /profile only retargets new chats, so a branch of an existing thread + // must stay on that thread's backend (cache hit for an open session). + const profile = await resolveSessionProfile(selectedStoredSessionIdRef.current) + + return forkBranch(branchMessages, selectedStoredSessionIdRef.current, $currentCwd.get().trim(), profile) }, [activeSessionIdRef, busyRef, copy, forkBranch, selectedStoredSessionIdRef] ) @@ -1197,7 +1218,7 @@ export function useSessionActions({ return false } - return await forkBranch(branchMessages, stored?.id ?? storedSessionId, stored?.cwd?.trim()) + return await forkBranch(branchMessages, stored?.id ?? storedSessionId, stored?.cwd?.trim(), profile) } catch (err) { notifyError(err, copy.branchFailed)