Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/desktop/src/app/contrib/wiring.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
onFreshDraftRouteIntent: clearRoutedSessionIntent,
requestGateway,
resetViewSync,
routedSessionId,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
selectedStoredSessionIdRef,
Expand Down
144 changes: 144 additions & 0 deletions apps/desktop/src/app/session/hooks/use-route-resume.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,150 @@ describe('useRouteResume', () => {
vi.restoreAllMocks()
})

it('does not resume stale routed session A while the create guard holds selection on B (#66057)', () => {
// createBackendSessionForSend updates refs/atoms to B and navigates, but the
// router can still report A for a tick. While creatingSessionRef is true,
// stuckOnRoutedSession must NOT treat that as "stranded on A" and call
// resumeSession(A) (jump-back bug).
const resumeSession = vi.fn(async () => undefined)
const startFreshSessionDraft = vi.fn()
const activeSessionIdRef: MutableRefObject<null | string> = { current: 'runtime-B' }
const creatingSessionRef = { current: true }
const runtimeIdByStoredSessionIdRef = { current: new Map([['session-B', 'runtime-B']]) }
const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: 'session-B' }

const { rerender } = render(
<RouteResumeHarness
activeSessionId="runtime-A"
activeSessionIdRef={{ current: 'runtime-A' }}
creatingSessionRef={{ current: false }}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-A"
resumeSession={resumeSession}
routedSessionId="session-A"
runtimeIdByStoredSessionIdRef={{ current: new Map([['session-A', 'runtime-A']]) }}
selectedStoredSessionId="session-A"
selectedStoredSessionIdRef={{ current: 'session-A' }}
startFreshSessionDraft={startFreshSessionDraft}
/>
)

expect(resumeSession).not.toHaveBeenCalled()

// Simulate post-create: refs/atoms already on B, route still on A, create
// guard still held until the router catches up.
rerender(
<RouteResumeHarness
activeSessionId="runtime-B"
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-A"
resumeSession={resumeSession}
routedSessionId="session-A"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId="session-B"
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)

expect(resumeSession).not.toHaveBeenCalled()
})

it('holds the create guard until the route catches up to the created session (#66057)', () => {
// While creatingSessionRef is true, even the stale-route + moved-selection
// shape must not resume. (Belt + guard: selectionMovedAheadOfRoute alone
// also blocks; this asserts the creatingSessionRef gate still works.)
const resumeSession = vi.fn(async () => undefined)
const startFreshSessionDraft = vi.fn()
const creatingSessionRef = { current: true }
const activeSessionIdRef: MutableRefObject<null | string> = { current: 'runtime-B' }
const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: 'session-B' }

render(
<RouteResumeHarness
activeSessionId="runtime-B"
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-A"
resumeSession={resumeSession}
routedSessionId="session-A"
runtimeIdByStoredSessionIdRef={{ current: new Map([['session-B', 'runtime-B']]) }}
selectedStoredSessionId="session-B"
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)

expect(resumeSession).not.toHaveBeenCalled()
})

it('recovers by resuming A after create timeout when the route never catches up to B', () => {
// Post-timeout shape: creatingSessionRef false, selection/active on B, route
// still on A. selectionMovedAheadOfRoute must NOT keep blocking once the
// pending-create hold is gone — stuckOnRoutedSession should resume A so
// ChatView leaves its route/selection mismatch loading state.
const resumeSession = vi.fn(async () => undefined)
const startFreshSessionDraft = vi.fn()
const activeSessionIdRef: MutableRefObject<null | string> = { current: 'runtime-A' }
const creatingSessionRef = { current: false }
const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: 'session-A' }

const { rerender } = render(
<RouteResumeHarness
activeSessionId="runtime-A"
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-A"
resumeSession={resumeSession}
routedSessionId="session-A"
runtimeIdByStoredSessionIdRef={{ current: new Map([['session-A', 'runtime-A']]) }}
selectedStoredSessionId="session-A"
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)

expect(resumeSession).not.toHaveBeenCalled()

// Create moved selection/runtime to B; safety timeout already released the
// guard; router never left A.
activeSessionIdRef.current = 'runtime-B'
selectedStoredSessionIdRef.current = 'session-B'
creatingSessionRef.current = false
rerender(
<RouteResumeHarness
activeSessionId="runtime-B"
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-A"
resumeSession={resumeSession}
routedSessionId="session-A"
runtimeIdByStoredSessionIdRef={{ current: new Map([['session-B', 'runtime-B']]) }}
selectedStoredSessionId="session-B"
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)

expect(resumeSession).toHaveBeenCalledTimes(1)
expect(resumeSession).toHaveBeenCalledWith('session-A', true)
})

it('does not re-resume the old session during a /:sid -> /new transition', () => {
const resumeSession = vi.fn(async () => undefined)
const startFreshSessionDraft = vi.fn()
Expand Down
21 changes: 20 additions & 1 deletion apps/desktop/src/app/session/hooks/use-route-resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,26 @@ export function useRouteResume({
// pathname flips to / (same null+/:sid signature). freshDraftReady is the
// discriminator: it's true while heading into a blank new chat, false when
// genuinely stranded on a routed session.
const stuckOnRoutedSession = routedSessionId !== selectedStoredSessionIdRef.current && !freshDraftReady
//
// Also must NOT fire when create/fork already moved selection + runtime to
// a new session B while the router still shows stale A (#66057). That looks
// "stuck on A" but resuming A yanks the UI back off the new chat.
//
// Scope this suppression to an active pending-create hold only. Once
// creatingSessionRef drops (route caught up, user left, or the safety
// timeout), a lingering A-route / B-selection mismatch must be able to
// self-heal via stuckOnRoutedSession — otherwise ChatView stays in its
// route/selection loading state forever after a stuck navigate.
const selectionMovedAheadOfRoute =
creatingSessionRef.current &&
Boolean(selectedStoredSessionIdRef.current) &&
selectedStoredSessionIdRef.current !== routedSessionId &&
Boolean(activeSessionIdRef.current)

const stuckOnRoutedSession =
routedSessionId !== selectedStoredSessionIdRef.current &&
!freshDraftReady &&
!selectionMovedAheadOfRoute

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 3s release does not restore recovery if navigation never reaches B: the route remains A while selected/active refs remain B, so this condition stays true and stuckOnRoutedSession never resumes A. Scope this suppression to pending creation or reconcile state when the timeout fires; add a test for that composed timeout path.

// Resume when the route meaningfully changed, the gateway just opened, or
// we're stranded on a routed session that never loaded. The first two
Expand Down
Loading
Loading