Fix paired remote HTML browser preview

This commit is contained in:
Jinwoo-H
2026-08-12 04:38:02 -04:00
parent 346e59c879
commit a3d4e585cd
10 changed files with 1300 additions and 38 deletions
+55 -1
View File
@@ -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'
+40
View File
@@ -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<boolean>): 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,
@@ -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[] = []
+116 -14
View File
@@ -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<string>()
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)
}
@@ -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()
})
})
@@ -0,0 +1,131 @@
type BrowserPlacement = { groupId: string; seen: boolean }
const placementsByPage = new Map<string, BrowserPlacement>()
const reservedGroups = new Set<string>()
const MAX_PENDING_PLACEMENTS = 128
function evictOldest(entries: Map<string, unknown> | Set<string>): 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<string>
): 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()
}
@@ -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()) {
@@ -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> = {}): 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<WebSessionTabsSyncState>
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<WebSessionTabsSyncState>
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'
+115 -22
View File
@@ -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<string>
environmentId: string
worktreeId: string
clientGroupIdByLocalTabId: ReadonlyMap<string, string>
}): 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 = (() => {
@@ -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),
'<!doctype html><html><body><h1>paired html focus</h1></body></html>\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()
}
})