diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 6a7bfc3a54f..4ec0e564a79 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -1894,6 +1894,7 @@ export default function SessionScreen() { setActiveHandle(active.terminal) subscribeToTerminal(active.terminal) } else if (active) { + // Why: an empty snapshot can transiently omit a live terminal; explicit close clears it on RPC success. const previous = activeHandleRef.current if (previous) { unsubscribeTerminal(previous) @@ -4133,6 +4134,7 @@ export default function SessionScreen() { reason: 'user' }) if (response.ok) { + const remainingTabs = sessionTabsRef.current.filter((candidate) => candidate.id !== tab.id) if (tab.type === 'browser' && tab.browserPageId === pendingBrowserFocusPageIdRef.current) { pendingBrowserFocusPageIdRef.current = null } @@ -4143,14 +4145,16 @@ export default function SessionScreen() { initializedHandlesRef.current.delete(terminalHandle) clearTerminalLiveInputDefault(terminalHandle) } - setSessionTabs((prev) => prev.filter((candidate) => candidate.id !== tab.id)) + sessionTabsRef.current = remainingTabs + setSessionTabs(remainingTabs) // Why: tombstone the closed tab and rely on the snapshot, not a blind refetch that often re-added the not-yet-closed tab. closedTabTombstonesRef.current.set(tab.id, Date.now() + 10_000) // Why: bulk close re-activates the anchor before awaiting each close; // the render-synced ref sees that switch while this closure would not, // so comparing against the ref keeps the anchor from being nulled out. - if (activeSessionTabIdRef.current === tab.id) { + if (activeSessionTabIdRef.current === tab.id || remainingTabs.length === 0) { activeSessionTabTypeRef.current = null + activeSessionTabIdRef.current = null setActiveSessionTabId(null) activeHandleRef.current = null setActiveHandle(null) diff --git a/mobile/src/session/mobile-session-last-tab-close.test.ts b/mobile/src/session/mobile-session-last-tab-close.test.ts new file mode 100644 index 00000000000..7bd6ad179fc --- /dev/null +++ b/mobile/src/session/mobile-session-last-tab-close.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const sessionRouteSource = readFileSync( + new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), + 'utf8' +) + +describe('mobile session last-tab close', () => { + it('preserves terminal identity while an empty snapshot may be transient', () => { + const start = sessionRouteSource.indexOf('const applySessionTabs = useCallback') + const end = sessionRouteSource.indexOf('const readMarkdownTab', start) + const block = sessionRouteSource.slice(start, end) + + expect(block).toContain('} else if (active) {') + }) + + it('clears stale active identity when closing leaves no tabs', () => { + const start = sessionRouteSource.indexOf('async function handleCloseSessionTab') + const end = sessionRouteSource.indexOf('const bulkCloseActions', start) + const block = sessionRouteSource.slice(start, end) + + expect(block).toContain( + 'activeSessionTabIdRef.current === tab.id || remainingTabs.length === 0' + ) + expect(block).toContain('activeSessionTabIdRef.current = null') + expect(block).toContain('activeHandleRef.current = null') + }) +})