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 c0b0ec63591e3..3a46e8b62d61a 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 @@ -1402,12 +1402,23 @@ describe('branchStoredSession desktop source tagging', () => { vi.restoreAllMocks() }) - it('opens the branch as a new tab and leaves the parent chat selected', async () => { + it('opens the branch as the primary session in the main workspace (#93444)', async () => { const requestGateway = vi.fn(async (method: string) => { if (method === 'session.create') { return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never } + if (method === 'session.resume') { + return { + info: {}, + message_count: 0, + messages: [], + resumed: 'branch-stored', + session_id: 'branch-runtime', + session_key: 'branch-stored' + } as never + } + return {} as never }) @@ -1426,17 +1437,69 @@ describe('branchStoredSession desktop source tagging', () => { navigate={navigate} onReady={branch => (branchStoredSession = branch)} requestGateway={requestGateway} + selectedStoredSessionId="stored-parent" + /> + ) + await waitFor(() => expect(branchStoredSession).not.toBeNull()) + + await expect(branchStoredSession!('stored-parent')).resolves.toBe(true) + + // The branch becomes the primary session — this is what routes the main + // workspace area to it, not just a new sidebar row. + expect($selectedStoredSessionId.get()).toBe('branch-stored') + // It must not ALSO exist as a tile: a session is either the main thread or + // a tile, never both (resumeSession closes any tile with the same id). + expect($sessionTiles.get().some(tile => tile.storedSessionId === 'branch-stored')).toBe(false) + }) + + it('keeps the current view when branching a different session from the sidebar (does not reintroduce #69750)', async () => { + const requestGateway = vi.fn(async (method: string) => { + if (method === 'session.create') { + return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never + } + + if (method === 'session.resume') { + return { + info: {}, + message_count: 0, + messages: [], + resumed: 'branch-stored', + session_id: 'branch-runtime', + session_key: 'branch-stored' + } as never + } + + return {} as never + }) + + // The user is looking at "stored-other" and right-clicks a DIFFERENT, + // unrelated session ("stored-parent") in the sidebar to branch it. + setSessions([storedSession({ id: 'stored-parent', message_count: 1 })]) + setSelectedStoredSessionId('stored-other') + vi.mocked(getAllSessionMessages).mockResolvedValue({ + messages: [{ content: 'branch me', role: 'user', timestamp: 1 }], + session_id: 'stored-parent' + } as never) + + const navigate = vi.fn() + let branchStoredSession: ((storedSessionId: string) => Promise) | null = null + render( + (branchStoredSession = branch)} + requestGateway={requestGateway} + selectedStoredSessionId="stored-other" /> ) await waitFor(() => expect(branchStoredSession).not.toBeNull()) await expect(branchStoredSession!('stored-parent')).resolves.toBe(true) - // The branch opened as its own tab... + // Branching a session that is not the one currently open must not steal + // the user's active view — "stored-other" stays selected. + expect($selectedStoredSessionId.get()).toBe('stored-other') + // The branch instead opens as its own tile. expect($sessionTiles.get().some(tile => tile.storedSessionId === 'branch-stored')).toBe(true) - // ...without stealing the primary selection or navigating away from the parent. - expect($selectedStoredSessionId.get()).toBe('stored-parent') - expect(navigate).not.toHaveBeenCalledWith(sessionRoute('branch-stored')) }) it('tags desktop branch sessions as desktop sessions', async () => { 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 540058dc53773..cf913713f037b 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 @@ -1649,8 +1649,6 @@ export function useSessionActions({ routedSessionId ) - // The branch opens as its own tile in the parent's worktree, not as the - // primary session — keep its runtime out of the main composer atoms. const runtimeInfo = applyRuntimeInfo(branched.info, { foreground: false }) patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd) @@ -1658,13 +1656,20 @@ export function useSessionActions({ updateSessionState(branched.session_id, state => ({ ...state, ...runtimeInfo }), routedSessionId) } - // Open the branch as its own tab and switch to it, leaving the parent - // chat exactly where it is. Prime the tile with the create runtime so it - // skips a redundant resume. Do NOT select it as the primary session - // first — openSessionTile no-ops when the id is already primary. - openSessionTile(routedSessionId, 'center') - patchSessionTile(routedSessionId, { runtimeId: branched.session_id }) - revealTreePane(`session-tile:${routedSessionId}`) + // Only take over the main pane when the chat being branched is the one + // already open there — branching a background/sidebar session must + // not yank the user's current view away from what they're looking at + // (the #69750 focus-stealing bug, reintroduced if this fires + // unconditionally). resumeSession reuses the runtime warm-cached above + // (ensureSessionState/updateSessionState) instead of an extra resume RPC. + if (parentStoredId !== null && selectedStoredSessionIdRef.current === parentStoredId) { + await resumeSession(routedSessionId) + } else { + openSessionTile(routedSessionId, 'center') + patchSessionTile(routedSessionId, { runtimeId: branched.session_id }) + revealTreePane(`session-tile:${routedSessionId}`) + } + broadcastSessionsChanged() return true @@ -1678,7 +1683,15 @@ export function useSessionActions({ }, 0) } }, - [copy, creatingSessionRef, ensureSessionState, requestGateway, updateSessionState] + [ + copy, + creatingSessionRef, + ensureSessionState, + requestGateway, + resumeSession, + selectedStoredSessionIdRef, + updateSessionState + ] ) // Branch the open chat — optionally from a specific message — off its live transcript.