diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index 557be2765..96a3f8a53 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -6,6 +6,7 @@ import { makePaneKey } from '../../../shared/stable-pane-id' import type { BrowserPage, BrowserWorkspace, Tab, TerminalTab } from '../../../shared/types' import type { OpenFile } from '../store/slices/editor' import { + _getWebSessionTabsTrackingCountsForTest, applyFreshWebSessionTabsSnapshot, applyWebSessionTabsSnapshot, applyWebSessionTabsSnapshots, @@ -92,6 +93,121 @@ describe('applyWebSessionTabsSnapshot', () => { expect(applyFreshWebSessionTabsSnapshot(afterNewer, newer, ENV, NOW)).toBe(afterNewer) }) + it('clears web session tracking maps when the host removes a worktree snapshot', () => { + const workspace: BrowserWorkspace = { + id: 'local-browser-workspace', + worktreeId: WT, + activePageId: 'local-browser-page', + pageIds: ['local-browser-page'], + url: 'https://example.com/', + title: 'Example Domain', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: NOW - 10 + } + const page: BrowserPage = { + id: 'local-browser-page', + workspaceId: workspace.id, + worktreeId: WT, + url: 'https://example.com/', + title: 'Example Domain', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: NOW - 10 + } + const unifiedTab: Tab = { + id: 'local-browser-unified', + entityId: workspace.id, + groupId: 'host-group-1', + worktreeId: WT, + contentType: 'browser', + label: 'New Tab', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: NOW - 10, + isPreview: false, + isPinned: false + } + + const patch = applyFreshWebSessionTabsSnapshot( + makeState({ + browserTabsByWorktree: { [WT]: [workspace] }, + browserPagesByWorkspace: { [workspace.id]: [page] }, + remoteBrowserPageHandlesByPageId: { + [page.id]: { environmentId: ENV, remotePageId: 'host-browser-page' } + }, + unifiedTabsByWorktree: { [WT]: [unifiedTab] }, + groupsByWorktree: { + [WT]: [ + { + id: 'host-group-1', + worktreeId: WT, + activeTabId: unifiedTab.id, + tabOrder: [unifiedTab.id], + recentTabIds: [unifiedTab.id] + } + ] + } + }), + makeSnapshot( + [ + { + type: 'browser', + id: 'host-browser-unified', + title: 'Example Domain', + browserWorkspaceId: 'host-browser-workspace', + browserPageId: 'host-browser-page', + url: 'https://example.com/', + loading: false, + canGoBack: false, + canGoForward: false, + isActive: true + } + ], + { activeTabId: 'host-browser-unified', activeTabType: 'browser' } + ), + ENV, + NOW + ) as Partial + const afterHostSnapshot = { + ...makeState(), + ...patch + } as WebSessionTabsSyncState + + expect(_getWebSessionTabsTrackingCountsForTest()).toEqual({ + freshness: 1, + hostMappings: 1 + }) + + applyFreshWebSessionTabsSnapshot( + afterHostSnapshot, + { + ...makeSnapshot([], { + publicationEpoch: 'removed-epoch', + snapshotVersion: 0, + activeGroupId: null, + activeTabId: null, + activeTabType: null + }), + removed: true + } as RuntimeMobileSessionTabsResult, + ENV, + NOW + 1 + ) + + expect(_getWebSessionTabsTrackingCountsForTest()).toEqual({ + freshness: 0, + hostMappings: 0 + }) + }) + it('hydrates ready host terminal surfaces as remote runtime terminal tabs', () => { const patch = applyWebSessionTabsSnapshot( makeState(), diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index d13bae298..17a6d6a2b 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -134,6 +134,13 @@ export function shouldApplyWebSessionTabsSnapshot( environmentId: string ): boolean { const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) + if ((snapshot as { removed?: unknown }).removed === true) { + // Why: removed worktrees can stop publishing snapshots, so their + // freshness/mapping entries need explicit cleanup instead of waiting for + // a later replacement snapshot that may never arrive. + clearWebSessionTabsTrackingForWorktree(environmentId, snapshot.worktree) + return true + } const current = latestSessionTabsSnapshotByWorktree.get(key) if ( current && @@ -154,6 +161,26 @@ export function resetWebSessionTabsSnapshotFreshnessForTests(): void { hostSessionTabIdByLocalKey.clear() } +export function _getWebSessionTabsTrackingCountsForTest(): { + freshness: number + hostMappings: number +} { + return { + freshness: latestSessionTabsSnapshotByWorktree.size, + hostMappings: hostSessionTabIdByLocalKey.size + } +} + +function clearWebSessionTabsTrackingForWorktree(environmentId: string, worktreeId: string): void { + latestSessionTabsSnapshotByWorktree.delete(sessionTabsFreshnessKey(environmentId, worktreeId)) + const keyPrefix = `${environmentId}:${worktreeId}:` + for (const key of hostSessionTabIdByLocalKey.keys()) { + if (key.startsWith(keyPrefix)) { + hostSessionTabIdByLocalKey.delete(key) + } + } +} + function hostSessionTabMappingKey(args: { environmentId: string worktreeId: string