mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
Fix paired remote HTML browser preview
This commit is contained in:
@@ -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'
|
||||
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user