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 31882d91b66d..35110e481b1f 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 @@ -1253,6 +1253,71 @@ describe('branchStoredSession desktop source tagging', () => { }) }) +// ── Main/tile dedup (the "same session open in main AND its own tab" bug) ───── +// A session is EITHER the main thread OR a tile, never both. openSessionTile +// enforces this from the tile side; resumeSession enforces it from the main +// side by dropping an existing tile when the session loads into main (cold-start +// restore, a pasted/⌘K route, a notification jump), so it can't render twice. +describe('resumeSession drops a redundant tile when the session loads into main', () => { + afterEach(() => { + cleanup() + setActiveSessionId(null) + setResumeFailedSessionId(null) + setMessages([]) + setSessions([]) + $sessionTiles.set([]) + vi.restoreAllMocks() + }) + + it('closes the tile so the session is not open in both main and its own tab', async () => { + // The session is already an open tile (e.g. persisted across a restart)... + $sessionTiles.set([{ storedSessionId: 'stored-1' }]) + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'session.resume') { + return { session_id: 'runtime-1', resumed: params?.session_id, messages: [], info: {} } as never + } + + return {} as never + }) + + vi.mocked(getSessionMessages).mockResolvedValue({ messages: [] } as never) + + let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise) | null = null + render( (resume = r)} requestGateway={requestGateway} />) + await waitFor(() => expect(resume).not.toBeNull()) + + // ...and now it loads into main. + await resume!('stored-1', true) + + // Its tile is gone — main owns the session, so it renders exactly once. + expect($sessionTiles.get().some(t => t.storedSessionId === 'stored-1')).toBe(false) + expect($selectedStoredSessionId.get()).toBe('stored-1') + }) + + it('leaves OTHER sessions tiles untouched', async () => { + $sessionTiles.set([{ storedSessionId: 'stored-1' }, { storedSessionId: 'stored-2' }]) + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'session.resume') { + return { session_id: 'runtime-1', resumed: params?.session_id, messages: [], info: {} } as never + } + + return {} as never + }) + + vi.mocked(getSessionMessages).mockResolvedValue({ messages: [] } as never) + + let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise) | null = null + render( (resume = r)} requestGateway={requestGateway} />) + await waitFor(() => expect(resume).not.toBeNull()) + await resume!('stored-1', true) + + // Only the resumed session's tile closes; the sibling tile stays put. + expect($sessionTiles.get().map(t => t.storedSessionId)).toEqual(['stored-2']) + }) +}) + // ── Warm-cache mapping integrity (the "open chat A, chat B loads" bug) ───────── // resumeSession's warm fast-path maps storedSessionId -> runtimeId -> cached // state. A reaped/respawned pooled backend re-mints runtime ids, so a recycled 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 5d293c012b21..9ec65d78d028 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 @@ -57,6 +57,7 @@ import { setYoloActive } from '@/store/session' import { + $sessionTiles, closeSessionTile, dropSessionState, openSessionTile, @@ -557,6 +558,18 @@ export function useSessionActions({ resetViewSync() setSelectedStoredSessionId(storedSessionId) selectedStoredSessionIdRef.current = storedSessionId + // A session is EITHER the main thread OR a tile — never both. openSessionTile + // enforces this from the tile side (it refuses to tile the selected session); + // this enforces it from the main side. Loading an existing session into main + // (cold-start restore, a pasted/⌘K route, a notification jump) while it's also + // an open tile would paint the same transcript twice — the workspace pane from + // the route and the tile pane in parallel, both fighting one runtime. Drop the + // now-redundant tile so main owns it. Runs before the async awaits below (and + // before the selection listener homes focus) so the tile is gone the same tick + // the route takes over; the warm cache/runtime binding survives for main to reuse. + if ($sessionTiles.get().some(t => t.storedSessionId === storedSessionId)) { + closeSessionTile(storedSessionId) + } // Optimistically clear any prior resume-failure latch for this session: // we're attempting a fresh resume, so the self-heal in use-route-resume // must not keep treating it as stranded. It's re-armed below only if THIS