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
18 changes: 10 additions & 8 deletions apps/desktop/src/app/desktop-controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
$selectedStoredSessionId,
$sessions,
getRememberedSessionId,
rememberedSessionProfile,
sessionPinId,
setAwaitingResponse,
setBusy,
Expand Down Expand Up @@ -201,6 +202,8 @@ export function DesktopController() {
const previewPaneOpen = useStore($paneOpen(PREVIEW_PANE_ID))
const panesFlipped = useStore($panesFlipped)
const profileScope = useStore($profileScope)
const activeGatewayProfile = useStore($activeGatewayProfile)
const sessions = useStore($sessions)
// Below SIDEBAR_COLLAPSE_BREAKPOINT_PX there's no room for a docked rail —
// collapse both sidebars (without touching their stored open state) so the
// hover-reveal overlay becomes the way in. Restores once it's wide again.
Expand Down Expand Up @@ -272,9 +275,9 @@ export function DesktopController() {
// Remember the open chat so a relaunch reopens it instead of an empty new-chat.
useEffect(() => {
if (routedSessionId) {
setRememberedSessionId(routedSessionId)
setRememberedSessionId(routedSessionId, rememberedSessionProfile(sessions, routedSessionId, activeGatewayProfile))
}
}, [routedSessionId])
}, [activeGatewayProfile, routedSessionId, sessions])

// Restore that chat once, on cold start only (we're at the new-chat route and
// haven't navigated yet). A dead/deleted id self-clears via the exhausted latch
Expand All @@ -286,18 +289,18 @@ export function DesktopController() {
}

restoredLastSessionRef.current = true
const last = getRememberedSessionId()
const last = getRememberedSessionId(activeGatewayProfile)

if (last && location.pathname === NEW_CHAT_ROUTE) {
navigate(sessionRoute(last), { replace: true })
}
}, [location.pathname, navigate])
}, [activeGatewayProfile, location.pathname, navigate])

useEffect(() => {
if (resumeExhaustedSessionId && getRememberedSessionId() === resumeExhaustedSessionId) {
setRememberedSessionId(null)
if (resumeExhaustedSessionId && getRememberedSessionId(activeGatewayProfile) === resumeExhaustedSessionId) {
setRememberedSessionId(null, activeGatewayProfile)
}
}, [resumeExhaustedSessionId])
}, [activeGatewayProfile, resumeExhaustedSessionId])

// Notification click: the main process already focused the window; jump to its
// session. Notifications are tagged with the gateway *runtime* session id, but
Expand Down Expand Up @@ -655,7 +658,6 @@ export function DesktopController() {
// without this the statusbar keeps showing the previous profile's model
// (the "forgets the LLM setting" report). gatewayState stays 'open' across a
// swap (background sockets persist), so the open→open effect won't re-run.
const activeGatewayProfile = useStore($activeGatewayProfile)
const lastGatewayProfileRef = useRef(activeGatewayProfile)

useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1082,6 +1082,8 @@ describe('usePromptActions sleep/wake session recovery', () => {
return {} as never
})

setSessions(() => [sessionInfo({ id: STORED_SESSION_ID, profile: 'default' })])

let handle: HarnessHandle | null = null
render(
<Harness
Expand All @@ -1095,9 +1097,9 @@ describe('usePromptActions sleep/wake session recovery', () => {
const ok = await handle!.submitText('message after wake')

expect(ok).toBe(true)
// First submit (stale id) → session.resume (stored id) → retry submit (fresh id).
// First submit (stale id) → session.resume (stored id + owning profile) → retry submit (fresh id).
expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', profile: 'default' })
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' })
})

Expand Down Expand Up @@ -1125,6 +1127,8 @@ describe('usePromptActions sleep/wake session recovery', () => {
return {} as never
})

setSessions(() => [sessionInfo({ id: STORED_SESSION_ID, profile: 'default' })])

let handle: HarnessHandle | null = null
render(
<Harness
Expand All @@ -1140,7 +1144,7 @@ describe('usePromptActions sleep/wake session recovery', () => {

expect(calls.map(c => c.method)).toEqual(['session.interrupt', 'session.resume', 'session.interrupt'])
expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID })
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', profile: 'default' })
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID })
})

