diff --git a/src/renderer/src/runtime/server-layout-frames-held-tabs.test.ts b/src/renderer/src/runtime/server-layout-frames-held-tabs.test.ts index 494a035b3e3..93392e468e9 100644 --- a/src/renderer/src/runtime/server-layout-frames-held-tabs.test.ts +++ b/src/renderer/src/runtime/server-layout-frames-held-tabs.test.ts @@ -20,6 +20,10 @@ import { createStoreSessionMockApi } from '../store/slices/store-session-test-ha import { _resetLayoutFramePositionForTests } from './layout-frame-position' import { useAppStore } from '@/store' import { stageWebRuntimeBrowserTab } from './web-runtime-browser-tab-staging' +import { + _resetStagedBrowserFrameHandoversForTests, + wasStagedBrowserTabHandedToFrames +} from '@/lib/staged-browser-frame-handover' import { ensureSimulatorTab } from '@/lib/ensure-simulator-tab' import type { DragEndEvent } from '@dnd-kit/core' import { commitTabDragDrop } from '@/components/tab-group/tab-drag-drop-commit' @@ -546,3 +550,70 @@ describe('a tab in this window’s own frame-drawn workspace', () => { sync.close() }) }) + +describe('a staged server-hosted browser tab (L285)', () => { + /** The server's layout once it published the page: its own tab and record for it. */ + const WITH_PAGE = (() => { + const layout = serverLayout([ + serverTerminalTab('host-tab-1', LEAF_ID, 'server-pty-1'), + { ...serverTerminalTab('server-browser-tab', LEAF_ID, null, 2), kind: 'browser' as const } + ]) + const browserTab = { ...layout.tabs[1]!, entityId: 'server-browser-1' } + delete browserTab.terminal + return { + ...layout, + tabs: [layout.tabs[0]!, browserTab], + browserTabs: [{ id: 'server-browser-1', pageIds: ['server-page-1'] }] + } + })() + + it('stays through a frame that does not list its page, and hands over to the one that does', async () => { + _resetStagedBrowserFrameHandoversForTests() + const { store, stream, frames } = await appStoreFrameDrawn() + const staged = stageWebRuntimeBrowserTab({ + environmentId: ENV, + worktreeId: WT, + remotePageId: 'server-page-1', + activate: true, + targetGroupId: HOST_GROUP + })! + + // Another client's change rebuilds the workspace before the server publishes the page. + streams.send(stream, frames.records(WT, WITH_TAB_2)) + await settle() + expect(check.workspaceBuilt).toContain(WT) + expect(tabIds(store)).toContain(staged.tabId) + + check.forgetBuilds() + streams.send(stream, frames.records(WT, WITH_PAGE)) + await settle() + expect(check.workspaceBuilt).toContain(WT) + expect(tabIds(store)).not.toContain(staged.tabId) + expect(tabIds(store)).toContain('server-browser-tab') + expect(wasStagedBrowserTabHandedToFrames(staged.workspaceId, 'server-page-1')).toBe(true) + expect(DESKTOP_CREATED_SERVER_TAB_HOLDS.isTabHeld(WT, staged.workspaceId)).toBe(false) + }) + + it('leaves no hold on a tab the mirror adopted before the frames drew the workspace', async () => { + const { store, stream, frames } = await appStoreFrameDrawn() + const staged = stageWebRuntimeBrowserTab({ + environmentId: ENV, + worktreeId: WT, + remotePageId: 'server-page-1', + activate: true, + targetGroupId: HOST_GROUP + })! + // The mirror's adoption: same rows, the handle no longer staged. + store.getState().setRemoteBrowserPageHandle(staged.pageId, { + environmentId: ENV, + remotePageId: 'server-page-1' + }) + + streams.send(stream, frames.records(WT, WITH_PAGE)) + await settle() + expect(check.workspaceBuilt).toContain(WT) + expect(tabIds(store)).not.toContain(staged.tabId) + expect(tabIds(store)).toContain('server-browser-tab') + expect(DESKTOP_CREATED_SERVER_TAB_HOLDS.isTabHeld(WT, staged.tabId!)).toBe(false) + }) +}) diff --git a/src/renderer/src/runtime/server-layout-frames.ts b/src/renderer/src/runtime/server-layout-frames.ts index 321816d15c3..1f302b083e0 100644 --- a/src/renderer/src/runtime/server-layout-frames.ts +++ b/src/renderer/src/runtime/server-layout-frames.ts @@ -28,6 +28,7 @@ import { import { serverHeldPanes, showServerLayoutHeldPanes } from './server-held-panes' import { createServerViewReport, onServerControlConnected } from './server-layout-view-report' import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync' +import { releaseHeldBrowserTabsTheServerLists } from './web-runtime-browser-tab-staging' import { provideServerLayoutApplyRoutes } from './server-layout-apply-routes' import { mirroredRuntimeEnvironmentIdForWorkspace } from './web-session-tabs-sync/mirrored-workspace-owner' import { @@ -153,12 +154,19 @@ function openServerFrames( translate: (frame) => { const translated = translator.translate(frame) // A tab this desktop created that the server now lists is the server's from here on. + const serverPageIds = new Map>() for (const key of translator.keys()) { + const layout = translator.serverLayout(key) forgetPublishedServerTabs( key, - (translator.serverLayout(key)?.tabs ?? []).flatMap((tab) => [tab.id, tab.entityId]) + (layout?.tabs ?? []).flatMap((tab) => [tab.id, tab.entityId]) + ) + serverPageIds.set( + key, + new Set((layout?.browserTabs ?? []).flatMap((tab) => tab.pageIds ?? [])) ) } + releaseHeldBrowserTabsTheServerLists(environmentId, serverPageIds) live ||= frame.type === 'snapshot' if (frame.type === 'snapshot') { viewReport.reset() diff --git a/src/renderer/src/runtime/web-runtime-browser-tab-staging.ts b/src/renderer/src/runtime/web-runtime-browser-tab-staging.ts index 341e83e5c2c..08d2b101f8f 100644 --- a/src/renderer/src/runtime/web-runtime-browser-tab-staging.ts +++ b/src/renderer/src/runtime/web-runtime-browser-tab-staging.ts @@ -71,7 +71,7 @@ export function stageWebRuntimeBrowserTab(args: { clientHosted: args.clientHosted === true, ...(tabId ? { tabId } : {}) } - noteClientHosting(args.environmentId, args.worktreeId, staged) + holdStagedBrowserTab(args.environmentId, args.worktreeId, staged) return staged } catch (error) { // Why: staging is an optimization; a store-side refusal must not fail the create itself. @@ -189,13 +189,19 @@ export function restageWebRuntimeBrowserTabHostingIntent( staged: true, ...(args.clientHosted ? { stagedClientHosted: true } : {}) }) - const restaged = { ...staged, clientHosted: args.clientHosted } - noteClientHosting(args.environmentId, args.worktreeId, restaged) - return restaged + return { ...staged, clientHosted: args.clientHosted } } -/** A client-hosted page never enters its server's layout, so that server's frames keep the tab (L274). */ -function noteClientHosting( +const stagedTabIds = (staged: Pick) => [ + staged.workspaceId, + ...(staged.tabId ? [staged.tabId] : []) +] + +/** + * The server's frames keep a staged tab: a client-hosted page never enters its server's layout + * (L274), and a server-hosted one is not there until the server publishes it (L285). + */ +function holdStagedBrowserTab( environmentId: string, worktreeId: string, staged: StagedWebRuntimeBrowserTab @@ -205,11 +211,43 @@ function noteClientHosting( return } // By both ids: its browser workspace and its tab-bar entry. - for (const id of [staged.workspaceId, ...(staged.tabId ? [staged.tabId] : [])]) { - if (staged.clientHosted) { - noteDesktopCreatedServerTab(environmentId, worktreeId, id) - } else { - forgetDesktopCreatedServerTab(id) + for (const id of stagedTabIds(staged)) { + noteDesktopCreatedServerTab(environmentId, worktreeId, id) + } +} + +/** + * A held browser tab whose page the server's layout now lists is the server's: its frames draw the + * page in their own tab and drop this one, which a create still in flight reads as the frames taking + * it over (L284). Also a tab the mirror adopted before the frames drew the workspace. + */ +export function releaseHeldBrowserTabsTheServerLists( + environmentId: string, + serverPageIdsByWorkspace: ReadonlyMap> +): void { + const state = useAppStore.getState() + const listed = [...serverPageIdsByWorkspace.values()] + const pageIds = new Set( + Object.entries(state.remoteBrowserPageHandlesByPageId).flatMap(([pageId, handle]) => + handle?.environmentId === environmentId && listed.some((ids) => ids.has(handle.remotePageId)) + ? [pageId] + : [] + ) + ) + if (pageIds.size === 0) { + return + } + for (const worktreeId of serverPageIdsByWorkspace.keys()) { + for (const workspace of state.browserTabsByWorktree[worktreeId] ?? []) { + if (!workspace.pageIds?.some((pageId) => pageIds.has(pageId))) { + continue + } + const tabId = state.unifiedTabsByWorktree[worktreeId]?.find( + (tab) => tab.contentType === 'browser' && tab.entityId === workspace.id + )?.id + for (const id of stagedTabIds({ workspaceId: workspace.id, ...(tabId ? { tabId } : {}) })) { + forgetDesktopCreatedServerTab(id) + } } } }