From e86fd4a31d179237f41db4fed1cffbec88a8babf Mon Sep 17 00:00:00 2001 From: hutao562 <24802680+hutao562@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:53:51 +0800 Subject: [PATCH] fix(desktop): route to the branched session so its window renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a bubble's branch action (or "branch in new chat") opens the child session, but its window stayed blank on a spinner until the user switched to another session and back. Root cause: forkBranch takes over the main pane by calling resumeSession(routedSessionId), but resumeSession swaps only the selection — its replaceRoute parameter is accepted and never used, and resumeSession itself never navigates. The URL kept pointing at the PARENT session while the selection pointed at the CHILD, so isRouteSessionMismatch stayed true on every render: ChatRuntimeBoundary suppressed the transcript (suppressMessages) and routedSessionIsLoading never retired. The away-and-back "fix" worked because a sidebar click routes through openSession, which navigates. Fix: navigate to the branch session (replace: true) right after resumeSession in the main-pane takeover path, so route and selection land atomically. The background-branch path is untouched — it opens a tile, which carries no route (and #69750 focus-stealing stays fixed). --- .../hooks/use-session-actions.test.tsx | 49 +++++++++++++++++++ .../hooks/use-session-actions/index.ts | 11 +++++ 2 files changed, 60 insertions(+) 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 0e1fd625ab12..53d0a88fb48c 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 @@ -1612,6 +1612,55 @@ describe('branchStoredSession desktop source tagging', () => { expect($sessionTiles.get().some(tile => tile.storedSessionId === 'branch-stored')).toBe(false) }) + it('routes the main workspace to the branch session (branch window renders without a manual away-and-back)', 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 + }) + + setSessions([storedSession({ id: 'stored-parent', message_count: 1 })]) + setSelectedStoredSessionId('stored-parent') + 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-parent" + /> + ) + await waitFor(() => expect(branchStoredSession).not.toBeNull()) + + await expect(branchStoredSession!('stored-parent')).resolves.toBe(true) + + // resumeSession swaps the selection but never the URL. Without a navigate + // here the route stays on the PARENT while the selection is the CHILD, so + // isRouteSessionMismatch stays true: the transcript is suppressed and the + // routed loader never retires — the branched window stays blank until the + // user switches away and back. The URL must follow the selection. + expect(navigate).toHaveBeenCalledWith(sessionRoute('branch-stored'), { replace: true }) + }) + 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') { 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 0f1ef5a3011e..f1f95ea89f5b 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 @@ -2022,6 +2022,16 @@ export function useSessionActions({ // (ensureSessionState/updateSessionState) instead of an extra resume RPC. if (parentStoredId !== null && selectedStoredSessionIdRef.current === parentStoredId) { await resumeSession(routedSessionId) + // resumeSession swaps the selection but never touches the URL — its + // `replaceRoute` parameter is accepted and ignored. The route still + // points at the PARENT, so isRouteSessionMismatch (route = parent, + // selection = child) stays true: ChatRuntimeBoundary keeps the + // transcript suppressed and the routed loader never retires, so the + // branched window shows an empty spinner until the user switches + // away and back (a sidebar click routes properly via openSession). + // Navigate here, same tick as the selection swap, so route and + // selection land atomically. + navigate(sessionRoute(routedSessionId), { replace: true }) } else { openSessionTile(routedSessionId, 'center') patchSessionTile(routedSessionId, { runtimeId: branched.session_id }) @@ -2045,6 +2055,7 @@ export function useSessionActions({ copy, creatingSessionRef, ensureSessionState, + navigate, requestGateway, resumeSession, selectedStoredSessionIdRef,