Expand Down Expand Up @@ -1246,7 +1250,7 @@ describe('usePromptActions sleep/wake session recovery', () => {

expect(ok).toBe(true)
expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', profile: 'default' })
expect(calls[2]?.params).toEqual({
session_id: RECOVERED_SESSION_ID,
text: 'message during starved loop'
Expand Down Expand Up @@ -1289,7 +1293,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
expect(ok).toBe(true)
expect(createBackendSessionForSend).not.toHaveBeenCalled()
expect(calls.map(c => c.method)).toEqual(['session.resume', 'prompt.submit'])
expect(calls[0]?.params).toEqual({ session_id: STORED_SESSION_ID })
expect(calls[0]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', profile: 'default' })
expect(calls[1]?.params).toMatchObject({ session_id: RECOVERED_SESSION_ID })
})

Expand Down Expand Up @@ -1383,7 +1387,8 @@ describe('usePromptActions submit session-context isolation (#54527)', () => {
expect(await submitting).toBe(false)
expect(calls.some(c => c.method === 'prompt.submit')).toBe(false)
expect(calls.find(c => c.method === 'session.resume')?.params).toEqual({
session_id: STORED_SESSION_A
session_id: STORED_SESSION_A,
source: 'desktop'
})
})

Expand Down
20 changes: 17 additions & 3 deletions apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { resetSessionBackground } from '@/store/composer-status'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { clearPreviewArtifacts } from '@/store/preview-status'
import { clearAllPrompts } from '@/store/prompts'
import { $busy, $connection, $messages, setAwaitingResponse, setBusy, setMessages } from '@/store/session'
import { $busy, $connection, $messages, $sessions, setAwaitingResponse, setBusy, setMessages } from '@/store/session'
import { clearSessionSubagents } from '@/store/subagents'
import { clearSessionTodos } from '@/store/todos'

Expand Down Expand Up @@ -59,6 +59,17 @@ interface HandoffResult {
error?: string
}

function storedSessionProfile(storedSessionId: string | null): string | null {
if (!storedSessionId) {
return null
}

const stored = $sessions.get().find(session => session.id === storedSessionId || session._lineage_root_id === storedSessionId)
const profile = stored?.profile?.trim()

return profile || null
}

/**
* Stage one file/image attachment into the session workspace and return the
* attachment rewritten with the gateway-side ref. Images upload their bytes in
Expand Down Expand Up @@ -548,9 +559,12 @@ export function usePromptActions({

if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) {
try {
const storedSessionId = selectedStoredSessionIdRef.current
const profile = storedSessionProfile(storedSessionId)
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: selectedStoredSessionIdRef.current,
source: 'desktop'
session_id: storedSessionId,
source: 'desktop',
...(profile ? { profile } : {})
})

const recoveredId = resumed?.session_id
Expand Down
25 changes: 21 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 @@ -13,7 +13,7 @@ import {
} from '@/store/composer'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { setAwaitingResponse, setBusy, setMessages } from '@/store/session'
import { $sessions, setAwaitingResponse, setBusy, setMessages } from '@/store/session'

import type { ClientSessionState } from '../../../types'

Expand Down Expand Up @@ -50,6 +50,17 @@ interface SubmitPromptDeps {
) => ClientSessionState
}

function storedSessionProfile(storedSessionId: string | null): string | null {
if (!storedSessionId) {
return null
}

const stored = $sessions.get().find(session => session.id === storedSessionId || session._lineage_root_id === storedSessionId)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This only searches the paginated $sessions cache. A selected cross-profile session can be absent from that cache, which leaves profile omitted and makes session.resume query the launch-profile DB. Please resolve uncached stored IDs through the existing cross-profile resolver (or a shared equivalent) before retrying.

const profile = stored?.profile?.trim()

return profile || null
}

/** The prompt submit pipeline, extracted from usePromptActions. */
export function useSubmitPrompt(deps: SubmitPromptDeps) {
const {
Expand Down Expand Up @@ -243,8 +254,11 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
// to session creation when NO stored session is selected (a genuine
// new-chat draft).
try {
const profile = storedSessionProfile(startingStoredSessionId)
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: startingStoredSessionId
session_id: startingStoredSessionId,
source: 'desktop',
...(profile ? { profile } : {})
})

if (sessionContextDrifted()) {
Expand Down Expand Up @@ -331,9 +345,12 @@ 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 storedSessionId = startingStoredSessionId
const profile = storedSessionProfile(storedSessionId)
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: startingStoredSessionId,
source: 'desktop'
session_id: storedSessionId,
source: 'desktop',
...(profile ? { profile } : {})
})

if (sessionContextDrifted()) {
Expand Down
106 changes: 74 additions & 32 deletions apps/desktop/src/app/session/hooks/use-session-actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,39 @@ function Harness({
return null
}

function BranchHarness({
onReady,
requestGateway
}: {
onReady: (branchStoredSession: (storedSessionId: string, sessionProfile?: string | null) => Promise<boolean>) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })

const actions = useSessionActions({
activeSessionId: null,
activeSessionIdRef: ref<string | null>(null),
busyRef: ref(false),
creatingSessionRef: ref(false),
ensureSessionState: () => ({}) as ClientSessionState,
getRouteToken: () => 'token',
navigate: vi.fn() as never,
requestGateway,
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
selectedStoredSessionId: null,
selectedStoredSessionIdRef: ref<string | null>(null),
sessionStateByRuntimeIdRef: ref(new Map<string, ClientSessionState>()),
syncSessionStateToView: vi.fn(),
updateSessionState: (_sessionId, updater) => updater({} as ClientSessionState)
})

useEffect(() => {
onReady(actions.branchStoredSession)
}, [actions.branchStoredSession, onReady])

return null
}

async function createWith(
profileSetup: () => void,
beforeCreate?: (handle: HarnessHandle) => Promise<void> | void
Expand Down Expand Up @@ -202,6 +235,47 @@ describe('createBackendSessionForSend profile routing', () => {
})
})

describe('branchStoredSession profile routing', () => {
afterEach(() => {
cleanup()
$activeGatewayProfile.set('default')
setMessages([])
setSessions([])
vi.restoreAllMocks()
})

it('creates the branch on the parent session profile instead of the currently active profile', async () => {
const createCalls: Record<string, unknown>[] = []

vi.mocked(getSessionMessages).mockResolvedValue({
messages: [{ content: 'parent text', role: 'user', timestamp: 1 }],
session_id: 'default-parent'
} as never)

setSessions([storedSession({ id: 'default-parent', profile: 'default' })])
$activeGatewayProfile.set('work-profile')

const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'session.create') {
createCalls.push(params ?? {})

return { session_id: 'runtime-branch', stored_session_id: 'stored-branch', messages: [] } as never
}

return {} as never
})

let branchStoredSession: ((storedSessionId: string, sessionProfile?: string | null) => Promise<boolean>) | null = null
render(<BranchHarness onReady={b => (branchStoredSession = b)} requestGateway={requestGateway} />)
await waitFor(() => expect(branchStoredSession).not.toBeNull())

await expect(branchStoredSession!('default-parent', 'default')).resolves.toBe(true)

expect(createCalls).toHaveLength(1)
expect(createCalls[0]).toMatchObject({ parent_session_id: 'default-parent', profile: 'default' })
})
})

