fix(desktop): branch a chat on its parent's owning profile

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 <yusufalweshdemir@gmail.com>
This commit is contained in:
Brooklyn Nicholson 2026-07-24 01:49:12 -05:00
parent b9d2eb7f43
commit 40a80487c4
2 changed files with 91 additions and 4 deletions

View file

@ -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<string, unknown> | undefined
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
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<string, unknown> | undefined
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
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<boolean>) | null = null
render(<BranchHarness onReady={branch => (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<string, unknown> | undefined
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
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<boolean>) | null = null
render(<BranchHarness onReady={branch => (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) ─────────

View file

@ -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<boolean> => {
async (
branchMessages: BranchMessage[],
parentStoredId: null | string,
cwd?: string,
profile?: null | string
): Promise<boolean> => {
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<SessionCreateResponse>('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)