mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
A browser tab opened in a server workspace drawn from the server's layout stays open (switch-on builds only) (#31898)
* fix(L273-core): tabs this desktop created in a paired server's frame-drawn workspace survive the server's frames (switch-on) A tab the server's layout never lists (a client-hosted browser tab today; any kind recorded the same way) used to vanish on the server's next frame rebuild. This desktop now records such tabs by server and workspace, in its localStorage only (never a session field nor sent), and the server's frame builds keep them as drawn, inside the server's own groups: - A held tab drawn in a group the server lacks moves to the nearest server group; it shows there only if the user was on it, and the user's group follows it. The group tree stays the server's. - The record forgets an id once the server's frame lists it, on close, and on worktree removal, and prunes ids the restored session no longer has before the first frame after a reload. - A held tab's drag moves it here only (no session.tabs.move); it offers no new split, and a server tab sharing its group counts only the server's tabs for split no-ops. - Client-hosted staging records its tab (both ids) in layout-owner builds; restage takes the worktree from its caller instead of scanning the store. * refactor(L273-core): the desktop-created record is inert with the switch off; clearer names - With the switch off the record is never read, kept or written (a single gate in the record). - The frame-drawn check is isDesktopCreatedTabInFrameDrawnServerWorkspace in frame-drawn-desktop-created-tabs.ts: "held" stays the frame hold's word. - The held-tab placement drops its unread activeGroupId; the group follow derives it. * fix(L282): a client-hosted browser tab this desktop creates in a frame-drawn server workspace completes once the server's snapshot lists it, instead of undoing itself after 8 s (switch-on) * fix(L284): a server-hosted browser tab this desktop creates in a frame-drawn server workspace completes when the server's frames take it over, instead of reading as the user's close (switch-on) * chore(L282/L284): adopt only staged client-hosted handles, and name the frame build's handover note (review nits) * fix(L284): a staged tab the server's frames took over completes the create though its pane, unmounted, has no handle yet (layout-owner build only) * fix(browser): a switch-on host answers a paired client's browser create with the new page's id, not its workspace's, before main's frame draws it * fix(browser): the reply names the page id the create asked for first, then the new record's own page (L291) * fix(L284): the frame build scans dropped pages for a handover only when a staged create's page is drawn, so a large close stays one pass
This commit is contained in:
@@ -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))
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, string>()
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<boolean> {
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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<void> {
|
||||
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.
|
||||
|
||||
@@ -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<WebSessionTabsSyncState> {
|
||||
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 }
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
+11
-2
@@ -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<WebSessionTabsSyncState> = {
|
||||
...adoptFrameDrawnClientHostedBrowserHandles(
|
||||
state,
|
||||
snapshot,
|
||||
environmentId,
|
||||
worktreeId,
|
||||
batchContext
|
||||
),
|
||||
...agentStatusPatch,
|
||||
...retractedTabSweepPatch,
|
||||
...remirroredClosedTabLiftPatch,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<Record<string, readonly { id: string }[] | undefined>>
|
||||
}
|
||||
type BrowserRecords = BrowserPages & {
|
||||
remoteBrowserPageHandlesByPageId: Readonly<
|
||||
Record<string, { environmentId: string; remotePageId: string; staged?: boolean } | undefined>
|
||||
>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string>
|
||||
): 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user