// ── Resume failure recovery (the "stuck loading session window" bug) ──────────
// When session.resume rejects AND the REST transcript fallback ALSO fails, the
// hook must (a) not throw out of the fallback (which stranded the loader), and
Expand Down Expand Up @@ -458,38 +532,6 @@ describe('resumeSession failure recovery', () => {
})
})

function BranchHarness({
onReady,
requestGateway
}: {
onReady: (branchStoredSession: (storedSessionId: string, sessionProfile?: string | null) => Promise<boolean>) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })

const actions = useSessionActions({
activeSessionId: null,
activeSessionIdRef: ref<string | null>(null),
busyRef: ref(false),
creatingSessionRef: ref(false),
ensureSessionState: () => ({}) as ClientSessionState,
getRouteToken: () => 'token',
navigate: vi.fn() as never,
requestGateway,
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
selectedStoredSessionId: null,
selectedStoredSessionIdRef: ref<string | null>(null),
sessionStateByRuntimeIdRef: ref(new Map<string, ClientSessionState>()),
syncSessionStateToView: vi.fn(),
updateSessionState: () => ({}) as ClientSessionState
})

useEffect(() => {
onReady(actions.branchStoredSession)
}, [actions.branchStoredSession, onReady])

return null
}

describe('branchStoredSession desktop source tagging', () => {
afterEach(() => {
Expand Down
Loading