diff --git a/src/renderer/src/lib/file-preview.test.ts b/src/renderer/src/lib/file-preview.test.ts index bea6e40a3be..23df643b51b 100644 --- a/src/renderer/src/lib/file-preview.test.ts +++ b/src/renderer/src/lib/file-preview.test.ts @@ -2,18 +2,33 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { REMOTE_FILE_BROWSER_UNSUPPORTED_MESSAGE, getWorkspaceFileBrowserOpenTarget, - openFileInBrowserTab + openFileInBrowserTab, + openFilePreviewToSide } from './file-preview' const mocks = vi.hoisted(() => ({ createBrowserTab: vi.fn(), + createEmptySplitGroup: vi.fn(() => 'group-2'), + createWebRuntimeSessionBrowserTab: vi.fn(), + environmentId: null as string | null, connectionId: null as string | null })) +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: () => mocks.environmentId +})) + +vi.mock('@/runtime/web-runtime-session', () => ({ + createWebRuntimeSessionBrowserTab: mocks.createWebRuntimeSessionBrowserTab +})) + vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ createBrowserTab: mocks.createBrowserTab, + createEmptySplitGroup: mocks.createEmptySplitGroup, + groupsByWorktree: {}, + layoutByWorktree: {}, repos: [{ id: 'repo-1', connectionId: mocks.connectionId }], worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] @@ -24,6 +39,8 @@ vi.mock('@/store', () => ({ beforeEach(() => { vi.clearAllMocks() + mocks.createWebRuntimeSessionBrowserTab.mockResolvedValue(true) + mocks.environmentId = null mocks.connectionId = null }) @@ -40,6 +57,43 @@ describe('openFileInBrowserTab', () => { }) }) + it('creates paired-runtime file browsers at the owning host', () => { + mocks.environmentId = 'runtime-1' + + openFileInBrowserTab({ filePath: '/srv/repo/example.html', worktreeId: 'wt-1' }) + + expect(mocks.createWebRuntimeSessionBrowserTab).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + environmentId: 'runtime-1', + url: 'file:///srv/repo/example.html', + stagedTitle: 'example.html', + stagedFocusAddressBar: false + }) + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + }) + + it('creates paired-runtime side previews in the requested split', () => { + mocks.environmentId = 'runtime-1' + + openFilePreviewToSide({ + language: 'html', + filePath: '/srv/repo/example.html', + worktreeId: 'wt-1', + sourceGroupId: 'group-1' + }) + + expect(mocks.createWebRuntimeSessionBrowserTab).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + environmentId: 'runtime-1', + url: 'file:///srv/repo/example.html', + clientTargetGroupId: 'group-2', + clientTargetGroupCreated: true, + stagedTitle: 'example.html', + stagedFocusAddressBar: false + }) + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + }) + it('returns unsupported for SSH worktrees without creating a local file URL tab', () => { mocks.connectionId = 'ssh-1' diff --git a/src/renderer/src/lib/file-preview.ts b/src/renderer/src/lib/file-preview.ts index fc23d680ad1..99cd8b56357 100644 --- a/src/renderer/src/lib/file-preview.ts +++ b/src/renderer/src/lib/file-preview.ts @@ -1,12 +1,23 @@ import { toast } from 'sonner' import { absolutePathToFileUri } from '@/components/editor/markdown-internal-links' import { getConnectionId } from '@/lib/connection-context' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { createWebRuntimeSessionBrowserTab } from '@/runtime/web-runtime-session' import { useAppStore } from '@/store' import { findSiblingGroupId } from '@/store/slices/tabs' export type PreviewableLanguage = 'html' export const REMOTE_FILE_BROWSER_UNSUPPORTED_MESSAGE = 'Open in Orca Browser is only available for local files.' +const FILE_BROWSER_OPEN_FAILED_MESSAGE = 'Unable to open this file in Orca Browser.' + +function reportRemoteFileBrowserOpen(result: Promise): void { + void result.then((created) => { + if (!created) { + toast.error(FILE_BROWSER_OPEN_FAILED_MESSAGE) + } + }) +} export type WorkspaceFileBrowserOpenTarget = | { @@ -51,6 +62,19 @@ export function openFileInBrowserTab(params: { } const state = useAppStore.getState() + const environmentId = getRuntimeEnvironmentIdForWorktree(state, params.worktreeId) + if (environmentId) { + reportRemoteFileBrowserOpen( + createWebRuntimeSessionBrowserTab({ + worktreeId: params.worktreeId, + environmentId, + url: target.url, + stagedTitle: target.title, + stagedFocusAddressBar: false + }) + ) + return target + } state.createBrowserTab(params.worktreeId, target.url, { title: target.title, @@ -114,6 +138,22 @@ export function openFilePreviewToSide(params: { return } + const environmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) + if (environmentId) { + reportRemoteFileBrowserOpen( + createWebRuntimeSessionBrowserTab({ + worktreeId, + environmentId, + url: target.url, + clientTargetGroupId: targetGroupId, + clientTargetGroupCreated: !existingSibling, + stagedTitle: target.title, + stagedFocusAddressBar: false + }) + ) + return + } + state.createBrowserTab(worktreeId, target.url, { title: target.title, targetGroupId, diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index 71ecd304f96..e4cc677406d 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -34,12 +34,18 @@ import { resetWebAgentSessionHandoffsForTests } from './web-agent-session-handoff' import { toRuntimeExecutionHostId } from '../../../shared/execution-host' +import { + isWebSessionBrowserPlacementGroupReserved, + resetWebSessionBrowserPlacementsForTests +} from './web-session-browser-placement' const mocks = vi.hoisted(() => ({ getState: vi.fn(), setState: vi.fn(), setActiveWorktree: vi.fn(), createBrowserTab: vi.fn(), + closeEmptyGroup: vi.fn(), + moveUnifiedTabToGroup: vi.fn(), setRemoteBrowserPageHandle: vi.fn(), focusBrowserTabInWorktree: vi.fn(), applyFreshWebSessionTabsSnapshot: vi.fn(), @@ -252,7 +258,10 @@ describe('createWebRuntimeSessionBrowserTab', () => { activeWorktreeId: WORKTREE_ID, browserPagesByWorkspace: {}, remoteBrowserPageHandlesByPageId: {}, + unifiedTabsByWorktree: {}, createBrowserTab: mocks.createBrowserTab, + closeEmptyGroup: mocks.closeEmptyGroup, + moveUnifiedTabToGroup: mocks.moveUnifiedTabToGroup, setRemoteBrowserPageHandle: mocks.setRemoteBrowserPageHandle, focusBrowserTabInWorktree: mocks.focusBrowserTabInWorktree, setActiveWorktree: mocks.setActiveWorktree @@ -268,6 +277,7 @@ describe('createWebRuntimeSessionBrowserTab', () => { activePageId: 'local-page-1', pageIds: ['local-page-1'] }) + mocks.moveUnifiedTabToGroup.mockReturnValue(true) mocks.applyFreshWebSessionTabsSnapshot.mockReturnValue({ state: 'after' }) mocks.resolveHostSessionTabIdForWebSessionTab.mockReturnValue(null) mocks.deliverLaunchPromptToAgentTab.mockResolvedValue(true) @@ -276,6 +286,7 @@ describe('createWebRuntimeSessionBrowserTab', () => { afterEach(() => { vi.unstubAllGlobals() clearRuntimeCompatibilityCacheForTests() + resetWebSessionBrowserPlacementsForTests() vi.clearAllMocks() }) @@ -350,6 +361,86 @@ describe('createWebRuntimeSessionBrowserTab', () => { }) }) + it('keeps the requested split reserved until a pre-published browser gains a unified tab', async () => { + mocks.getState.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: ENVIRONMENT_ID }, + activeWorktreeId: WORKTREE_ID, + browserPagesByWorkspace: { + 'host-browser-workspace': [ + { id: 'host-browser-page', workspaceId: 'host-browser-workspace' } + ] + }, + remoteBrowserPageHandlesByPageId: { + 'host-browser-page': { + environmentId: ENVIRONMENT_ID, + remotePageId: 'remote-browser-page-1' + } + }, + unifiedTabsByWorktree: { [WORKTREE_ID]: [] }, + createBrowserTab: mocks.createBrowserTab, + closeEmptyGroup: mocks.closeEmptyGroup, + moveUnifiedTabToGroup: mocks.moveUnifiedTabToGroup, + setRemoteBrowserPageHandle: mocks.setRemoteBrowserPageHandle, + focusBrowserTabInWorktree: mocks.focusBrowserTabInWorktree, + setActiveWorktree: mocks.setActiveWorktree + }) + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'create', + ok: true, + result: { browserPageId: 'remote-browser-page-1' } + }) + .mockResolvedValueOnce({ id: 'list', ok: true, result: makeSnapshot() }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + await expect( + createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + environmentId: ENVIRONMENT_ID, + clientTargetGroupId: 'client-preview-group' + }) + ).resolves.toBe(true) + + expect( + isWebSessionBrowserPlacementGroupReserved({ + environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, + groupId: 'client-preview-group' + }) + ).toBe(true) + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + expect(mocks.closeEmptyGroup).not.toHaveBeenCalled() + }) + + it('releases and removes a newly-created split when host creation fails', async () => { + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: vi.fn().mockRejectedValue(new Error('offline')) + } + } + }) + + await expect( + createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + environmentId: ENVIRONMENT_ID, + clientTargetGroupId: 'client-preview-group', + clientTargetGroupCreated: true + }) + ).resolves.toBe(false) + + expect(mocks.closeEmptyGroup).toHaveBeenCalledWith(WORKTREE_ID, 'client-preview-group') + expect( + isWebSessionBrowserPlacementGroupReserved({ + environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, + groupId: 'client-preview-group' + }) + ).toBe(false) + }) + it('keeps the requested worktree selected while the browser snapshot catches up', async () => { const snapshot = makeSnapshot() const setStateResults: unknown[] = [] diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index afe64a8097e..efd30bd3756 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -36,7 +36,10 @@ import { } from './agent-session-create-operation' import { parseRemoteRuntimePtyId } from './runtime-terminal-stream' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' -import { recordWebSessionFocusIntent } from './web-session-focus-intent' +import { + clearWebSessionFocusIntentIfMatches, + recordWebSessionFocusIntent +} from './web-session-focus-intent' import { clearWebSessionCloseIntent, recordWebSessionCloseIntent } from './web-session-close-intent' import { clearWebSessionReorderIntent, @@ -61,6 +64,11 @@ import { translate } from '../i18n/i18n' import { getRuntimeEnvironmentRevision } from './runtime-environment-revision' import { parsePaneKey } from '../../../shared/stable-pane-id' import { toRuntimeExecutionHostId } from '../../../shared/execution-host' +import { + recordWebSessionBrowserPlacement, + releaseWebSessionBrowserPlacementGroup, + reserveWebSessionBrowserPlacementGroup +} from './web-session-browser-placement' export { HOST_TERMINAL_SURFACE_SEPARATOR, @@ -423,6 +431,8 @@ export async function createWebRuntimeSessionBrowserTab(args: { url?: string profileId?: string | null targetGroupId?: string + clientTargetGroupId?: string + clientTargetGroupCreated?: boolean selectWorktree?: boolean stagedTitle?: string stagedFocusAddressBar?: boolean @@ -440,6 +450,13 @@ export async function createWebRuntimeSessionBrowserTab(args: { const shouldSelectWorktree = args.selectWorktree !== false const stagedFromWorktreeId = useAppStore.getState().activeWorktreeId + if (args.clientTargetGroupId) { + reserveWebSessionBrowserPlacementGroup({ + environmentId, + worktreeId: args.worktreeId, + groupId: args.clientTargetGroupId + }) + } if (shouldSelectWorktree) { selectWebRuntimeSessionWorktree(args.worktreeId, environmentId) } @@ -464,12 +481,20 @@ export async function createWebRuntimeSessionBrowserTab(args: { if (matchesWebSessionIntentOwner(intentOwner)) { recordWebSessionFocusIntent(intentOwner, args.worktreeId, created.browserPageId) } - stageWebRuntimeBrowserTab({ + if (args.clientTargetGroupId) { + recordWebSessionBrowserPlacement({ + environmentId, + worktreeId: args.worktreeId, + remotePageId: created.browserPageId, + groupId: args.clientTargetGroupId + }) + } + const staged = stageWebRuntimeBrowserTab({ environmentId, worktreeId: args.worktreeId, remotePageId: created.browserPageId, url: args.url, - targetGroupId: args.targetGroupId, + targetGroupId: args.clientTargetGroupId ?? args.targetGroupId, title: args.stagedTitle, focusAddressBar: args.stagedFocusAddressBar, restoreFocus: @@ -477,11 +502,53 @@ export async function createWebRuntimeSessionBrowserTab(args: { (stagedFromWorktreeId === args.worktreeId || useAppStore.getState().activeWorktreeId === args.worktreeId) }) - void refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId, { - expectedEnvironmentPairingRevision: intentOwner.pairingRevision - }) + try { + await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId, { + expectedEnvironmentPairingRevision: intentOwner.pairingRevision + }) + } catch (error) { + console.warn( + '[web-runtime-session] browser created but reconciliation failed:', + error instanceof Error ? error.message : String(error) + ) + } + if (!staged) { + const stagedAfterRefresh = stageWebRuntimeBrowserTab({ + environmentId, + worktreeId: args.worktreeId, + remotePageId: created.browserPageId, + url: args.url, + targetGroupId: args.clientTargetGroupId, + title: args.stagedTitle, + focusAddressBar: args.stagedFocusAddressBar, + restoreFocus: shouldSelectWorktree + }) + if (stagedAfterRefresh && args.clientTargetGroupId) { + releaseWebSessionBrowserPlacementGroup({ + environmentId, + worktreeId: args.worktreeId, + groupId: args.clientTargetGroupId + }) + } + } else if (args.clientTargetGroupId) { + releaseWebSessionBrowserPlacementGroup({ + environmentId, + worktreeId: args.worktreeId, + groupId: args.clientTargetGroupId + }) + } return true } catch (error) { + if (args.clientTargetGroupId) { + releaseWebSessionBrowserPlacementGroup({ + environmentId, + worktreeId: args.worktreeId, + groupId: args.clientTargetGroupId + }) + if (args.clientTargetGroupCreated) { + useAppStore.getState().closeEmptyGroup(args.worktreeId, args.clientTargetGroupId) + } + } if (args.failureLogMode === 'operation-only') { console.warn('[web-runtime-session] failed to create browser tab') } else { @@ -503,10 +570,10 @@ function stageWebRuntimeBrowserTab(args: { title?: string focusAddressBar?: boolean restoreFocus?: boolean -}): void { +}): boolean { const remotePageId = args.remotePageId.trim() if (!remotePageId) { - return + return false } const existing = findLocalBrowserPageForRemotePage( @@ -519,12 +586,27 @@ function stageWebRuntimeBrowserTab(args: { } if (existing) { + if (args.targetGroupId) { + const state = useAppStore.getState() + const unifiedTab = (state.unifiedTabsByWorktree[args.worktreeId] ?? []).find( + (tab) => tab.contentType === 'browser' && tab.entityId === existing.workspaceId + ) + if (!unifiedTab) { + return false + } + if ( + unifiedTab.groupId !== args.targetGroupId && + !state.moveUnifiedTabToGroup(unifiedTab.id, args.targetGroupId, { activate: true }) + ) { + return false + } + } if (args.restoreFocus !== false) { useAppStore .getState() .focusBrowserTabInWorktree(args.worktreeId, existing.pageId, { surfacePane: true }) } - return + return true } const url = args.url?.trim() || 'about:blank' @@ -537,28 +619,36 @@ function stageWebRuntimeBrowserTab(args: { }) const pageId = browserTab.activePageId ?? browserTab.pageIds?.[0] ?? null if (!pageId) { - return + return false } useAppStore.getState().setRemoteBrowserPageHandle(pageId, { environmentId: args.environmentId, remotePageId }) + return true } function selectWebRuntimeSessionWorktree(worktreeId: string, environmentId: string): void { - useAppStore.getState().setActiveWorktree(worktreeId, toRuntimeExecutionHostId(environmentId)) + const state = useAppStore.getState() + if ( + state.activeWorktreeId === worktreeId && + state.activeWorkspaceExecutionHostId === toRuntimeExecutionHostId(environmentId) + ) { + return + } + state.setActiveWorktree(worktreeId, toRuntimeExecutionHostId(environmentId)) } function findLocalBrowserPageForRemotePage( state: AppState, environmentId: string, remotePageId: string -): { pageId: string } | null { - for (const pages of Object.values(state.browserPagesByWorkspace)) { +): { pageId: string; workspaceId: string } | null { + for (const [workspaceId, pages] of Object.entries(state.browserPagesByWorkspace)) { for (const page of pages) { const handle = state.remoteBrowserPageHandlesByPageId[page.id] if (handle?.environmentId === environmentId && handle.remotePageId === remotePageId) { - return { pageId: page.id } + return { pageId: page.id, workspaceId } } } } @@ -873,6 +963,7 @@ async function callWebRuntimeSessionTabMethod( const intentOwner = captureWebSessionIntentOwner(environmentId) const callEnvironment = captureRuntimeEnvironmentCall(environmentId, intentOwner.pairingRevision) const closeIntentTabIds = new Set() + let activationHostTabId: string | null = null const isClose = method === 'session.tabs.close' const isLifecycleClose = isClose && args.reason !== 'user' @@ -898,6 +989,11 @@ async function callWebRuntimeSessionTabMethod( try { const { resolveHostSessionTabIdForWebSessionTab } = await import('./web-session-tabs-sync') const state = useAppStore.getState() + const isBrowserActivation = + !isClose && + (state.unifiedTabsByWorktree?.[args.worktreeId] ?? []).some( + (tab) => tab.id === args.tabId && tab.contentType === 'browser' + ) const hostTabId = resolveHostSessionTabIdForWebSessionTab(state, { environmentId, @@ -908,6 +1004,9 @@ async function callWebRuntimeSessionTabMethod( // Why: suppress until the host confirms removal, else an in-flight pre-close snapshot flashes the tab back. closeIntentTabIds.add(hostTabId) recordWebSessionCloseIntent(intentOwner, args.worktreeId, hostTabId, Date.now()) + } else if (isBrowserActivation) { + activationHostTabId = hostTabId + recordWebSessionFocusIntent(intentOwner, args.worktreeId, hostTabId) } const response = await callEnvironment({ // Why: old hosts cannot route this additive method, so a generation @@ -956,6 +1055,9 @@ async function callWebRuntimeSessionTabMethod( } return true } catch (error) { + if (activationHostTabId) { + clearWebSessionFocusIntentIfMatches(intentOwner, args.worktreeId, activationHostTabId) + } for (const hostTabId of closeIntentTabIds) { clearWebSessionCloseIntent(intentOwner, args.worktreeId, hostTabId) } diff --git a/src/renderer/src/runtime/web-session-browser-placement.test.ts b/src/renderer/src/runtime/web-session-browser-placement.test.ts new file mode 100644 index 00000000000..55d7cd5c74f --- /dev/null +++ b/src/renderer/src/runtime/web-session-browser-placement.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + clearWebSessionBrowserPlacementsForEnvironment, + clearWebSessionBrowserPlacementsForWorktree, + getWebSessionBrowserPlacementGroup, + isWebSessionBrowserPlacementGroupReserved, + recordWebSessionBrowserPlacement, + reserveWebSessionBrowserPlacementGroup, + resetWebSessionBrowserPlacementsForTests +} from './web-session-browser-placement' + +const ENVIRONMENT_ID = 'environment-1' +const WORKTREE_ID = 'worktree-1' + +afterEach(resetWebSessionBrowserPlacementsForTests) + +describe('web session browser placement', () => { + it('bounds pending page placements and group reservations', () => { + for (let index = 0; index < 129; index += 1) { + recordWebSessionBrowserPlacement({ + environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, + remotePageId: `page-${index}`, + groupId: `group-${index}` + }) + reserveWebSessionBrowserPlacementGroup({ + environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, + groupId: `group-${index}` + }) + } + + expect( + getWebSessionBrowserPlacementGroup({ + environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, + remotePageId: 'page-0' + }) + ).toBeUndefined() + expect( + isWebSessionBrowserPlacementGroupReserved({ + environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, + groupId: 'group-0' + }) + ).toBe(false) + expect( + getWebSessionBrowserPlacementGroup({ + environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, + remotePageId: 'page-128' + }) + ).toBe('group-128') + }) + + it('clears only the requested worktree or environment', () => { + for (const [environmentId, worktreeId, suffix] of [ + [ENVIRONMENT_ID, WORKTREE_ID, 'target'], + [ENVIRONMENT_ID, 'worktree-2', 'sibling'], + ['environment-2', WORKTREE_ID, 'other-environment'] + ] as const) { + recordWebSessionBrowserPlacement({ + environmentId, + worktreeId, + remotePageId: `page-${suffix}`, + groupId: `group-${suffix}` + }) + reserveWebSessionBrowserPlacementGroup({ + environmentId, + worktreeId, + groupId: `group-${suffix}` + }) + } + + clearWebSessionBrowserPlacementsForWorktree(ENVIRONMENT_ID, WORKTREE_ID) + + expect( + getWebSessionBrowserPlacementGroup({ + environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, + remotePageId: 'page-target' + }) + ).toBeUndefined() + expect( + getWebSessionBrowserPlacementGroup({ + environmentId: ENVIRONMENT_ID, + worktreeId: 'worktree-2', + remotePageId: 'page-sibling' + }) + ).toBe('group-sibling') + + clearWebSessionBrowserPlacementsForEnvironment('environment-2') + + expect( + getWebSessionBrowserPlacementGroup({ + environmentId: 'environment-2', + worktreeId: WORKTREE_ID, + remotePageId: 'page-other-environment' + }) + ).toBeUndefined() + }) +}) diff --git a/src/renderer/src/runtime/web-session-browser-placement.ts b/src/renderer/src/runtime/web-session-browser-placement.ts new file mode 100644 index 00000000000..a5bd07231b9 --- /dev/null +++ b/src/renderer/src/runtime/web-session-browser-placement.ts @@ -0,0 +1,131 @@ +type BrowserPlacement = { groupId: string; seen: boolean } + +const placementsByPage = new Map() +const reservedGroups = new Set() +const MAX_PENDING_PLACEMENTS = 128 + +function evictOldest(entries: Map | Set): void { + const oldest = entries.keys().next().value + if (oldest !== undefined) { + entries.delete(oldest) + } +} + +function pageKey(environmentId: string, worktreeId: string, remotePageId: string): string { + return `${environmentId}\0${worktreeId}\0${remotePageId}` +} + +function groupKey(environmentId: string, worktreeId: string, groupId: string): string { + return `${environmentId}\0${worktreeId}\0${groupId}` +} + +function worktreePrefix(environmentId: string, worktreeId: string): string { + return `${environmentId}\0${worktreeId}\0` +} + +export function reserveWebSessionBrowserPlacementGroup(args: { + environmentId: string + worktreeId: string + groupId: string +}): void { + while (reservedGroups.size >= MAX_PENDING_PLACEMENTS) { + evictOldest(reservedGroups) + } + reservedGroups.add(groupKey(args.environmentId, args.worktreeId, args.groupId)) +} + +export function releaseWebSessionBrowserPlacementGroup(args: { + environmentId: string + worktreeId: string + groupId: string +}): void { + reservedGroups.delete(groupKey(args.environmentId, args.worktreeId, args.groupId)) +} + +export function isWebSessionBrowserPlacementGroupReserved(args: { + environmentId: string + worktreeId: string + groupId: string +}): boolean { + return reservedGroups.has(groupKey(args.environmentId, args.worktreeId, args.groupId)) +} + +export function recordWebSessionBrowserPlacement(args: { + environmentId: string + worktreeId: string + remotePageId: string + groupId: string +}): void { + while (placementsByPage.size >= MAX_PENDING_PLACEMENTS) { + evictOldest(placementsByPage) + } + placementsByPage.set(pageKey(args.environmentId, args.worktreeId, args.remotePageId), { + groupId: args.groupId, + seen: false + }) +} + +export function getWebSessionBrowserPlacementGroup(args: { + environmentId: string + worktreeId: string + remotePageId: string +}): string | undefined { + const placement = placementsByPage.get( + pageKey(args.environmentId, args.worktreeId, args.remotePageId) + ) + if (placement) { + placement.seen = true + releaseWebSessionBrowserPlacementGroup({ ...args, groupId: placement.groupId }) + } + return placement?.groupId +} + +export function pruneWebSessionBrowserPlacements( + environmentId: string, + worktreeId: string, + liveRemotePageIds: ReadonlySet +): void { + const prefix = worktreePrefix(environmentId, worktreeId) + for (const [key, placement] of placementsByPage) { + const remotePageId = key.startsWith(prefix) ? key.slice(prefix.length) : null + if (remotePageId && placement.seen && !liveRemotePageIds.has(remotePageId)) { + placementsByPage.delete(key) + } + } +} + +export function clearWebSessionBrowserPlacementsForWorktree( + environmentId: string, + worktreeId: string +): void { + const prefix = worktreePrefix(environmentId, worktreeId) + for (const key of placementsByPage.keys()) { + if (key.startsWith(prefix)) { + placementsByPage.delete(key) + } + } + for (const key of reservedGroups) { + if (key.startsWith(prefix)) { + reservedGroups.delete(key) + } + } +} + +export function clearWebSessionBrowserPlacementsForEnvironment(environmentId: string): void { + const prefix = `${environmentId}\0` + for (const key of placementsByPage.keys()) { + if (key.startsWith(prefix)) { + placementsByPage.delete(key) + } + } + for (const key of reservedGroups) { + if (key.startsWith(prefix)) { + reservedGroups.delete(key) + } + } +} + +export function resetWebSessionBrowserPlacementsForTests(): void { + placementsByPage.clear() + reservedGroups.clear() +} diff --git a/src/renderer/src/runtime/web-session-focus-intent.ts b/src/renderer/src/runtime/web-session-focus-intent.ts index 8ed33b8d15e..e4d94e12f0f 100644 --- a/src/renderer/src/runtime/web-session-focus-intent.ts +++ b/src/renderer/src/runtime/web-session-focus-intent.ts @@ -50,6 +50,17 @@ export function clearWebSessionFocusIntent(owner: WebSessionIntentOwner, worktre pendingFocusByOwnerAndWorktree.delete(focusIntentPartitionKey(owner, worktreeId)) } +export function clearWebSessionFocusIntentIfMatches( + owner: WebSessionIntentOwner, + worktreeId: string, + hostTabId: string +): void { + const key = focusIntentPartitionKey(owner, worktreeId) + if (pendingFocusByOwnerAndWorktree.get(key)?.hostTabId === hostTabId) { + pendingFocusByOwnerAndWorktree.delete(key) + } +} + export function clearWebSessionFocusIntentsForOwner(owner: WebSessionIntentOwner): void { const prefix = `${webSessionIntentOwnerKey(owner)}\0` for (const key of pendingFocusByOwnerAndWorktree.keys()) { 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 cad1147ed1e..5324eac70be 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -20,13 +20,24 @@ import { recordWebSessionReorderIntent, resetWebSessionReorderIntentForTests } from './web-session-reorder-intent' -import type { BrowserPage, BrowserWorkspace, Tab, TerminalTab } from '../../../shared/types' +import type { + BrowserPage, + BrowserWorkspace, + Tab, + TabGroupLayoutNode, + TerminalTab +} from '../../../shared/types' import type { OpenFile } from '../store/slices/editor' import { confirmWebAgentSessionHandoffAfterCreate, recordWebAgentSessionHandoff, resetWebAgentSessionHandoffsForTests } from './web-agent-session-handoff' +import { + getWebSessionBrowserPlacementGroup, + recordWebSessionBrowserPlacement, + reserveWebSessionBrowserPlacementGroup +} from './web-session-browser-placement' import { _getWebSessionTabsTrackingCountsForTest, acceptReplayedWebSessionTabsSnapshot, @@ -58,6 +69,16 @@ const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222' const THIRD_LEAF_ID = '33333333-3333-4333-8333-333333333333' const HOST_SURFACE_ID = `host-tab-1::${LEAF_ID}` +function layoutHasGroup(layout: TabGroupLayoutNode | undefined, groupId: string): boolean { + if (!layout) { + return false + } + if (layout.type === 'leaf') { + return layout.groupId === groupId + } + return layoutHasGroup(layout.first, groupId) || layoutHasGroup(layout.second, groupId) +} + function makeState(overrides: Partial = {}): WebSessionTabsSyncState { return { activeBrowserTabId: null, @@ -3911,6 +3932,187 @@ describe('applyWebSessionTabsSnapshot', () => { expect(patch.layoutByWorktree).toBeUndefined() }) + it('keeps one remote browser in its client-owned side-preview group', () => { + const editorGroupId = 'client-editor-group' + const previewGroupId = 'client-preview-group' + recordWebSessionBrowserPlacement({ + environmentId: ENV, + worktreeId: WT, + remotePageId: 'host-browser-page', + groupId: previewGroupId + }) + + const patch = applyWebSessionTabsSnapshot( + makeState({ + groupsByWorktree: { + [WT]: [ + { + id: editorGroupId, + worktreeId: WT, + activeTabId: 'local-editor', + tabOrder: ['local-editor'] + }, + { + id: previewGroupId, + worktreeId: WT, + activeTabId: null, + tabOrder: [] + } + ] + }, + layoutByWorktree: { + [WT]: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: editorGroupId }, + second: { type: 'leaf', groupId: previewGroupId }, + ratio: 0.5 + } + }, + unifiedTabsByWorktree: { + [WT]: [ + { + id: 'local-editor', + worktreeId: WT, + groupId: editorGroupId, + contentType: 'editor', + entityId: 'local-file', + label: 'example.html', + sortOrder: 0, + createdAt: NOW, + isPreview: false, + isPinned: false, + customLabel: null, + color: null + } + ] + } + }), + makeSnapshot( + [ + { + type: 'browser', + id: 'host-browser-tab', + title: 'example.html', + browserWorkspaceId: 'host-browser-workspace', + browserPageId: 'host-browser-page', + url: 'file:///srv/repo/example.html', + loading: false, + canGoBack: false, + canGoForward: false, + isActive: true + } + ], + { activeTabId: 'host-browser-tab', activeTabType: 'browser' } + ), + ENV, + NOW + ) as Partial + + const browserTab = patch.unifiedTabsByWorktree?.[WT]?.find( + (tab) => tab.contentType === 'browser' + ) + expect(browserTab).toMatchObject({ id: 'host-browser-tab', groupId: previewGroupId }) + expect( + patch.groupsByWorktree?.[WT]?.find((group) => group.id === previewGroupId) + ).toMatchObject({ activeTabId: 'host-browser-tab', tabOrder: ['host-browser-tab'] }) + expect(patch.groupsByWorktree?.[WT]?.find((group) => group.id === editorGroupId)).toMatchObject( + { activeTabId: 'local-editor', tabOrder: ['local-editor'] } + ) + expect(patch.browserTabsByWorktree?.[WT]).toHaveLength(1) + }) + + it('keeps a reserved side-preview split across a pre-publication snapshot', () => { + const editorGroupId = 'client-editor-group' + const previewGroupId = 'client-preview-group' + const initialLayout: TabGroupLayoutNode = { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: editorGroupId }, + second: { type: 'leaf', groupId: previewGroupId }, + ratio: 0.5 + } + reserveWebSessionBrowserPlacementGroup({ + environmentId: ENV, + worktreeId: WT, + groupId: previewGroupId + }) + + const patch = applyWebSessionTabsSnapshot( + makeState({ + groupsByWorktree: { + [WT]: [ + { + id: editorGroupId, + worktreeId: WT, + activeTabId: 'local-editor', + tabOrder: ['local-editor'] + }, + { + id: previewGroupId, + worktreeId: WT, + activeTabId: null, + tabOrder: [] + } + ] + }, + layoutByWorktree: { [WT]: initialLayout }, + unifiedTabsByWorktree: { + [WT]: [ + { + id: 'local-editor', + worktreeId: WT, + groupId: editorGroupId, + contentType: 'editor', + entityId: 'local-file', + label: 'example.html', + sortOrder: 0, + createdAt: NOW, + isPreview: false, + isPinned: false, + customLabel: null, + color: null + } + ] + } + }), + makeSnapshot([], { + activeTabType: null, + tabGroups: [{ id: editorGroupId, activeTabId: null, tabOrder: [], recentTabIds: [] }], + tabGroupLayout: { type: 'leaf', groupId: editorGroupId } + }), + ENV, + NOW + ) as Partial + + expect(patch.groupsByWorktree?.[WT]?.map((group) => group.id)).toContain(previewGroupId) + expect(layoutHasGroup(patch.layoutByWorktree?.[WT] ?? initialLayout, previewGroupId)).toBe(true) + }) + + it('forgets client browser placement after the host removes the page', () => { + recordWebSessionBrowserPlacement({ + environmentId: ENV, + worktreeId: WT, + remotePageId: 'host-browser-page', + groupId: 'client-preview-group' + }) + getWebSessionBrowserPlacementGroup({ + environmentId: ENV, + worktreeId: WT, + remotePageId: 'host-browser-page' + }) + + applyWebSessionTabsSnapshot(makeState(), makeSnapshot([]), ENV, NOW) + + expect( + getWebSessionBrowserPlacementGroup({ + environmentId: ENV, + worktreeId: WT, + remotePageId: 'host-browser-page' + }) + ).toBeUndefined() + }) + it('creates a rendered web layout group when stale group records do not include it', () => { const visibleGroupId = 'visible-web-group' const hostOnlyGroupId = 'host-group-1' diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 107c085db4e..195623d2e1d 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -90,6 +90,14 @@ import { installWindowVisibilitySubscriptionParking, type WindowVisibilitySubscriptionSpec } from './window-visibility-subscription-parking' +import { + clearWebSessionBrowserPlacementsForEnvironment, + clearWebSessionBrowserPlacementsForWorktree, + getWebSessionBrowserPlacementGroup, + isWebSessionBrowserPlacementGroupReserved, + pruneWebSessionBrowserPlacements, + resetWebSessionBrowserPlacementsForTests +} from './web-session-browser-placement' const WEB_SESSION_GROUP_PREFIX = 'web-session-tabs:' export const WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS = 100 @@ -170,6 +178,7 @@ type MirroredBrowserTab = { remotePageId: string unifiedTab: Tab hostTabId: string + clientGroupId?: string } type MirroredEditorTab = { @@ -633,6 +642,7 @@ export function resetWebSessionTabsSnapshotFreshnessForTests(): void { lastHostTerminalTabCountByWorktree.clear() hostSessionTabIdByLocalKey.clear() hostSessionTabMappingKeysByEnvironmentAndWorktree.clear() + resetWebSessionBrowserPlacementsForTests() } export function _getWebSessionTabsTrackingCountsForTest(): { @@ -675,6 +685,7 @@ function clearWebSessionTabsTrackingForWorktree(environmentId: string, worktreeI clearWebSessionCloseIntentsForWorktree({ environmentId }, worktreeId) clearWebAgentSessionHandoffsForWorktree(environmentId, worktreeId) clearHostSessionTabIdMappings(environmentId, worktreeId) + clearWebSessionBrowserPlacementsForWorktree(environmentId, worktreeId) } export function clearWebSessionTabsTrackingForEnvironment(environmentId: string): void { @@ -728,6 +739,7 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string) hostSessionTabMappingKeysByEnvironmentAndWorktree.delete(trimmedEnvironmentId) } clearWebAgentSessionHandoffsForEnvironment(trimmedEnvironmentId) + clearWebSessionBrowserPlacementsForEnvironment(trimmedEnvironmentId) clearAllWebRuntimeWakeTerminalRespawn() } @@ -1494,6 +1506,10 @@ function buildMirroredBrowserTabs( sortOffset: number, now: number ): MirroredBrowserTab[] { + const renderedGroupIds = collectLayoutGroupIds(state.layoutByWorktree[snapshot.worktree]) + const clientGroupIds = new Set( + (state.groupsByWorktree[snapshot.worktree] ?? []).map((group) => group.id) + ) return snapshot.tabs.filter(isReadyBrowserTab).map((tab, index) => { const existing = findBrowserWorkspaceForRemotePage( state, @@ -1504,7 +1520,18 @@ function buildMirroredBrowserTabs( const workspaceId = existing?.workspace.id ?? tab.browserWorkspaceId const pageId = existing?.page.id ?? tab.browserPageId const createdAt = existing?.page.createdAt ?? now + sortOffset + index - const groupId = hostGroupIdByTabId.get(tab.id) ?? fallbackGroupId + const recordedClientGroupId = getWebSessionBrowserPlacementGroup({ + environmentId, + worktreeId: snapshot.worktree, + remotePageId: tab.browserPageId + }) + const clientGroupId = + recordedClientGroupId && + clientGroupIds.has(recordedClientGroupId) && + (renderedGroupIds.size === 0 || renderedGroupIds.has(recordedClientGroupId)) + ? recordedClientGroupId + : undefined + const groupId = clientGroupId ?? hostGroupIdByTabId.get(tab.id) ?? fallbackGroupId const title = tab.title.trim() || 'Browser' const nextPage: BrowserPage = { id: pageId, @@ -1546,7 +1573,8 @@ function buildMirroredBrowserTabs( certificateFailure: tab.certificateFailure ?? null, remotePageId: tab.browserPageId, unifiedTab: buildBrowserUnifiedTab(workspace, tab, existing?.unifiedTab ?? null, groupId), - hostTabId: tab.id + hostTabId: tab.id, + ...(clientGroupId ? { clientGroupId } : {}) } }) } @@ -1745,7 +1773,8 @@ function buildMirroredHostGroups({ now, validUnifiedTabIds, environmentId, - worktreeId + worktreeId, + clientGroupIdByLocalTabId }: { currentGroups: readonly TabGroup[] hostGroups: readonly RuntimeMobileSessionTabGroup[] @@ -1756,15 +1785,34 @@ function buildMirroredHostGroups({ validUnifiedTabIds: ReadonlySet environmentId: string worktreeId: string + clientGroupIdByLocalTabId: ReadonlyMap }): TabGroup[] | null { const strippedGroups = currentGroups.map((group) => { - const tabOrder = group.tabOrder.filter( - (tabId) => validUnifiedTabIds.has(tabId) && !mirroredUnifiedIds.has(tabId) + const retainedTabOrder = group.tabOrder.filter( + (tabId) => + validUnifiedTabIds.has(tabId) && + (!mirroredUnifiedIds.has(tabId) || clientGroupIdByLocalTabId.get(tabId) === group.id) ) + const placedTabIds = [...clientGroupIdByLocalTabId] + .filter( + ([tabId, groupId]) => + groupId === group.id && validUnifiedTabIds.has(tabId) && !retainedTabOrder.includes(tabId) + ) + .map(([tabId]) => tabId) + const tabOrder = [...retainedTabOrder, ...placedTabIds] + const activeTabId = + nextActiveUnifiedTabId && tabOrder.includes(nextActiveUnifiedTabId) + ? nextActiveUnifiedTabId + : group.activeTabId && tabOrder.includes(group.activeTabId) + ? group.activeTabId + : (tabOrder[0] ?? null) return { ...group, tabOrder, - recentTabIds: sanitizeRecentTabIds(group.recentTabIds, tabOrder) + activeTabId, + recentTabIds: activeTabId + ? pushRecentTabId(sanitizeRecentTabIds(group.recentTabIds, tabOrder), activeTabId) + : [] } }) const groupsById = new Map(strippedGroups.map((group) => [group.id, group])) @@ -1775,7 +1823,12 @@ function buildMirroredHostGroups({ const existing = groupsById.get(hostGroup.id) const localHostOrder = hostGroup.tabOrder .map((tabId) => hostToLocalTabId.get(tabId)) - .filter((tabId): tabId is string => tabId !== undefined && validUnifiedTabIds.has(tabId)) + .filter( + (tabId): tabId is string => + tabId !== undefined && + validUnifiedTabIds.has(tabId) && + !clientGroupIdByLocalTabId.has(tabId) + ) const hostTabOrder = [ ...(existing?.tabOrder.filter((tabId) => !localHostOrder.includes(tabId)) ?? []), ...localHostOrder @@ -1817,7 +1870,11 @@ function buildMirroredHostGroups({ } for (const group of strippedGroups) { - if (!seen.has(group.id) && group.tabOrder.length > 0) { + if ( + !seen.has(group.id) && + (group.tabOrder.length > 0 || + isWebSessionBrowserPlacementGroupReserved({ environmentId, worktreeId, groupId: group.id })) + ) { orderedGroups.push(group) } } @@ -2408,6 +2465,7 @@ function applyWebSessionTabsSnapshotWithContext( const hostGroupIdByTabId = buildHostGroupIdByTabId(snapshot.tabGroups) const readyBrowserTabs = snapshot.tabs.filter(isReadyBrowserTab) const nextRemoteBrowserPageIds = new Set(readyBrowserTabs.map((tab) => tab.browserPageId)) + pruneWebSessionBrowserPlacements(environmentId, worktreeId, nextRemoteBrowserPageIds) const mirroredBrowserTabs = buildMirroredBrowserTabs( snapshot, environmentId, @@ -2692,6 +2750,11 @@ function applyWebSessionTabsSnapshotWithContext( }) const currentGroups = state.groupsByWorktree[worktreeId] ?? [] + const clientGroupIdByLocalTabId = new Map( + mirroredBrowserTabs.flatMap((entry) => + entry.clientGroupId ? [[entry.unifiedTab.id, entry.clientGroupId]] : [] + ) + ) const nextGroups = (() => { if (!nextUnifiedTabs || nextUnifiedTabs.length === 0) { return null @@ -2706,21 +2769,40 @@ function applyWebSessionTabsSnapshotWithContext( now, validUnifiedTabIds, environmentId, - worktreeId + worktreeId, + clientGroupIdByLocalTabId }) } - const strippedGroups = currentGroups.map((group) => ({ - ...group, - tabOrder: group.tabOrder.filter( - (tabId) => validUnifiedTabIds.has(tabId) && !mirroredUnifiedIds.has(tabId) - ), - recentTabIds: sanitizeRecentTabIds( - group.recentTabIds, - group.tabOrder.filter( - (tabId) => validUnifiedTabIds.has(tabId) && !mirroredUnifiedIds.has(tabId) - ) + const strippedGroups = currentGroups.map((group) => { + const retainedTabOrder = group.tabOrder.filter( + (tabId) => + validUnifiedTabIds.has(tabId) && + (!mirroredUnifiedIds.has(tabId) || clientGroupIdByLocalTabId.get(tabId) === group.id) ) - })) + const placedTabIds = [...clientGroupIdByLocalTabId] + .filter( + ([tabId, groupId]) => + groupId === group.id && + validUnifiedTabIds.has(tabId) && + !retainedTabOrder.includes(tabId) + ) + .map(([tabId]) => tabId) + const tabOrder = [...retainedTabOrder, ...placedTabIds] + const activeTabId = + nextActiveUnifiedTabId && tabOrder.includes(nextActiveUnifiedTabId) + ? nextActiveUnifiedTabId + : group.activeTabId && tabOrder.includes(group.activeTabId) + ? group.activeTabId + : (tabOrder[0] ?? null) + return { + ...group, + tabOrder, + activeTabId, + recentTabIds: activeTabId + ? pushRecentTabId(sanitizeRecentTabIds(group.recentTabIds, tabOrder), activeTabId) + : [] + } + }) const target = strippedGroups.find((group) => group.id === targetGroupId) ?? { id: targetGroupId, worktreeId, @@ -2730,7 +2812,9 @@ function applyWebSessionTabsSnapshotWithContext( } const targetOrder = [ ...target.tabOrder.filter((tabId) => validUnifiedTabIds.has(tabId)), - ...mirroredUnifiedTabs.map((tab) => tab.id) + ...mirroredUnifiedTabs + .filter((tab) => !clientGroupIdByLocalTabId.has(tab.id)) + .map((tab) => tab.id) ] const targetActiveTabId = nextActiveUnifiedTabId && targetOrder.includes(nextActiveUnifiedTabId) @@ -2750,7 +2834,16 @@ function applyWebSessionTabsSnapshotWithContext( const merged = strippedGroups.some((group) => group.id === targetGroupId) ? strippedGroups.map((group) => (group.id === targetGroupId ? updatedTarget : group)) : [...strippedGroups, updatedTarget] - return merged.filter((group) => group.id === targetGroupId || group.tabOrder.length > 0) + return merged.filter( + (group) => + group.id === targetGroupId || + group.tabOrder.length > 0 || + isWebSessionBrowserPlacementGroupReserved({ + environmentId, + worktreeId, + groupId: group.id + }) + ) })() const nextTabBarOrder = (() => { diff --git a/tests/e2e/paired-remote-html-browser-focus.spec.ts b/tests/e2e/paired-remote-html-browser-focus.spec.ts new file mode 100644 index 00000000000..f8e9302b415 --- /dev/null +++ b/tests/e2e/paired-remote-html-browser-focus.spec.ts @@ -0,0 +1,436 @@ +import { writeFileSync } from 'node:fs' +import path from 'node:path' +import { expect, test } from './helpers/orca-app' +import { openFileExplorer } from './helpers/file-explorer' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient, + type PairedElectronClient +} from './helpers/paired-electron-client' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +const FIXTURE_NAME = 'paired-html-focus.html' + +test('keeps a selected remote HTML browser tab focused after host adoption', async ({ + orcaPage, + testRepoPath +}, testInfo) => { + test.setTimeout(240_000) + writeFileSync( + path.join(testRepoPath, FIXTURE_NAME), + '

paired html focus

\n' + ) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + let client: PairedElectronClient | null = null + try { + client = await launchPairedElectronClient(offer, testInfo, 'Remote HTML focus') + const page = client.page + const worktreeId = await expect + .poll( + () => + page.evaluate((repoPath) => { + const state = window.__store?.getState() + return state + ? (state.allWorktrees().find((worktree) => worktree.path === repoPath)?.id ?? null) + : null + }, testRepoPath), + { timeout: 60_000, message: 'paired client never received the host worktree' } + ) + .not.toBeNull() + .then(() => + page.evaluate((repoPath) => { + const state = window.__store?.getState() + return state?.allWorktrees().find((worktree) => worktree.path === repoPath)?.id ?? null + }, testRepoPath) + ) + if (!worktreeId) { + throw new Error('paired client worktree disappeared after discovery') + } + await page.evaluate( + ({ environmentId, worktreeId }) => { + window.__store?.getState().setActiveWorktree(worktreeId, `runtime:${environmentId}`) + }, + { environmentId: client.environmentId, worktreeId } + ) + await openFileExplorer(page) + const fixtureRow = page.locator('[data-file-explorer-row]').filter({ hasText: FIXTURE_NAME }) + await expect(fixtureRow).toBeVisible({ timeout: 30_000 }) + await fixtureRow.click() + const openPreviewToSide = page.getByRole('button', { name: 'Open Preview to the Side' }) + await expect(openPreviewToSide).toBeVisible({ timeout: 30_000 }) + const sourceEditor = await page.evaluate((targetWorktreeId) => { + const state = window.__store?.getState() + const groupId = state?.activeGroupIdByWorktree[targetWorktreeId] + const group = (state?.groupsByWorktree[targetWorktreeId] ?? []).find( + (candidate) => candidate.id === groupId + ) + const tab = (state?.unifiedTabsByWorktree[targetWorktreeId] ?? []).find( + (candidate) => candidate.id === group?.activeTabId && candidate.contentType === 'editor' + ) + return groupId && tab ? { groupId, tabId: tab.id } : null + }, worktreeId) + if (!sourceEditor) { + throw new Error('paired client editor had no source identity before side preview') + } + const sourceGroupId = sourceEditor.groupId + const browserBaseline = await page.evaluate( + async ({ environmentId, worktreeId }) => { + const state = window.__store?.getState() + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'session.tabs.list', + params: { worktree: `id:${worktreeId}` }, + timeoutMs: 15_000 + }) + return { + clientUnified: (state?.unifiedTabsByWorktree[worktreeId] ?? []).filter( + (tab) => tab.contentType === 'browser' + ).length, + clientWorkspaces: (state?.browserTabsByWorktree[worktreeId] ?? []).length, + host: response.ok + ? response.result.tabs.filter((tab) => tab.type === 'browser').length + : -1 + } + }, + { environmentId: client.environmentId, worktreeId } + ) + await openPreviewToSide.click() + + await expect + .poll( + () => + page.evaluate( + async ({ environmentId, fixtureName, worktreeId }) => { + const state = window.__store?.getState() + if (!state) { + return null + } + const browser = (state.browserTabsByWorktree[worktreeId] ?? []).find((tab) => + tab.url.endsWith(`/${fixtureName}`) + ) + const browserPage = browser + ? (state.browserPagesByWorkspace[browser.id] ?? [])[0] + : null + const handle = browserPage + ? state.remoteBrowserPageHandlesByPageId[browserPage.id] + : null + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'session.tabs.list', + params: { worktree: `id:${worktreeId}` }, + timeoutMs: 15_000 + }) + const hostHasHtml = + response.ok && + response.result.tabs.some( + (tab) => tab.type === 'browser' && tab.url.endsWith(`/${fixtureName}`) + ) + return { + browserRuntimeEnvironmentId: browserPage?.browserRuntimeEnvironmentId ?? null, + handleEnvironmentId: handle?.environmentId ?? null, + handleRemotePageId: handle?.remotePageId ?? null, + hostHasHtml + } + }, + { environmentId: client!.environmentId, fixtureName: FIXTURE_NAME, worktreeId } + ), + { timeout: 60_000, message: 'remote HTML browser ownership never converged' } + ) + .toMatchObject({ + browserRuntimeEnvironmentId: client.environmentId, + handleEnvironmentId: client.environmentId, + hostHasHtml: true + }) + const htmlTabInventory = await page.evaluate( + async ({ environmentId, fixtureName, worktreeId }) => { + const state = window.__store?.getState() + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'session.tabs.list', + params: { worktree: `id:${worktreeId}` }, + timeoutMs: 15_000 + }) + const clientWorkspaceIds = (state?.browserTabsByWorktree[worktreeId] ?? []) + .filter((tab) => tab.url.endsWith(`/${fixtureName}`)) + .map((tab) => tab.id) + return { + clientWorkspaceIds, + clientUnifiedTabs: (state?.unifiedTabsByWorktree[worktreeId] ?? []) + .filter( + (tab) => tab.contentType === 'browser' && clientWorkspaceIds.includes(tab.entityId) + ) + .map((tab) => ({ groupId: tab.groupId, id: tab.id })), + hostTabIds: response.ok + ? response.result.tabs + .filter((tab) => tab.type === 'browser' && tab.url.endsWith(`/${fixtureName}`)) + .map((tab) => tab.id) + : [], + hostTabGroups: response.ok ? (response.result.tabGroups ?? []) : [], + totalClientUnified: (state?.unifiedTabsByWorktree[worktreeId] ?? []).filter( + (tab) => tab.contentType === 'browser' + ).length, + totalClientWorkspaces: (state?.browserTabsByWorktree[worktreeId] ?? []).length, + totalHost: response.ok + ? response.result.tabs.filter((tab) => tab.type === 'browser').length + : -1 + } + }, + { environmentId: client.environmentId, fixtureName: FIXTURE_NAME, worktreeId } + ) + expect(htmlTabInventory.clientWorkspaceIds).toHaveLength(1) + expect(htmlTabInventory.clientUnifiedTabs).toHaveLength(1) + expect(htmlTabInventory.hostTabIds).toHaveLength(1) + expect(htmlTabInventory.totalClientUnified).toBe(browserBaseline.clientUnified + 1) + expect(htmlTabInventory.totalClientWorkspaces).toBe(browserBaseline.clientWorkspaces + 1) + expect(htmlTabInventory.totalHost).toBe(browserBaseline.host + 1) + expect(htmlTabInventory.clientUnifiedTabs[0]?.groupId).not.toBe(sourceGroupId) + expect( + htmlTabInventory.hostTabGroups.some( + (group) => group.id === htmlTabInventory.clientUnifiedTabs[0]?.groupId + ) + ).toBe(false) + const hostBrowserGroupId = htmlTabInventory.hostTabGroups.find((group) => + group.tabOrder.includes(htmlTabInventory.hostTabIds[0]!) + )?.id + expect(hostBrowserGroupId).toBeTruthy() + await expect(page.locator(`[data-tab-group-body-id="${sourceGroupId}"]`)).toBeVisible() + await expect( + page.locator(`[data-tab-group-body-id="${htmlTabInventory.clientUnifiedTabs[0]!.groupId}"]`) + ).toBeVisible() + await expect(page.getByTestId('remote-browser-frame')).toBeVisible({ timeout: 60_000 }) + + const tabIds = await page.evaluate( + ({ fixtureName, worktreeId: targetWorktreeId }) => { + const state = window.__store?.getState() + if (!state) { + return null + } + const terminalId = state.tabsByWorktree[targetWorktreeId]?.[0]?.id ?? null + const browserId = (state.browserTabsByWorktree[targetWorktreeId] ?? []).find((tab) => + tab.url.endsWith(`/${fixtureName}`) + )?.id + return terminalId && browserId ? { browserId, terminalId } : null + }, + { fixtureName: FIXTURE_NAME, worktreeId } + ) + if (!tabIds) { + throw new Error('paired client did not retain both terminal and HTML browser tabs') + } + await page.locator(`[data-tab-id="${tabIds.terminalId}"]`).click() + await expect + .poll( + () => + page.evaluate((targetWorktreeId) => { + const state = window.__store?.getState() + return state?.activeTabTypeByWorktree[targetWorktreeId] ?? null + }, worktreeId), + { message: 'terminal tab never became active before the browser click' } + ) + .toBe('terminal') + await expect + .poll( + () => + page.evaluate( + async ({ environmentId, worktreeId }) => { + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'session.tabs.list', + params: { worktree: `id:${worktreeId}` }, + timeoutMs: 15_000 + }) + if (!response.ok) { + return false + } + return ( + response.result.tabs.find((tab) => tab.id === response.result.activeTabId)?.type === + 'terminal' + ) + }, + { environmentId: client.environmentId, worktreeId } + ), + { timeout: 30_000, message: 'host never accepted terminal activation' } + ) + .toBe(true) + const hostBrowserTab = await page.evaluate( + async ({ environmentId, fixtureName, worktreeId }) => { + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'session.tabs.list', + params: { worktree: `id:${worktreeId}` }, + timeoutMs: 15_000 + }) + const tab = response.ok + ? response.result.tabs.find( + (candidate) => + candidate.type === 'browser' && candidate.url.endsWith(`/${fixtureName}`) + ) + : null + return tab?.type === 'browser' ? { browserPageId: tab.browserPageId, id: tab.id } : null + }, + { environmentId: client.environmentId, fixtureName: FIXTURE_NAME, worktreeId } + ) + if (!hostBrowserTab?.browserPageId) { + throw new Error('host HTML browser tab disappeared before activation') + } + const clientRemotePageId = await page.evaluate( + ({ fixtureName, worktreeId }) => { + const state = window.__store?.getState() + const workspace = (state?.browserTabsByWorktree[worktreeId] ?? []).find((tab) => + tab.url.endsWith(`/${fixtureName}`) + ) + const browserPage = workspace + ? (state?.browserPagesByWorkspace[workspace.id] ?? [])[0] + : null + return browserPage + ? (state?.remoteBrowserPageHandlesByPageId[browserPage.id]?.remotePageId ?? null) + : null + }, + { fixtureName: FIXTURE_NAME, worktreeId } + ) + expect(clientRemotePageId).toBe(hostBrowserTab.browserPageId) + const content = await page.evaluate( + async ({ browserPageId, environmentId, worktreeId }) => + window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'browser.eval', + params: { + worktree: `id:${worktreeId}`, + page: browserPageId, + expression: 'document.querySelector("h1")?.textContent' + }, + timeoutMs: 15_000 + }), + { + browserPageId: hostBrowserTab.browserPageId, + environmentId: client.environmentId, + worktreeId + } + ) + expect(content).toMatchObject({ ok: true, result: { result: 'paired html focus' } }) + await expect + .poll( + () => + page.evaluate( + ({ browserTabId, environmentId }) => { + const state = window.__store?.getState() + return (state?.browserPagesByWorkspace[browserTabId] ?? []).some((browserPage) => { + const handle = state?.remoteBrowserPageHandlesByPageId[browserPage.id] + return ( + handle?.environmentId === environmentId || + browserPage.browserRuntimeEnvironmentId === environmentId + ) + }) + }, + { browserTabId: tabIds.browserId, environmentId: client.environmentId } + ), + { timeout: 30_000, message: 'client lost remote browser ownership before activation' } + ) + .toBe(true) + await page.locator(`[data-tab-id="${tabIds.browserId}"]`).click() + + await expect + .poll( + () => + page.evaluate( + async ({ environmentId, fixtureName, worktreeId }) => { + const state = window.__store?.getState() + if (!state) { + return null + } + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'session.tabs.list', + params: { worktree: `id:${worktreeId}` }, + timeoutMs: 15_000 + }) + const activeGroup = (state.groupsByWorktree[worktreeId] ?? []).find( + (group) => group.id === state.activeGroupIdByWorktree[worktreeId] + ) + const activeUnified = (state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (tab) => tab.id === activeGroup?.activeTabId + ) + const hostActive = response.ok + ? response.result.tabs.find((tab) => tab.id === response.result.activeTabId) + : null + return { + activeGroupType: activeUnified?.contentType ?? null, + activeTabType: state.activeTabTypeByWorktree[worktreeId] ?? null, + hostActiveHtml: + hostActive?.type === 'browser' && hostActive.url.endsWith(`/${fixtureName}`) + } + }, + { environmentId: client!.environmentId, fixtureName: FIXTURE_NAME, worktreeId } + ), + { timeout: 30_000, message: 'browser click did not remain authoritative' } + ) + .toEqual({ activeGroupType: 'browser', activeTabType: 'browser', hostActiveHtml: true }) + await expect(page.getByTestId('remote-browser-frame')).toBeVisible() + + const browserTab = page.locator(`[data-tab-id="${tabIds.browserId}"]`) + await browserTab.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Close', exact: true }).click() + await expect + .poll( + () => + page.evaluate( + async ({ + environmentId, + hostTabId, + sourceGroupId, + unifiedTabId, + workspaceId, + worktreeId + }) => { + const state = window.__store?.getState() + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'session.tabs.list', + params: { worktree: `id:${worktreeId}` }, + timeoutMs: 15_000 + }) + return { + clientUnifiedPresent: (state?.unifiedTabsByWorktree[worktreeId] ?? []).some( + (tab) => tab.id === unifiedTabId + ), + clientWorkspacePresent: (state?.browserTabsByWorktree[worktreeId] ?? []).some( + (tab) => tab.id === workspaceId + ), + hostTabPresent: response.ok + ? response.result.tabs.some((tab) => tab.id === hostTabId) + : true, + sourceGroupPresent: (state?.groupsByWorktree[worktreeId] ?? []).some( + (group) => group.id === sourceGroupId + ) + } + }, + { + environmentId: client!.environmentId, + hostTabId: hostBrowserTab.id, + sourceGroupId, + unifiedTabId: htmlTabInventory.clientUnifiedTabs[0]!.id, + workspaceId: htmlTabInventory.clientWorkspaceIds[0]!, + worktreeId + } + ), + { timeout: 30_000, message: 'remote HTML browser close did not converge cleanly' } + ) + .toEqual({ + clientUnifiedPresent: false, + clientWorkspacePresent: false, + hostTabPresent: false, + sourceGroupPresent: true + }) + await expect(browserTab).toHaveCount(0) + await expect(page.locator(`[data-tab-id="${tabIds.terminalId}"]`)).toBeVisible() + await expect(page.locator(`[data-tab-id="${sourceEditor.tabId}"]`)).toBeVisible() + await expect( + page.locator(`[data-tab-group-body-id="${sourceGroupId}"] .monaco-editor`) + ).toBeVisible() + } finally { + await client?.dispose() + } +})