diff --git a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.owner.test.ts b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.owner.test.ts index 82e21df8ec6..e339c71f7d4 100644 --- a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.owner.test.ts +++ b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.owner.test.ts @@ -1,7 +1,12 @@ // @vitest-environment happy-dom import { beforeEach, describe, expect, it, vi } from 'vitest' -type CreateRequest = { requestId: string; worktreeId?: string; url: string } +type CreateRequest = { + requestId: string + worktreeId?: string + url: string + browserPageId?: string +} type CloseRequest = { requestId: string; tabId?: string | null; worktreeId?: string } type ScopeRequest = { requestId: string @@ -120,6 +125,32 @@ describe('browser requests from this desktop follow the workspace owner (#22275) expect(mocks.replyTabCreate).toHaveBeenCalledWith({ requestId: 'r1', browserPageId: 'ws-new' }) }) + it("answers with the new tab's page before main's frame draws it (layout-owner build)", () => { + mocks.getState.mockReturnValue(storeState(LOCAL_WT)) + mocks.createBrowserTab.mockImplementation(() => ({ id: 'ws-new', activePageId: 'page-new' })) + + mocks.createListener?.({ requestId: 'r4', worktreeId: LOCAL_WT, url: 'about:blank' }) + + // Not the workspace id: a paired client matches the reply against the page it asked for. + expect(mocks.replyTabCreate).toHaveBeenCalledWith({ + requestId: 'r4', + browserPageId: 'page-new' + }) + + mocks.createListener?.({ + requestId: 'r5', + worktreeId: LOCAL_WT, + url: 'about:blank', + browserPageId: 'page-asked' + }) + + // The page a paired client asked for, which the new tab opens under. + expect(mocks.replyTabCreate).toHaveBeenCalledWith({ + requestId: 'r5', + browserPageId: 'page-asked' + }) + }) + it("renders a page named in a managed host's workspace on this desktop (#29145)", () => { mocks.getState.mockReturnValue(storeState(LOCAL_WT)) diff --git a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts index 50c2f270870..8882e12d67b 100644 --- a/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/browser-request-ipc-bridge.ts @@ -81,7 +81,9 @@ export function registerBrowserRequestIpcBridge(unsubs: (() => void)[]): void { }) // Why: registerGuest fires with the page ID, not the workspace ID; return it so waitForTabRegistration can correlate. const pages = useAppStore.getState().browserPagesByWorkspace[workspace.id] ?? [] - const browserPageId = pages[0]?.id ?? workspace.id + // A layout-owner build draws the page once main's frame lands: answer the id it was opened under. + const browserPageId = + data.browserPageId ?? pages[0]?.id ?? workspace.activePageId ?? workspace.id acquireBrowserAutomationBootstrapLease(worktreeId, browserPageId) window.api.ui.replyTabCreate({ requestId: data.requestId, browserPageId }) } catch (err) { diff --git a/src/renderer/src/lib/staged-browser-frame-handover.ts b/src/renderer/src/lib/staged-browser-frame-handover.ts new file mode 100644 index 00000000000..c1941917e27 --- /dev/null +++ b/src/renderer/src/lib/staged-browser-frame-handover.ts @@ -0,0 +1,29 @@ +// A browser tab this desktop staged in a frame-drawn server workspace that the server's frames then +// drew as their own tab for the same page (ledger L284). The frames replace the staged rows, so the +// create must read their absence as done, not as the user's close. + +/** Staged workspace id → the remote page the frames took over. */ +const handedOver = new Map() + +export function noteStagedBrowserTabHandedToFrames( + stagedWorkspaceId: string, + remotePageId: string +): void { + handedOver.set(stagedWorkspaceId, remotePageId) +} + +export function wasStagedBrowserTabHandedToFrames( + stagedWorkspaceId: string, + remotePageId: string +): boolean { + return handedOver.get(stagedWorkspaceId) === remotePageId +} + +/** The create settled: its record must not outlive it. */ +export function forgetStagedBrowserTabHandover(stagedWorkspaceId: string): void { + handedOver.delete(stagedWorkspaceId) +} + +export function _resetStagedBrowserFrameHandoversForTests(): void { + handedOver.clear() +} diff --git a/src/renderer/src/runtime/web-runtime-browser-creation.ts b/src/renderer/src/runtime/web-runtime-browser-creation.ts index d0b1e522528..487030acf6f 100644 --- a/src/renderer/src/runtime/web-runtime-browser-creation.ts +++ b/src/renderer/src/runtime/web-runtime-browser-creation.ts @@ -33,6 +33,10 @@ import { import { isWebRuntimeSessionActive } from './web-runtime-session-environment' import { refreshWebRuntimeSessionTabsSnapshot } from './web-runtime-session-snapshot' import { registerPairedBrowserTabCreator } from '../store/slices/browser/paired-browser-tab-creator' +import { + forgetStagedBrowserTabHandover, + wasStagedBrowserTabHandedToFrames +} from '@/lib/staged-browser-frame-handover' export async function createWebRuntimeSessionBrowserTab( args: CreateWebRuntimeSessionBrowserTabArgs @@ -45,8 +49,10 @@ export async function createWebRuntimeSessionBrowserTab( return false } const context = createWebRuntimeBrowserCreationContext(args, environmentId) + let stagedWorkspaceId: string | undefined try { stageWebRuntimeBrowserCreation(context) + stagedWorkspaceId = context.staged?.workspaceId const placementPreference = args.placementPreference ?? 'auto' let placement: BrowserPageCreationPlacement = { kind: 'server' } // Why no cached-capability gate here: the renderer's runtime status can hold a pre-upgrade @@ -128,7 +134,17 @@ export async function createWebRuntimeSessionBrowserTab( // Why (accepted bound): the host page has no name until the create answers, so a cancel during // that round-trip leaves it alive for up to the RPC timeout. That is the same bound the // unreconciled-cleanup path has always accepted, so no sweep is added for it. - if (context.staged && !isStagedWebRuntimeBrowserTabLive(context.staged, args.worktreeId)) { + // Why: in a frame-drawn workspace the frames replace the staged rows with their own tab for + // the page; that is the create landing, not the user's close (L284). + // Their tab gets no handle until its pane mounts, so the handover alone is the create landing. + const handedToFrames = (): boolean => + stagedWorkspaceId !== undefined && + wasStagedBrowserTabHandedToFrames(stagedWorkspaceId, created.browserPageId) + const stagedClosedByUser = (): boolean => + context.staged !== null && + !isStagedWebRuntimeBrowserTabLive(context.staged, args.worktreeId) && + !handedToFrames() + if (stagedClosedByUser()) { throw new StagedWebRuntimeBrowserTabCancelledError() } if (created.browserPageId !== context.provisionalPageId) { @@ -148,6 +164,7 @@ export async function createWebRuntimeSessionBrowserTab( }) } catch (error) { if ( + !handedToFrames() && !hasMaterializedWebRuntimeBrowserPage( useAppStore.getState(), environmentId, @@ -160,19 +177,22 @@ export async function createWebRuntimeSessionBrowserTab( } } const expectedGroupId = resolveExpectedGroupId() - let materialized = hasMaterializedWebRuntimeBrowserPage( - useAppStore.getState(), - environmentId, - args.worktreeId, - created.browserPageId, - expectedGroupId - ) + let materialized = + handedToFrames() || + hasMaterializedWebRuntimeBrowserPage( + useAppStore.getState(), + environmentId, + args.worktreeId, + created.browserPageId, + expectedGroupId + ) if (!materialized) { materialized = await waitForWebRuntimeBrowserPageMaterialization({ environmentId, worktreeId: args.worktreeId, remotePageId: created.browserPageId, - ...(expectedGroupId ? { expectedGroupId } : {}) + ...(expectedGroupId ? { expectedGroupId } : {}), + handedToFrames }) } if (!materialized && expectedGroupId) { @@ -199,7 +219,7 @@ export async function createWebRuntimeSessionBrowserTab( // X-ed is gone. The whole materialization wait above is a live window for that X, so re-check the // row here before the stage is surrendered, or the create reports success and never retires the // host page. - if (context.staged && !isStagedWebRuntimeBrowserTabLive(context.staged, args.worktreeId)) { + if (stagedClosedByUser()) { throw new StagedWebRuntimeBrowserTabCancelledError() } // Why: materialization means the snapshot has taken ownership of these rows, so nothing @@ -254,6 +274,10 @@ export async function createWebRuntimeSessionBrowserTab( } } return finishWebRuntimeBrowserCreationFailure(context, failure, error) + } finally { + if (stagedWorkspaceId) { + forgetStagedBrowserTabHandover(stagedWorkspaceId) + } } } diff --git a/src/renderer/src/runtime/web-runtime-browser-materialization-wait.test.ts b/src/renderer/src/runtime/web-runtime-browser-materialization-wait.test.ts new file mode 100644 index 00000000000..ce922e42e5c --- /dev/null +++ b/src/renderer/src/runtime/web-runtime-browser-materialization-wait.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const store = vi.hoisted(() => { + const listeners = new Set<() => void>() + return { + state: { + browserPagesByWorkspace: {}, + browserTabsByWorktree: {}, + remoteBrowserPageHandlesByPageId: {}, + unifiedTabsByWorktree: {} + }, + emit: () => listeners.forEach((listener) => listener()), + listeners + } +}) + +vi.mock('../store', () => ({ + useAppStore: { + getState: () => store.state, + subscribe: (listener: () => void) => { + const call = (): void => listener() + store.listeners.add(call) + return () => store.listeners.delete(call) + } + } +})) + +import { waitForWebRuntimeBrowserPageMaterialization } from './web-runtime-browser-materialization-wait' + +describe('waitForWebRuntimeBrowserPageMaterialization', () => { + afterEach(() => { + vi.useRealTimers() + store.listeners.clear() + }) + + it('ends when the frames take the page over mid-wait, before any handle (L284)', async () => { + vi.useFakeTimers() + let handedOver = false + const waited = waitForWebRuntimeBrowserPageMaterialization({ + environmentId: 'env-1', + worktreeId: 'wt-1', + remotePageId: 'page-1', + handedToFrames: () => handedOver + }) + handedOver = true + store.emit() + await expect(waited).resolves.toBe(true) + }) + + it('times out when nothing draws the page', async () => { + vi.useFakeTimers() + const waited = waitForWebRuntimeBrowserPageMaterialization({ + environmentId: 'env-1', + worktreeId: 'wt-1', + remotePageId: 'page-1', + handedToFrames: () => false + }) + await vi.advanceTimersByTimeAsync(8_000) + await expect(waited).resolves.toBe(false) + }) +}) diff --git a/src/renderer/src/runtime/web-runtime-browser-materialization-wait.ts b/src/renderer/src/runtime/web-runtime-browser-materialization-wait.ts index c1d18c94e35..0d35ad61b59 100644 --- a/src/renderer/src/runtime/web-runtime-browser-materialization-wait.ts +++ b/src/renderer/src/runtime/web-runtime-browser-materialization-wait.ts @@ -8,10 +8,13 @@ export function waitForWebRuntimeBrowserPageMaterialization(args: { worktreeId: string remotePageId: string expectedGroupId?: string + /** A frame-drawn workspace's frames took the page over (L284): done, with no handle yet. */ + handedToFrames?: () => boolean }): Promise { - const hasMaterialized = (): boolean => + const hasMaterialized = (state = useAppStore.getState()): boolean => + args.handedToFrames?.() === true || hasMaterializedWebRuntimeBrowserPage( - useAppStore.getState(), + state, args.environmentId, args.worktreeId, args.remotePageId, @@ -36,15 +39,7 @@ export function waitForWebRuntimeBrowserPageMaterialization(args: { resolve(materialized) } unsubscribe = useAppStore.subscribe((state) => { - if ( - hasMaterializedWebRuntimeBrowserPage( - state, - args.environmentId, - args.worktreeId, - args.remotePageId, - args.expectedGroupId - ) - ) { + if (hasMaterialized(state)) { finish(true) } }) diff --git a/src/renderer/src/runtime/web-runtime-session-browser-create-staging.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-create-staging.test.ts index 88a8b6ab678..c9e98ba6d12 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-create-staging.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-create-staging.test.ts @@ -13,6 +13,11 @@ import { stubBrowserTabCreateEnvironment } from './web-runtime-session-test-harness' import { stubBrowserCreateRuntimeEnvironmentBridge } from './runtime-environment-bridge-fixture' +import { + _resetStagedBrowserFrameHandoversForTests, + noteStagedBrowserTabHandedToFrames, + wasStagedBrowserTabHandedToFrames +} from '@/lib/staged-browser-frame-handover' const mocks = vi.hoisted(() => ({ getState: vi.fn(), @@ -161,7 +166,10 @@ function closedHostPages(runtimeCall: { mock: { calls: unknown[][] } }): string[ .map((request) => request.params?.page ?? '') } -afterEach(() => resetWebSessionCloseIntentForTests()) +afterEach(() => { + resetWebSessionCloseIntentForTests() + _resetStagedBrowserFrameHandoversForTests() +}) describe('createWebRuntimeSessionBrowserTab optimistic staging', () => { beforeEach(() => { @@ -429,6 +437,136 @@ describe('createWebRuntimeSessionBrowserTab optimistic staging', () => { expect(stagedBrowserWorkspaces(mocks)).toEqual([]) }) + describe("a server-hosted create the server's frames take over (L284)", () => { + async function useRealMaterialization(): Promise { + const { hasMaterializedWebRuntimeBrowserPage: realPredicate } = await vi.importActual< + typeof BrowserMaterializationModule + >('./web-runtime-browser-materialization') + mocks.hasMaterializedWebRuntimeBrowserPage.mockImplementation(realPredicate) + } + + /** The frame build replacing the staged rows with the server's tab for the page. */ + function framesDrawHostPage(handedOver: boolean): void { + closeStagedWorkspace('staged-workspace-1') + mirrorHostPage('host-workspace-9', 'host-page-1') + if (handedOver) { + noteStagedBrowserTabHandedToFrames('staged-workspace-1', 'host-page-1') + } + } + + function createAnswering(onCreate: () => void, onClose?: () => void) { + return vi.fn((request: { method: string; params: { page?: string } }) => { + if (request.method === 'browser.tabCreate') { + onCreate() + return Promise.resolve({ + id: 'create', + ok: true, + result: { browserPageId: 'host-page-1' } + }) + } + if (request.method === 'browser.tabClose') { + onClose?.() + return Promise.resolve({ id: 'close', ok: true, result: { closed: true } }) + } + return Promise.resolve({ id: 'list', ok: true, result: makeSnapshot() }) + }) + } + + it('completes into the drawn tab and closes nothing', async () => { + await useRealMaterialization() + const runtimeCall = createAnswering(() => framesDrawHostPage(true)) + stubBrowserCreateRuntimeEnvironmentBridge(runtimeCall) + + await expect( + createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + environmentId: ENVIRONMENT_ID + }) + ).resolves.toBe(true) + + expect(closedHostPages(runtimeCall)).toEqual([]) + // The record does not outlive the create. + expect(wasStagedBrowserTabHandedToFrames('staged-workspace-1', 'host-page-1')).toBe(false) + }) + + it('completes when the drawn tab has no handle yet, its pane not mounted', async () => { + await useRealMaterialization() + const runtimeCall = createAnswering(() => { + closeStagedWorkspace('staged-workspace-1') + noteStagedBrowserTabHandedToFrames('staged-workspace-1', 'host-page-1') + }) + stubBrowserCreateRuntimeEnvironmentBridge(runtimeCall) + + await expect( + createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + environmentId: ENVIRONMENT_ID + }) + ).resolves.toBe(true) + + expect(closedHostPages(runtimeCall)).toEqual([]) + }) + + it('still cancels a staged tab the user closed before the frames drew the page', async () => { + await useRealMaterialization() + const runtimeCall = createAnswering( + () => framesDrawHostPage(false), + () => forgetHostPage('host-workspace-9', 'host-page-1') + ) + stubBrowserCreateRuntimeEnvironmentBridge(runtimeCall) + + await expect( + createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + environmentId: ENVIRONMENT_ID + }) + ).resolves.toBe(false) + + expect(closedHostPages(runtimeCall)).toEqual(['host-page-1']) + }) + + it('does not reuse a handover an earlier create left for another page', async () => { + await useRealMaterialization() + noteStagedBrowserTabHandedToFrames('staged-workspace-1', 'earlier-host-page') + const runtimeCall = createAnswering( + () => framesDrawHostPage(false), + () => forgetHostPage('host-workspace-9', 'host-page-1') + ) + stubBrowserCreateRuntimeEnvironmentBridge(runtimeCall) + + await expect( + createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + environmentId: ENVIRONMENT_ID + }) + ).resolves.toBe(false) + + expect(closedHostPages(runtimeCall)).toEqual(['host-page-1']) + expect(wasStagedBrowserTabHandedToFrames('staged-workspace-1', 'earlier-host-page')).toBe( + false + ) + }) + + it("leaves a close of the drawn tab after the handover to the server tab's own close", async () => { + await useRealMaterialization() + // The user closes the frames' tab: the server closes the page, and frames drop the tab. + const runtimeCall = createAnswering(() => { + framesDrawHostPage(true) + forgetHostPage('host-workspace-9', 'host-page-1') + }) + stubBrowserCreateRuntimeEnvironmentBridge(runtimeCall) + await expect( + createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + environmentId: ENVIRONMENT_ID + }) + ).resolves.toBe(true) + + // Not read as a staged-tab cancel: the create sends no close of its own. + expect(closedHostPages(runtimeCall)).toEqual([]) + }) + }) + // Why: with a known-id host nothing rehomes, so `staged` still points at rows a snapshot may // have adopted by the time a later failure unwinds. Those rows are the host's now, and the // unwind must not take a tab the user is looking at down with it. diff --git a/src/renderer/src/runtime/web-session-tabs-sync/frame-drawn-desktop-browser-handles.ts b/src/renderer/src/runtime/web-session-tabs-sync/frame-drawn-desktop-browser-handles.ts new file mode 100644 index 00000000000..0a2eee9b825 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/frame-drawn-desktop-browser-handles.ts @@ -0,0 +1,55 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { markWebSessionBrowserPlacementAdopted } from '../web-session-browser-placement' +import { findBrowserWorkspaceForRemotePage } from './mirrored-browser-tabs' +import type { WebSessionTabsBatchContext, WebSessionTabsSyncState } from './state' +import { writableWebSessionTabsRecord } from './state-equality-core' +import { isReadyBrowserTab } from './terminal-surfaces' + +/** + * In a frame-drawn workspace the frames own every tab, but a page this desktop hosts never enters + * the server's layout: the server's snapshot listing it is the proof its create landed. So only the + * handle of a staged client-hosted page is adopted here, as the mirror would (L282). + */ +export function adoptFrameDrawnClientHostedBrowserHandles( + state: WebSessionTabsSyncState, + snapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + worktreeId: string, + batchContext?: WebSessionTabsBatchContext +): Partial { + let handles = state.remoteBrowserPageHandlesByPageId + for (const tab of snapshot.tabs) { + if (!isReadyBrowserTab(tab) || tab.placement?.kind !== 'client') { + continue + } + const existing = findBrowserWorkspaceForRemotePage( + state, + worktreeId, + environmentId, + tab.browserPageId + ) + if (!existing || handles[existing.page.id]?.staged !== true) { + continue + } + if (handles === state.remoteBrowserPageHandlesByPageId) { + handles = writableWebSessionTabsRecord( + state, + 'remoteBrowserPageHandlesByPageId', + batchContext + ) + } + handles[existing.page.id] = { + environmentId, + remotePageId: tab.browserPageId, + placement: tab.placement + } + markWebSessionBrowserPlacementAdopted({ + environmentId, + worktreeId, + remotePageId: tab.browserPageId + }) + } + return handles === state.remoteBrowserPageHandlesByPageId + ? {} + : { remoteBrowserPageHandlesByPageId: handles } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/frame-drawn-server-workspaces.test.ts b/src/renderer/src/runtime/web-session-tabs-sync/frame-drawn-server-workspaces.test.ts index a41daed9048..46aefede7ff 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/frame-drawn-server-workspaces.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/frame-drawn-server-workspaces.test.ts @@ -162,6 +162,102 @@ describe('frame-drawn server workspaces', () => { }) }) + it("adopts a staged client-hosted page this desktop created once the server's snapshot lists it (L282)", () => { + const store = frameDrawnStore() + const placement = { + kind: 'client' as const, + browserHostClientId: 'this-desktop', + browserHostGeneration: 1, + pageHostGeneration: 1 + } + const staged: WebSessionTabsSyncState = { + ...store, + browserTabsByWorktree: { + ...store.browserTabsByWorktree, + [WT]: [ + { + id: 'ws-1', + worktreeId: WT, + label: undefined, + sessionProfileId: null, + activePageId: 'page-1', + pageIds: ['page-1'], + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: NOW + } + ] + }, + browserPagesByWorkspace: { + ...store.browserPagesByWorkspace, + 'ws-1': [ + { + id: 'page-1', + workspaceId: 'ws-1', + worktreeId: WT, + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: NOW, + browserRuntimeEnvironmentId: ENV + } + ] + }, + remoteBrowserPageHandlesByPageId: { + 'page-1': { + environmentId: ENV, + remotePageId: 'host-page-1', + staged: true, + stagedClientHosted: true + } + } + } + const snapshot = makeSnapshot( + [ + terminalSurface({ + hostTabId: 'host-tab-1', + leafId: LEAF_ID, + title: 'a', + terminal: 'terminal-1' + }), + { + type: 'browser' as const, + id: 'host-browser-1', + title: 'Example', + browserWorkspaceId: 'host-ws-1', + browserPageId: 'host-page-1', + placement, + url: 'https://example.com', + loading: false, + canGoBack: false, + canGoForward: false, + isActive: false + } + ], + { worktree: WT, snapshotVersion: 2 } + ) + + const patch = applyWebSessionTabsSnapshot(staged, snapshot, ENV, NOW) + + // The handle only: the tab and its page stay this desktop's, drawn by the frames' hold. + expect(patch.remoteBrowserPageHandlesByPageId?.['page-1']).toEqual({ + environmentId: ENV, + remotePageId: 'host-page-1', + placement + }) + expect(Object.hasOwn(patch, 'browserTabsByWorktree')).toBe(false) + expect(Object.hasOwn(patch, 'browserPagesByWorkspace')).toBe(false) + }) + it('still mirrors every slice for another workspace of the same environment, batched', () => { const store = frameDrawnStore() const frameTree = store.tabsByWorktree[WT] diff --git a/src/renderer/src/runtime/web-session-tabs-sync/frame-drawn-workspace-status-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/frame-drawn-workspace-status-patch.ts index df06bf41eac..65059d4ccef 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/frame-drawn-workspace-status-patch.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/frame-drawn-workspace-status-patch.ts @@ -6,6 +6,7 @@ import { buildRetractedMirroredTabSweepPatch } from './agent-status-primitives' import { collectCollidingRetractionPaneKeys } from './retraction-pane-ownership' +import { adoptFrameDrawnClientHostedBrowserHandles } from './frame-drawn-desktop-browser-handles' import { isWebSessionTabsWorktreeRemovalFrame } from './session-tabs-inventory-absence' import type { WebSessionTabsBatchContext, WebSessionTabsSyncState } from './state' import { writableWebSessionTabsRecord } from './state-equality-core' @@ -13,8 +14,9 @@ import { buildMirroredTerminalTabs } from './terminal-build' import { isMirroredTerminalSurfaceId, isTerminalSurfaceTab } from './terminal-surfaces' /** - * A frame-drawn workspace's share of one snapshot: agent status and unread markers only. Its tabs, - * layout, bindings and focus belong to the layout frames, so none of those slices are written. + * A frame-drawn workspace's share of one snapshot: agent status, unread markers, and the handles of + * client-hosted pages this desktop created. Its tabs, layout, bindings and focus belong to the + * layout frames, so none of those slices are written. */ export function buildFrameDrawnWorkspaceStatusPatch( state: WebSessionTabsSyncState, @@ -104,6 +106,13 @@ export function buildFrameDrawnWorkspaceStatusPatch( } const patch: Partial = { + ...adoptFrameDrawnClientHostedBrowserHandles( + state, + snapshot, + environmentId, + worktreeId, + batchContext + ), ...agentStatusPatch, ...retractedTabSweepPatch, ...remirroredClosedTabLiftPatch, diff --git a/src/renderer/src/store/workspace-layout-frame-browser-handover.test.ts b/src/renderer/src/store/workspace-layout-frame-browser-handover.test.ts new file mode 100644 index 00000000000..1c81666fc40 --- /dev/null +++ b/src/renderer/src/store/workspace-layout-frame-browser-handover.test.ts @@ -0,0 +1,54 @@ +// L284: a frame that draws a server tab for a page this desktop staged takes the create over. +import { afterEach, describe, expect, it } from 'vitest' +import { + _resetStagedBrowserFrameHandoversForTests, + wasStagedBrowserTabHandedToFrames +} from '@/lib/staged-browser-frame-handover' +import { noteFramesTakingOverStagedBrowserTabs } from './workspace-layout-frame-browser-handover' + +const ENV = 'env-1' + +function records( + workspaceId: string, + pageId: string, + handle: { remotePageId: string; staged?: true } +) { + return { + browserPagesByWorkspace: { [workspaceId]: [{ id: pageId }] }, + remoteBrowserPageHandlesByPageId: { + [pageId]: { ...handle, environmentId: ENV } + } + } +} + +afterEach(() => _resetStagedBrowserFrameHandoversForTests()) + +describe("a frame drawing the server's tab for a staged page", () => { + it('records the handover when the staged tab drops and the same page is drawn', () => { + const state = records('staged-ws', 'staged-page', { remotePageId: 'page-1', staged: true }) + // The frame's page has the server's id and, at this point, no handle. + const built = { browserPagesByWorkspace: { 'server-ws': [{ id: 'page-1' }] } } + + noteFramesTakingOverStagedBrowserTabs(state, built, ['staged-ws'], new Set(['server-ws'])) + + expect(wasStagedBrowserTabHandedToFrames('staged-ws', 'page-1')).toBe(true) + }) + + it.each([ + ['the dropped tab was not staged', { remotePageId: 'page-1' }, 'server-ws', 'page-1'], + [ + 'the frame drew another page', + { remotePageId: 'page-1', staged: true }, + 'server-ws', + 'page-2' + ], + ['no drawn tab holds the page', { remotePageId: 'page-1', staged: true }, 'other-ws', 'page-1'] + ] as const)('records nothing when %s', (_, stagedHandle, builtTab, builtPage) => { + const state = records('staged-ws', 'staged-page', stagedHandle) + const built = { browserPagesByWorkspace: { [builtTab]: [{ id: builtPage }] } } + + noteFramesTakingOverStagedBrowserTabs(state, built, ['staged-ws'], new Set(['server-ws'])) + + expect(wasStagedBrowserTabHandedToFrames('staged-ws', 'page-1')).toBe(false) + }) +}) diff --git a/src/renderer/src/store/workspace-layout-frame-browser-handover.ts b/src/renderer/src/store/workspace-layout-frame-browser-handover.ts new file mode 100644 index 00000000000..8ec8d748100 --- /dev/null +++ b/src/renderer/src/store/workspace-layout-frame-browser-handover.ts @@ -0,0 +1,49 @@ +import { noteStagedBrowserTabHandedToFrames } from '@/lib/staged-browser-frame-handover' + +/** Only what the handover reads of the store's browser pages and their handles. */ +type BrowserPages = { + browserPagesByWorkspace: Readonly> +} +type BrowserRecords = BrowserPages & { + remoteBrowserPageHandlesByPageId: Readonly< + Record + > +} + +/** + * Records each staged browser tab of this desktop that a frame drops while drawing a server tab of + * the same page in the same workspaces: the frames took the create over (L284). The frames' worktrees + * are one server's, so the page id alone names the page. + */ +export function noteFramesTakingOverStagedBrowserTabs( + state: BrowserRecords, + built: BrowserPages, + droppedTabIds: readonly string[], + drawnTabIds: ReadonlySet +): void { + if (droppedTabIds.length === 0) { + return + } + // A frame's page id is the server's page id, which the staged handle names as its remote id. + const drawnPageIds = new Set( + [...drawnTabIds].flatMap((tabId) => + (built.browserPagesByWorkspace[tabId] ?? []).map((page) => page.id) + ) + ) + // Before scanning the dropped pages: a close drops many pages and draws none of a staged create's. + if ( + !Object.values(state.remoteBrowserPageHandlesByPageId).some( + (handle) => handle?.staged === true && drawnPageIds.has(handle.remotePageId) + ) + ) { + return + } + for (const tabId of droppedTabIds) { + for (const page of state.browserPagesByWorkspace[tabId] ?? []) { + const handle = state.remoteBrowserPageHandlesByPageId[page.id] + if (handle?.staged === true && drawnPageIds.has(handle.remotePageId)) { + noteStagedBrowserTabHandedToFrames(tabId, handle.remotePageId) + } + } + } +} diff --git a/src/renderer/src/store/workspace-layout-frame-store-patch.ts b/src/renderer/src/store/workspace-layout-frame-store-patch.ts index 6a150f11436..9123fd643a1 100644 --- a/src/renderer/src/store/workspace-layout-frame-store-patch.ts +++ b/src/renderer/src/store/workspace-layout-frame-store-patch.ts @@ -2,6 +2,7 @@ // equal to the store's keeps the store's object, so an unchanged frame re-renders nothing; a // record the store already holds keeps what only this window knows about it (a terminal row's // in-flight spawn and recovery state, an open file's editor state, a browser tab's page state). +// Building also notes a staged browser tab the frames take over (L284): every built patch is applied. import { BROWSER_TAB_LAYOUT_FIELDS } from '../../../shared/workspace-layout/workspace-layout-beside' import { stableJson } from '../../../shared/workspace-layout/workspace-layout-load-report' @@ -14,6 +15,7 @@ import { withHeldTabsAsDrawn, type HeldDrawnTabs } from './workspace-layout-frame-held-tabs' +import { noteFramesTakingOverStagedBrowserTabs } from './workspace-layout-frame-browser-handover' /** Row fields this window owns: what its mounted panes are doing, and which terminal each row * binds (main publishes the saved binding; the live one stays the window's until main owns it). */ @@ -258,6 +260,7 @@ export function buildLayoutFrameStorePatch( .flatMap((key) => state.browserTabsByWorktree[key] ?? []) .map((tab) => tab.id) .filter((id) => !builtBrowserTabIds.has(id)) + noteFramesTakingOverStagedBrowserTabs(state, built, closedBrowserTabIds, builtBrowserTabIds) const closedPageIds = new Set( closedBrowserTabIds.flatMap((id) => (state.browserPagesByWorkspace[id] ?? []).map((page) => page.id)