fix(desktop): drop a session's tile when it loads into the main tab

A session is meant to be either the main thread or a tile, never both.
openSessionTile enforced this from the tile side (refusing to tile the
selected session), but the main side never dropped an existing tile — so
any path that routes main to a session that's already tiled (cold-start
remembered-session restore, a pasted/Cmd-K route, a notification jump)
painted the same transcript twice: the workspace pane from the route and
the tile pane in parallel, both fighting one runtime.

resumeSession is the single chokepoint every load-into-main path funnels
through, so close the redundant tile there once the session becomes the
selection. The warm cache/runtime binding survives for main to reuse.
This commit is contained in:
Brooklyn Nicholson 2026-07-28 04:16:28 -05:00
parent d2cdb21633
commit ab741ed331
2 changed files with 78 additions and 0 deletions

View file

@ -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<string, unknown>) => {
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<unknown>) | null = null
render(<ResumeHarness onReady={r => (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<string, unknown>) => {
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<unknown>) | null = null
render(<ResumeHarness onReady={r => (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

View file

@ -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