Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 68 additions & 5 deletions apps/desktop/src/app/session/hooks/use-session-actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
})

Expand All @@ -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<boolean>) | null = null
render(
<BranchHarness
navigate={navigate}
onReady={branch => (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 () => {
Expand Down
33 changes: 23 additions & 10 deletions apps/desktop/src/app/session/hooks/use-session-actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1649,22 +1649,27 @@ 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)

if (runtimeInfo) {
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
Expand All @@ -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.
Expand Down
Loading