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
19 changes: 6 additions & 13 deletions apps/desktop/src/app/contrib/wiring.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { latestSessionTodos } from '@/lib/todos'
import { setCronFocusJobId } from '@/store/cron'
import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout'
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
import { $activeGatewayProfile, $freshSessionRequest, $profileScope, refreshActiveProfile } from '@/store/profile'
import { $activeGatewayProfile, $profileScope, refreshActiveProfile } from '@/store/profile'
import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '@/store/projects'
import {
$activeSessionId,
Expand Down Expand Up @@ -65,6 +65,7 @@ import { useComposerActions } from '../chat/hooks/use-composer-actions'
import { CommandPalette } from '../command-palette'
import { useGatewayBoot } from '../gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from '../gateway/hooks/use-gateway-request'
import { useFreshSessionRequests } from '../hooks/use-fresh-session-requests'
import { useKeybinds } from '../hooks/use-keybinds'
import { ModelPickerOverlay } from '../model-picker-overlay'
import { ModelVisibilityOverlay } from '../model-visibility-overlay'
Expand Down Expand Up @@ -407,18 +408,10 @@ export function ContribWiring({ children }: { children: ReactNode }) {
})

// A profile switch/create drops to a fresh new-session draft so the
// previously open session doesn't bleed across contexts. Skip initial value.
const freshSessionRequest = useStore($freshSessionRequest)
const lastFreshRef = useRef(freshSessionRequest)

useEffect(() => {
if (freshSessionRequest === lastFreshRef.current) {
return
}

lastFreshRef.current = freshSessionRequest
startFreshSessionDraft()
}, [freshSessionRequest, startFreshSessionDraft])
// previously open session doesn't bleed across contexts. This listener is
// deliberately synchronous: selectProfile requests the reset immediately
// before it can repoint the active gateway to another profile.
useFreshSessionRequests(startFreshSessionDraft)

// Swapping the live gateway to another profile must re-pull that profile's
// global model + active-profile pill (both are nanostores — the blanket
Expand Down
37 changes: 37 additions & 0 deletions apps/desktop/src/app/hooks/use-fresh-session-requests.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'

import { $freshSessionRequest, requestFreshSession } from '@/store/profile'

import { useFreshSessionRequests } from './use-fresh-session-requests'

function Harness({ onRequest }: { onRequest: () => void }) {
useFreshSessionRequests(onRequest)

return null
}

describe('useFreshSessionRequests', () => {
afterEach(() => {
cleanup()
$freshSessionRequest.set(0)
vi.restoreAllMocks()
})

it('clears the foreground synchronously before profile activation can continue', () => {
const order: string[] = []
const onRequest = vi.fn(() => order.push('foreground-cleared'))

render(<Harness onRequest={onRequest} />)

expect(onRequest).not.toHaveBeenCalled()

act(() => {
requestFreshSession()
order.push('gateway-activation')
})

expect(onRequest).toHaveBeenCalledTimes(1)
expect(order).toEqual(['foreground-cleared', 'gateway-activation'])
})
})
20 changes: 20 additions & 0 deletions apps/desktop/src/app/hooks/use-fresh-session-requests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useEffect, useRef } from 'react'

import { $freshSessionRequest } from '@/store/profile'

/**
* Deliver fresh-session requests synchronously once the listener is mounted.
*
* Profile selection bumps the store immediately before it repoints the active
* gateway. A reactive render/effect bridge is too late here: the new gateway
* can become active while the foreground refs still identify the old profile's
* session. Nanostores listeners run inside the originating `.set`, so the
* foreground teardown completes before gateway activation can continue.
*/
export function useFreshSessionRequests(onRequest: () => void): void {
const onRequestRef = useRef(onRequest)

onRequestRef.current = onRequest

useEffect(() => $freshSessionRequest.listen(() => onRequestRef.current()), [])
}
Original file line number Diff line number Diff line change
Expand Up @@ -1193,6 +1193,52 @@ describe('usePromptActions sleep/wake session recovery', () => {
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' })
})

it('does not resume the old session when a profile switch finishes during prompt.submit', async () => {
const calls: string[] = []
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: STORED_SESSION_ID }
let routeToken = 'profile-a/session-a'
let rejectSubmit: (error: Error) => void = () => undefined

const requestGateway = vi.fn(async (method: string) => {
calls.push(method)

if (method === 'prompt.submit') {
return await new Promise<never>((_resolve, reject) => {
rejectSubmit = reject
})
}

if (method === 'session.resume') {
return { session_id: RECOVERED_SESSION_ID } as never
}

return {} as never
})

let handle: HarnessHandle | null = null
await actRender(
<Harness
getRouteToken={() => routeToken}
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
storedSessionId={STORED_SESSION_ID}
/>
)

const submitting = handle!.submitText('message already leaving profile A')
await waitFor(() => expect(calls).toEqual(['prompt.submit']))

// Mirrors startFreshSessionDraft's synchronous identity teardown.
selectedStoredSessionIdRef.current = null
routeToken = 'profile-b/new'
rejectSubmit(new Error('4007 session not found'))

expect(await submitting).toBe(false)
expect(calls).toEqual(['prompt.submit'])
})

it('background queue resume uses the queued stored id and leaves foreground runtime selected', async () => {
const calls: { method: string; params?: Record<string, unknown> }[] = []
let submitAttempts = 0
Expand Down
28 changes: 24 additions & 4 deletions apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,13 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
requestGateway('prompt.submit', { session_id: sessionId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
)
} catch (firstErr) {
// A profile/session switch can finish while prompt.submit is in
// flight. Its 4007 belongs to the abandoned context; never try to
// resume that old durable id through the newly active gateway.
if (sessionContextDrifted()) {
return abortForSessionSwitch(sessionId)
}

const recoverStoredSessionId = targetStoredSessionId ?? selectedStoredSessionIdRef.current

if ((isSessionNotFoundError(firstErr) || isGatewayTimeoutError(firstErr)) && recoverStoredSessionId) {
Expand All @@ -468,10 +475,23 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
// backend loop (#55578 symptom d) rejects the submit even though
// the stored session is fine — resume + retry instead of erroring
// out and losing the session binding.
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: recoverStoredSessionId,
source: 'desktop'
})
let resumed: { session_id: string } | undefined

try {
resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: recoverStoredSessionId,
source: 'desktop'
})
} catch (resumeErr) {
// The switch may have happened after the pre-resume guard while
// this RPC was awaiting its response. Treat that as a silent
// cancellation; otherwise preserve the original error behavior.
if (sessionContextDrifted()) {
return abortForSessionSwitch(sessionId)
}

throw resumeErr
}

if (sessionContextDrifted()) {
return abortForSessionSwitch(sessionId)
Expand Down