mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
Fix hidden browser automation paintability (#2851)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
/* eslint-disable max-lines -- Why: this file groups every CLI browser-command test (page targeting, profiles, waits, viewport) so test-fixture imports and the runtime-client mock stay shared in one place. */
|
||||
import path from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
@@ -90,7 +89,7 @@ describe('orca cli browser page targeting', () => {
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'browser.snapshot', {
|
||||
page: 'page-1',
|
||||
worktree: `path:${path.resolve('/tmp/repo/feature')}`
|
||||
worktree: 'id:repo::/tmp/repo/feature'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -124,7 +123,7 @@ describe('orca cli browser page targeting', () => {
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'browser.tabSwitch', {
|
||||
index: undefined,
|
||||
page: 'page-2',
|
||||
worktree: `path:${path.resolve('/tmp/repo/feature')}`
|
||||
worktree: 'id:repo::/tmp/repo/feature'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/* eslint-disable max-lines -- Why: computer CLI coverage shares one mocked runtime setup across command contracts. */
|
||||
import path from 'path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
@@ -71,7 +70,7 @@ describe('orca computer CLI handlers', () => {
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'computer.listApps', {
|
||||
worktree: `path:${path.resolve('/tmp/repo')}`
|
||||
worktree: 'id:repo::/tmp/repo'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -142,7 +141,7 @@ describe('orca computer CLI handlers', () => {
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'computer.getAppState', {
|
||||
app: 'Finder',
|
||||
worktree: `path:${path.resolve('/tmp/repo')}`,
|
||||
worktree: 'id:repo::/tmp/repo',
|
||||
noScreenshot: true,
|
||||
restoreWindow: true
|
||||
})
|
||||
@@ -176,7 +175,7 @@ describe('orca computer CLI handlers', () => {
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'computer.listWindows', {
|
||||
app: 'Finder',
|
||||
worktree: `path:${path.resolve('/tmp/repo')}`
|
||||
worktree: 'id:repo::/tmp/repo'
|
||||
})
|
||||
const output = vi.mocked(console.log).mock.calls[0][0]
|
||||
expect(output).toContain('[0] id:42 "Recents"')
|
||||
@@ -241,7 +240,7 @@ describe('orca computer CLI handlers', () => {
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'computer.drag', {
|
||||
app: 'Finder',
|
||||
worktree: `path:${path.resolve('/tmp/repo')}`,
|
||||
worktree: 'id:repo::/tmp/repo',
|
||||
fromElementIndex: undefined,
|
||||
toElementIndex: undefined,
|
||||
fromX: 1,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/* eslint-disable max-lines -- Why: file CLI coverage shares one mocked runtime setup across command contracts. */
|
||||
import path from 'path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
@@ -83,7 +82,7 @@ describe('orca file CLI handlers', () => {
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', {
|
||||
worktree: `path:${path.resolve('/tmp/repo')}`,
|
||||
worktree: 'id:repo::/tmp/repo',
|
||||
relativePath: 'src/App.tsx'
|
||||
})
|
||||
expect(vi.mocked(console.log).mock.calls[0][0]).toBe('Opened src/App.tsx.')
|
||||
@@ -160,20 +159,20 @@ describe('orca file CLI handlers', () => {
|
||||
await main(['file', 'open-changed'], '/tmp/repo/src')
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'git.status', {
|
||||
worktree: `path:${path.resolve('/tmp/repo')}`
|
||||
worktree: 'id:repo::/tmp/repo'
|
||||
})
|
||||
expect(callMock).toHaveBeenNthCalledWith(3, 'files.openDiff', {
|
||||
worktree: `path:${path.resolve('/tmp/repo')}`,
|
||||
worktree: 'id:repo::/tmp/repo',
|
||||
relativePath: 'src/App.tsx',
|
||||
staged: false
|
||||
})
|
||||
expect(callMock).toHaveBeenNthCalledWith(4, 'files.openDiff', {
|
||||
worktree: `path:${path.resolve('/tmp/repo')}`,
|
||||
worktree: 'id:repo::/tmp/repo',
|
||||
relativePath: 'package.json',
|
||||
staged: true
|
||||
})
|
||||
expect(callMock).toHaveBeenNthCalledWith(5, 'files.openDiff', {
|
||||
worktree: `path:${path.resolve('/tmp/repo')}`,
|
||||
worktree: 'id:repo::/tmp/repo',
|
||||
relativePath: 'docs/new.md',
|
||||
staged: false
|
||||
})
|
||||
|
||||
+17
-14
@@ -182,7 +182,10 @@ describe('orca cli worktree awareness', () => {
|
||||
it('shows the enclosing worktree for `worktree current`', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo')]),
|
||||
worktreeListFixture([
|
||||
buildWorktree('/tmp/repo/feature', 'feature/foo'),
|
||||
buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'duplicate-repo')
|
||||
]),
|
||||
okFixture('req_1', {
|
||||
worktree: {
|
||||
id: 'repo::/tmp/repo/feature',
|
||||
@@ -197,7 +200,7 @@ describe('orca cli worktree awareness', () => {
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { limit: 10_000 })
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', {
|
||||
worktree: `path:${path.resolve('/tmp/repo/feature')}`
|
||||
worktree: 'id:repo::/tmp/repo/feature'
|
||||
})
|
||||
expect(logSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -245,7 +248,7 @@ describe('orca cli worktree awareness', () => {
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.set', {
|
||||
worktree: `path:${path.resolve('/tmp/repo/feature')}`,
|
||||
worktree: 'id:repo::/tmp/repo/feature',
|
||||
displayName: undefined,
|
||||
linkedIssue: undefined,
|
||||
comment: 'hello',
|
||||
@@ -332,7 +335,7 @@ describe('orca cli worktree awareness', () => {
|
||||
displayName: undefined,
|
||||
linkedIssue: undefined,
|
||||
comment: undefined,
|
||||
parentWorktree: `path:${path.resolve('/tmp/repo/parent')}`,
|
||||
parentWorktree: 'id:repo::/tmp/repo/parent',
|
||||
noParent: false
|
||||
})
|
||||
})
|
||||
@@ -437,7 +440,7 @@ describe('orca cli worktree awareness', () => {
|
||||
runHooks: false,
|
||||
activate: true,
|
||||
parentWorktree: undefined,
|
||||
cwdParentWorktree: `path:${path.resolve('/tmp/repo')}`,
|
||||
cwdParentWorktree: 'id:repo-1::/tmp/repo',
|
||||
noParent: false,
|
||||
callerTerminalHandle: undefined
|
||||
})
|
||||
@@ -541,7 +544,7 @@ describe('orca cli worktree awareness', () => {
|
||||
comment: undefined,
|
||||
runHooks: false,
|
||||
activate: false,
|
||||
parentWorktree: `path:${path.resolve('/tmp/repo/parent')}`,
|
||||
parentWorktree: 'id:repo-1::/tmp/repo/parent',
|
||||
noParent: false,
|
||||
callerTerminalHandle: undefined
|
||||
})
|
||||
@@ -666,7 +669,7 @@ describe('orca cli worktree awareness', () => {
|
||||
runHooks: false,
|
||||
activate: false,
|
||||
parentWorktree: undefined,
|
||||
cwdParentWorktree: `path:${path.resolve('/tmp/repo')}`,
|
||||
cwdParentWorktree: 'id:repo-1::/tmp/repo',
|
||||
noParent: false,
|
||||
callerTerminalHandle: 'term_parent'
|
||||
})
|
||||
@@ -882,7 +885,7 @@ describe('orca cli worktree awareness', () => {
|
||||
runHooks: true,
|
||||
activate: true,
|
||||
parentWorktree: undefined,
|
||||
cwdParentWorktree: `path:${path.resolve('/tmp/repo')}`,
|
||||
cwdParentWorktree: 'id:repo-1::/tmp/repo',
|
||||
noParent: false,
|
||||
callerTerminalHandle: undefined
|
||||
})
|
||||
@@ -1273,7 +1276,7 @@ describe('orca cli worktree awareness', () => {
|
||||
await main(['worktree', 'show', '--worktree', 'current', '--json'], '/tmp/repo/feature/src')
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', {
|
||||
worktree: `path:${path.resolve('/tmp/repo/feature')}`
|
||||
worktree: 'id:repo::/tmp/repo/feature'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1397,7 +1400,7 @@ describe('orca cli worktree awareness', () => {
|
||||
await main(['terminal', 'list', '--worktree', 'active', '--json'], '/tmp/repo/feature/src')
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.list', {
|
||||
worktree: `path:${path.resolve('/tmp/repo/feature')}`,
|
||||
worktree: 'id:repo::/tmp/repo/feature',
|
||||
limit: undefined
|
||||
})
|
||||
})
|
||||
@@ -1610,7 +1613,7 @@ describe('orca cli worktree awareness', () => {
|
||||
prompt: 'Review open changes',
|
||||
agentId: 'codex',
|
||||
repo: undefined,
|
||||
workspace: `path:${path.resolve('/tmp/repo/feature')}`,
|
||||
workspace: 'id:repo-1::/tmp/repo/feature',
|
||||
workspaceMode: 'existing',
|
||||
baseBranch: undefined,
|
||||
reuseSession: undefined,
|
||||
@@ -1823,7 +1826,7 @@ describe('orca cli worktree awareness', () => {
|
||||
2,
|
||||
'automation.create',
|
||||
expect.objectContaining({
|
||||
workspace: `path:${path.resolve('/tmp/repo/feature')}`,
|
||||
workspace: 'id:repo-1::/tmp/repo/feature',
|
||||
workspaceMode: 'existing',
|
||||
reuseSession: true
|
||||
})
|
||||
@@ -1950,7 +1953,7 @@ describe('orca cli worktree awareness', () => {
|
||||
prompt: 'Review open changes',
|
||||
agentId: 'codex',
|
||||
repo: undefined,
|
||||
workspace: `path:${path.resolve('/tmp/repo/feature')}`,
|
||||
workspace: 'id:repo-1::/tmp/repo/feature',
|
||||
workspaceMode: 'existing',
|
||||
baseBranch: undefined,
|
||||
timezone: undefined,
|
||||
@@ -1982,7 +1985,7 @@ describe('orca cli worktree awareness', () => {
|
||||
prompt: undefined,
|
||||
agentId: undefined,
|
||||
repo: undefined,
|
||||
workspace: `path:${path.resolve('/tmp/repo/feature')}`,
|
||||
workspace: 'id:repo-1::/tmp/repo/feature',
|
||||
workspaceMode: undefined,
|
||||
baseBranch: undefined,
|
||||
reuseSession: undefined,
|
||||
|
||||
@@ -69,10 +69,10 @@ export async function resolveCurrentWorktreeSelector(
|
||||
}
|
||||
|
||||
// Why: users expect "active/current" to mean the enclosing managed worktree
|
||||
// even from nested subdirectories. The CLI resolves that shell-local concept
|
||||
// to the deepest matching worktree root, then hands the runtime a normal
|
||||
// path selector so selector semantics stay centralized in one layer.
|
||||
return buildCurrentWorktreeSelector(enclosingWorktree.path)
|
||||
// even from nested subdirectories. Resolve to the concrete runtime id here:
|
||||
// duplicate repo registrations can expose the same Git worktree path, and a
|
||||
// path selector would throw selector_ambiguous after losing the repo id.
|
||||
return `id:${enclosingWorktree.id}`
|
||||
}
|
||||
|
||||
export async function getOptionalWorktreeSelector(
|
||||
|
||||
@@ -70,6 +70,7 @@ function mockBrowserManager(
|
||||
getWorktreeIdForTab: (tabId: string) => worktrees.get(tabId),
|
||||
getGuestWebContentsId: vi.fn(() => null),
|
||||
ensureWebviewVisible: vi.fn(async () => () => {}),
|
||||
acquireAutomationVisibility: vi.fn(async () => () => {}),
|
||||
...overrides
|
||||
} as unknown as BrowserManager
|
||||
}
|
||||
@@ -420,6 +421,55 @@ describe('AgentBrowserBridge', () => {
|
||||
expect(snapshotIdx).toBeLessThan(clickIdx)
|
||||
})
|
||||
|
||||
it('acquires an automation visibility lease while running snapshot commands', async () => {
|
||||
const lifecycleEvents: string[] = []
|
||||
const restore = vi.fn(() => {
|
||||
lifecycleEvents.push('restore-100')
|
||||
})
|
||||
const acquireAutomationVisibility = vi.fn(async (webContentsId: number) => {
|
||||
lifecycleEvents.push(`acquire-${webContentsId}`)
|
||||
return restore
|
||||
})
|
||||
|
||||
const b = new AgentBrowserBridge(
|
||||
mockBrowserManager(undefined, undefined, {
|
||||
acquireAutomationVisibility
|
||||
})
|
||||
)
|
||||
b.setActiveTab(100)
|
||||
|
||||
let releaseSnapshot: (() => void) | null = null
|
||||
execFileMock.mockImplementation(
|
||||
(_bin: string, args: string[], _opts: unknown, cb: Function) => {
|
||||
if (args.includes('close')) {
|
||||
cb(null, JSON.stringify({ success: true, data: null }), '')
|
||||
return
|
||||
}
|
||||
if (args.includes('snapshot')) {
|
||||
lifecycleEvents.push('command-snapshot')
|
||||
releaseSnapshot = () => {
|
||||
cb(null, JSON.stringify({ success: true, data: { snapshot: 'tree' } }), '')
|
||||
}
|
||||
return
|
||||
}
|
||||
cb(null, JSON.stringify({ success: true, data: { ok: true } }), '')
|
||||
}
|
||||
)
|
||||
|
||||
const snapshot = b.snapshot()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(releaseSnapshot).not.toBeNull()
|
||||
})
|
||||
expect(lifecycleEvents).toEqual(['acquire-100', 'command-snapshot'])
|
||||
expect(restore).not.toHaveBeenCalled()
|
||||
|
||||
releaseSnapshot!()
|
||||
|
||||
await expect(snapshot).resolves.toEqual({ browserPageId: 'tab-1', snapshot: 'tree' })
|
||||
expect(lifecycleEvents).toEqual(['acquire-100', 'command-snapshot', 'restore-100'])
|
||||
})
|
||||
|
||||
it('serializes screenshot visibility prep across sessions', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
@@ -432,8 +482,8 @@ describe('AgentBrowserBridge', () => {
|
||||
['tab-2', 'wt-2']
|
||||
])
|
||||
const lifecycleEvents: string[] = []
|
||||
const ensureWebviewVisibleMock = vi.fn(async (webContentsId: number) => {
|
||||
lifecycleEvents.push(`ensure-${webContentsId}`)
|
||||
const acquireAutomationVisibilityMock = vi.fn(async (webContentsId: number) => {
|
||||
lifecycleEvents.push(`acquire-${webContentsId}`)
|
||||
return () => {
|
||||
lifecycleEvents.push(`restore-${webContentsId}`)
|
||||
}
|
||||
@@ -449,7 +499,7 @@ describe('AgentBrowserBridge', () => {
|
||||
|
||||
const b = new AgentBrowserBridge(
|
||||
mockBrowserManager(tabs, worktrees, {
|
||||
ensureWebviewVisible: ensureWebviewVisibleMock
|
||||
acquireAutomationVisibility: acquireAutomationVisibilityMock
|
||||
})
|
||||
)
|
||||
b.setActiveTab(1, 'wt-1')
|
||||
@@ -489,9 +539,9 @@ describe('AgentBrowserBridge', () => {
|
||||
await Promise.resolve()
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
|
||||
expect(lifecycleEvents).toContain('ensure-1')
|
||||
expect(lifecycleEvents).toContain('acquire-1')
|
||||
expect(lifecycleEvents).toContain('command-orca-tab-tab-1')
|
||||
expect(lifecycleEvents).not.toContain('ensure-2')
|
||||
expect(lifecycleEvents).not.toContain('acquire-2')
|
||||
|
||||
expect(releaseFirstScreenshot).not.toBeNull()
|
||||
releaseFirstScreenshot!()
|
||||
@@ -503,7 +553,9 @@ describe('AgentBrowserBridge', () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(lifecycleEvents.indexOf('restore-1')).toBeLessThan(lifecycleEvents.indexOf('ensure-2'))
|
||||
expect(lifecycleEvents.indexOf('restore-1')).toBeLessThan(
|
||||
lifecycleEvents.indexOf('acquire-2')
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
await expect(second).resolves.toEqual({
|
||||
@@ -665,7 +717,9 @@ describe('AgentBrowserBridge', () => {
|
||||
)
|
||||
|
||||
const runningSnapshot = bridge.snapshot()
|
||||
await Promise.resolve()
|
||||
await vi.waitFor(() => {
|
||||
expect(resolveRunningCommand).not.toBeNull()
|
||||
})
|
||||
|
||||
const destroyPromise = (
|
||||
bridge as unknown as { destroySession: (name: string) => Promise<void> }
|
||||
|
||||
@@ -86,6 +86,11 @@ type AgentBrowserExecOptions = {
|
||||
timeoutError?: BrowserError
|
||||
}
|
||||
|
||||
type EnqueueTargetedCommandOptions = {
|
||||
ensureSession?: boolean
|
||||
ensureVisible?: boolean
|
||||
}
|
||||
|
||||
type AgentBrowserBridgeOptions = {
|
||||
onTabsChanged?: (worktreeId?: string) => void
|
||||
}
|
||||
@@ -388,10 +393,10 @@ export class AgentBrowserBridge {
|
||||
private readonly sessions = new Map<string, SessionState>()
|
||||
private readonly commandQueues = new Map<string, QueuedCommand[]>()
|
||||
private readonly processingQueues = new Set<string>()
|
||||
// Why: screenshot prep temporarily changes shared renderer visibility/focus
|
||||
// state. Per-session queues only serialize commands within one browser tab, so
|
||||
// concurrent screenshots on different tabs can otherwise interleave
|
||||
// ensureWebviewVisible()/restore and blank each other's capture.
|
||||
// Why: screenshot prep temporarily changes shared renderer paintability state.
|
||||
// Per-session queues only serialize commands within one browser tab, so
|
||||
// concurrent screenshots on different tabs can otherwise interleave hidden
|
||||
// surface leases and blank each other's capture.
|
||||
private screenshotTurn: Promise<void> = Promise.resolve()
|
||||
private readonly agentBrowserBin: string
|
||||
// Why: when a process swap destroys a session that had active intercept patterns,
|
||||
@@ -1139,9 +1144,14 @@ export class AgentBrowserBridge {
|
||||
): Promise<BrowserScreenshotResult> {
|
||||
// Why: agent-browser writes the screenshot to a temp file and returns
|
||||
// { "path": "/tmp/screenshot-xxx.png" }. We read the file and return base64.
|
||||
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
|
||||
return this.captureScreenshotCommand(sessionName, ['screenshot'], 300, format)
|
||||
})
|
||||
return this.enqueueTargetedCommand(
|
||||
worktreeId,
|
||||
browserPageId,
|
||||
async (sessionName) => {
|
||||
return this.captureScreenshotCommand(sessionName, ['screenshot'], 300, format)
|
||||
},
|
||||
{ ensureVisible: false }
|
||||
)
|
||||
}
|
||||
|
||||
async fullPageScreenshot(
|
||||
@@ -1149,14 +1159,19 @@ export class AgentBrowserBridge {
|
||||
worktreeId?: string,
|
||||
browserPageId?: string
|
||||
): Promise<BrowserScreenshotResult> {
|
||||
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName, target) => {
|
||||
return this.captureFullPageScreenshotCommand(
|
||||
sessionName,
|
||||
target.webContentsId,
|
||||
500,
|
||||
format === 'jpeg' ? 'jpeg' : 'png'
|
||||
)
|
||||
})
|
||||
return this.enqueueTargetedCommand(
|
||||
worktreeId,
|
||||
browserPageId,
|
||||
async (sessionName, target) => {
|
||||
return this.captureFullPageScreenshotCommand(
|
||||
sessionName,
|
||||
target.webContentsId,
|
||||
500,
|
||||
format === 'jpeg' ? 'jpeg' : 'png'
|
||||
)
|
||||
},
|
||||
{ ensureVisible: false }
|
||||
)
|
||||
}
|
||||
|
||||
private readScreenshotFromResult(raw: unknown, format?: string): BrowserScreenshotResult {
|
||||
@@ -1180,12 +1195,12 @@ export class AgentBrowserBridge {
|
||||
return this.withSerializedScreenshotAccess(async () => {
|
||||
const session = this.sessions.get(sessionName)
|
||||
const restore = session
|
||||
? await this.browserManager.ensureWebviewVisible(session.webContentsId)
|
||||
? await this.browserManager.acquireAutomationVisibility(session.webContentsId)
|
||||
: () => {}
|
||||
try {
|
||||
// Why: after focusing the window and unhiding the webview, the compositor
|
||||
// Why: after acquiring the hidden paintability lease, the compositor
|
||||
// needs a short settle period to produce a painted frame. Waiting inside
|
||||
// the global screenshot lock prevents another tab from stealing visible
|
||||
// the global screenshot lock prevents another tab from changing lease
|
||||
// state before the current capture actually hits CDP.
|
||||
await new Promise((r) => setTimeout(r, settleMs))
|
||||
const raw = await this.execAgentBrowser(sessionName, commandArgs)
|
||||
@@ -1205,11 +1220,11 @@ export class AgentBrowserBridge {
|
||||
return this.withSerializedScreenshotAccess(async () => {
|
||||
const session = this.sessions.get(sessionName)
|
||||
const restore = session
|
||||
? await this.browserManager.ensureWebviewVisible(session.webContentsId)
|
||||
? await this.browserManager.acquireAutomationVisibility(session.webContentsId)
|
||||
: () => {}
|
||||
try {
|
||||
// Why: full-page capture still depends on the guest compositor producing
|
||||
// a fresh frame. Wait after activating the target webview so the direct
|
||||
// a fresh frame. Wait after the target webview is paintable so the direct
|
||||
// CDP capture sees the live page instead of a stale surface.
|
||||
await new Promise((r) => setTimeout(r, settleMs))
|
||||
const wc = this.getWebContents(webContentsId)
|
||||
@@ -1691,8 +1706,11 @@ export class AgentBrowserBridge {
|
||||
worktreeId: string | undefined,
|
||||
execute: (sessionName: string) => Promise<T>
|
||||
): Promise<T> {
|
||||
return this.enqueueTargetedCommand(worktreeId, undefined, async (sessionName) =>
|
||||
execute(sessionName)
|
||||
return this.enqueueTargetedCommand(
|
||||
worktreeId,
|
||||
undefined,
|
||||
async (sessionName) => execute(sessionName),
|
||||
{ ensureVisible: false }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1700,7 +1718,7 @@ export class AgentBrowserBridge {
|
||||
worktreeId: string | undefined,
|
||||
browserPageId: string | undefined,
|
||||
execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise<T>,
|
||||
options: { ensureSession?: boolean } = {}
|
||||
options: EnqueueTargetedCommandOptions = {}
|
||||
): Promise<T> {
|
||||
const target = this.resolveCommandTarget(worktreeId, browserPageId)
|
||||
const sessionName = `orca-tab-${target.browserPageId}`
|
||||
@@ -1716,7 +1734,13 @@ export class AgentBrowserBridge {
|
||||
this.commandQueues.set(sessionName, queue)
|
||||
}
|
||||
queue.push({
|
||||
execute: (() => execute(sessionName, target)) as () => Promise<unknown>,
|
||||
execute: (() =>
|
||||
this.executeWithVisibleTarget(
|
||||
sessionName,
|
||||
target,
|
||||
execute,
|
||||
options
|
||||
)) as () => Promise<unknown>,
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject
|
||||
})
|
||||
@@ -1724,6 +1748,26 @@ export class AgentBrowserBridge {
|
||||
})
|
||||
}
|
||||
|
||||
private async executeWithVisibleTarget<T>(
|
||||
sessionName: string,
|
||||
target: ResolvedBrowserCommandTarget,
|
||||
execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise<T>,
|
||||
options: EnqueueTargetedCommandOptions
|
||||
): Promise<T> {
|
||||
if (options.ensureVisible === false) {
|
||||
return execute(sessionName, target)
|
||||
}
|
||||
|
||||
// Why: inactive browser panes are display:none in the renderer; the
|
||||
// automation lease makes only this target paintable without selecting it.
|
||||
const restore = await this.browserManager.acquireAutomationVisibility(target.webContentsId)
|
||||
try {
|
||||
return await execute(sessionName, target)
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
}
|
||||
|
||||
private async processQueue(sessionName: string): Promise<void> {
|
||||
if (this.processingQueues.has(sessionName)) {
|
||||
return
|
||||
|
||||
@@ -249,6 +249,166 @@ describe('browserManager', () => {
|
||||
restore()
|
||||
})
|
||||
|
||||
it('acquires renderer automation visibility without changing active browser state', async () => {
|
||||
const rendererExecuteJavaScriptMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce('lease-1')
|
||||
.mockResolvedValueOnce(true)
|
||||
const guest = {
|
||||
id: 1707,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock
|
||||
}
|
||||
const renderer = {
|
||||
id: rendererWebContentsId,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
executeJavaScript: rendererExecuteJavaScriptMock
|
||||
}
|
||||
webContentsFromIdMock.mockImplementation((id: number) => {
|
||||
if (id === guest.id) {
|
||||
return guest
|
||||
}
|
||||
if (id === rendererWebContentsId) {
|
||||
return renderer
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'page-automation',
|
||||
workspaceId: 'workspace-1',
|
||||
worktreeId: 'wt-1',
|
||||
webContentsId: guest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
const restore = await browserManager.acquireAutomationVisibility(guest.id)
|
||||
const acquireScript = rendererExecuteJavaScriptMock.mock.calls[0]?.[0]
|
||||
expect(acquireScript).toContain('__orcaBrowserAutomationVisibility')
|
||||
expect(acquireScript).toContain('bridge.acquire("page-automation")')
|
||||
expect(acquireScript).not.toContain('setActiveBrowserTab')
|
||||
expect(acquireScript).not.toContain('setActiveTabType')
|
||||
|
||||
restore()
|
||||
|
||||
const releaseScript = rendererExecuteJavaScriptMock.mock.calls[1]?.[0]
|
||||
expect(releaseScript).toContain('bridge.release("lease-1")')
|
||||
})
|
||||
|
||||
it('returns a no-op automation visibility restore when renderer acquire hangs', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const rendererExecuteJavaScriptMock = vi.fn().mockReturnValueOnce(new Promise(() => {}))
|
||||
const guest = {
|
||||
id: 1708,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock
|
||||
}
|
||||
const renderer = {
|
||||
id: rendererWebContentsId,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
executeJavaScript: rendererExecuteJavaScriptMock
|
||||
}
|
||||
webContentsFromIdMock.mockImplementation((id: number) => {
|
||||
if (id === guest.id) {
|
||||
return guest
|
||||
}
|
||||
if (id === rendererWebContentsId) {
|
||||
return renderer
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'page-hung-acquire',
|
||||
workspaceId: 'workspace-1',
|
||||
worktreeId: 'wt-1',
|
||||
webContentsId: guest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
const restorePromise = browserManager.acquireAutomationVisibility(guest.id)
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
const restore = await restorePromise
|
||||
|
||||
restore()
|
||||
|
||||
expect(rendererExecuteJavaScriptMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('releases a delayed automation visibility token after acquire timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
let resolveAcquire: (token: string) => void = () => {}
|
||||
const acquirePromise = new Promise<string>((resolve) => {
|
||||
resolveAcquire = resolve
|
||||
})
|
||||
const rendererExecuteJavaScriptMock = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(acquirePromise)
|
||||
.mockResolvedValueOnce(true)
|
||||
const guest = {
|
||||
id: 1709,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock
|
||||
}
|
||||
const renderer = {
|
||||
id: rendererWebContentsId,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
executeJavaScript: rendererExecuteJavaScriptMock
|
||||
}
|
||||
webContentsFromIdMock.mockImplementation((id: number) => {
|
||||
if (id === guest.id) {
|
||||
return guest
|
||||
}
|
||||
if (id === rendererWebContentsId) {
|
||||
return renderer
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'page-delayed-acquire',
|
||||
workspaceId: 'workspace-1',
|
||||
worktreeId: 'wt-1',
|
||||
webContentsId: guest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
const restorePromise = browserManager.acquireAutomationVisibility(guest.id)
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
const restore = await restorePromise
|
||||
|
||||
restore()
|
||||
expect(rendererExecuteJavaScriptMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveAcquire('late-lease-1')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(rendererExecuteJavaScriptMock).toHaveBeenCalledTimes(2)
|
||||
const releaseScript = rendererExecuteJavaScriptMock.mock.calls[1]?.[0]
|
||||
expect(releaseScript).toContain('bridge.release("late-lease-1")')
|
||||
})
|
||||
|
||||
it('restores the previously focused browser workspace after screenshot prep changes tabs', async () => {
|
||||
const rendererExecuteJavaScriptMock = vi
|
||||
.fn()
|
||||
|
||||
@@ -44,6 +44,74 @@ import {
|
||||
} from '../../shared/browser-annotation-viewport-bridge'
|
||||
import type { KeybindingOverrides } from '../../shared/keybindings'
|
||||
|
||||
const AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS = 2_000
|
||||
|
||||
function resolveWithTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
fallbackValue: T
|
||||
): Promise<{ value: T; timedOut: boolean }> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
const timeoutPromise = new Promise<{ value: T; timedOut: boolean }>((resolve) => {
|
||||
timeoutId = setTimeout(() => resolve({ value: fallbackValue, timedOut: true }), timeoutMs)
|
||||
})
|
||||
return Promise.race([
|
||||
promise.then((value) => ({ value, timedOut: false })),
|
||||
timeoutPromise
|
||||
]).finally(() => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function releaseAutomationVisibilityToken(renderer: Electron.WebContents, token: string): void {
|
||||
if (renderer.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
renderer
|
||||
.executeJavaScript(
|
||||
`(function() {
|
||||
var bridge = window.__orcaBrowserAutomationVisibility;
|
||||
if (!bridge || typeof bridge.release !== 'function') return false;
|
||||
return bridge.release(${JSON.stringify(token)});
|
||||
})()`
|
||||
)
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
function cleanupLateAutomationVisibilityToken(
|
||||
renderer: Electron.WebContents,
|
||||
acquirePromise: Promise<unknown>
|
||||
): void {
|
||||
acquirePromise
|
||||
.then((lateToken) => {
|
||||
if (typeof lateToken !== 'string' || lateToken.length === 0) {
|
||||
return
|
||||
}
|
||||
// Why: the renderer creates the lease before waiting for paint; if main's
|
||||
// acquire timeout wins, release the eventual token so hidden webviews do
|
||||
// not stay paintable indefinitely.
|
||||
releaseAutomationVisibilityToken(renderer, lateToken)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
function createNoopRestoreForTimedOutAutomationAcquire(
|
||||
renderer: Electron.WebContents,
|
||||
acquirePromise: Promise<unknown>,
|
||||
timedOut: boolean
|
||||
): () => void {
|
||||
if (timedOut) {
|
||||
cleanupLateAutomationVisibilityToken(renderer, acquirePromise)
|
||||
}
|
||||
return () => {}
|
||||
}
|
||||
|
||||
function isAutomationVisibilityToken(token: unknown): token is string {
|
||||
return typeof token === 'string' && token.length > 0
|
||||
}
|
||||
|
||||
// Why: mobile presets need a touch-capable UA or responsive sites serve the
|
||||
// desktop variant based on UA sniffing. This is the Chrome DevTools default
|
||||
// iPhone UA template; we splice in the guest session's real Chrome major so
|
||||
@@ -437,6 +505,42 @@ export class BrowserManager {
|
||||
}
|
||||
}
|
||||
|
||||
async acquireAutomationVisibility(guestWebContentsId: number): Promise<() => void> {
|
||||
const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId)
|
||||
if (!browserPageId) {
|
||||
return () => {}
|
||||
}
|
||||
const renderer = this.resolveRendererForBrowserTab(browserPageId)
|
||||
if (!renderer || renderer.isDestroyed()) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
// Why: agent browser commands need a paintable webview for lazy-loading
|
||||
// sites, but must not steal the user's visible Orca tab/worktree.
|
||||
const acquirePromise = renderer
|
||||
.executeJavaScript(
|
||||
`(async function() {
|
||||
var bridge = window.__orcaBrowserAutomationVisibility;
|
||||
if (!bridge || typeof bridge.acquire !== 'function') return null;
|
||||
return await bridge.acquire(${JSON.stringify(browserPageId)});
|
||||
})()`
|
||||
)
|
||||
.catch(() => null)
|
||||
const { value: token, timedOut } = await resolveWithTimeout(
|
||||
acquirePromise,
|
||||
AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS,
|
||||
null
|
||||
)
|
||||
|
||||
if (!isAutomationVisibilityToken(token)) {
|
||||
return createNoopRestoreForTimedOutAutomationAcquire(renderer, acquirePromise, timedOut)
|
||||
}
|
||||
|
||||
return () => {
|
||||
releaseAutomationVisibilityToken(renderer, token)
|
||||
}
|
||||
}
|
||||
|
||||
attachGuestPolicies(guest: Electron.WebContents): void {
|
||||
if (this.policyAttachedGuestIds.has(guest.id)) {
|
||||
return
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/* eslint-disable max-lines -- Why: this file is a command adapter for one external surface, Agent Browser automation. It stays separate from OrcaRuntimeService so runtime state does not grow further while browser routing remains easy to scan in one place. */
|
||||
import { randomUUID } from 'crypto'
|
||||
import { ipcMain, webContents, type BrowserWindow } from 'electron'
|
||||
import { getRepoIdFromWorktreeId } from '../../shared/worktree-id'
|
||||
import type {
|
||||
BrowserBackResult,
|
||||
BrowserCaptureStartResult,
|
||||
@@ -248,22 +247,14 @@ export class RuntimeBrowserCommands {
|
||||
return { browserPageId: resolvedPageId, webContents: guest }
|
||||
}
|
||||
|
||||
// Why: browser tabs only mount (and become operable) when their worktree is
|
||||
// the active worktree in the renderer AND activeTabType is 'browser'. If either
|
||||
// condition is false, the webview stays in display:none and Electron won't start
|
||||
// its guest process — dom-ready never fires, registerGuest never runs, and CLI
|
||||
// browser commands fail with "CDP connection refused".
|
||||
// Why: browser tabs must become paintable before their webview guest starts
|
||||
// and registerGuest fires, but automation must not steal the user's visible
|
||||
// worktree/browser pane. Ask the renderer to background-mount the worktree and
|
||||
// acquire a hidden automation visibility lease instead of activating the UI.
|
||||
private async ensureBrowserWorktreeActive(worktreeId: string): Promise<void> {
|
||||
const win = this.host.getAuthoritativeWindow()
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
if (!repoId) {
|
||||
return
|
||||
}
|
||||
win.webContents.send('ui:activateWorktree', { repoId, worktreeId })
|
||||
// Why: switching worktree alone sets activeView='terminal'. Browser webviews
|
||||
// won't mount until activeTabType is 'browser'. Send a second IPC to flip it.
|
||||
win.webContents.send('browser:activateView', { worktreeId })
|
||||
// Why: give the renderer time to mount the webview after switching worktrees.
|
||||
// Why: give the renderer time to mount the hidden paintable webview.
|
||||
// The webview needs to attach and fire dom-ready before registerGuest runs.
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
|
||||
@@ -7854,6 +7854,45 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves an exact path selector when duplicate repo entries expose the same path', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const duplicatePath = '/tmp/workspaces/runtime-duplicate-selector'
|
||||
const getRepos = vi.spyOn(store, 'getRepos').mockReturnValue([
|
||||
{
|
||||
id: TEST_REPO_ID,
|
||||
path: TEST_REPO_PATH,
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1
|
||||
},
|
||||
{
|
||||
id: 'repo-duplicate-entry',
|
||||
path: '/tmp/repo-secondary-worktree',
|
||||
displayName: 'repo-secondary-worktree',
|
||||
badgeColor: 'red',
|
||||
addedAt: 2
|
||||
}
|
||||
])
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
{
|
||||
path: duplicatePath,
|
||||
head: 'def',
|
||||
branch: 'runtime-duplicate-selector',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
try {
|
||||
const worktree = await runtime.showManagedWorktree(`path:${duplicatePath}`)
|
||||
|
||||
expect(worktree.id).toBe(`${TEST_REPO_ID}::${duplicatePath}`)
|
||||
expect(worktree.path).toBe(duplicatePath)
|
||||
} finally {
|
||||
getRepos.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps CLI-created worktrees successful when initial terminal creation fails', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const spawn = vi.fn().mockRejectedValue(new Error('pty unavailable'))
|
||||
|
||||
@@ -9491,6 +9491,13 @@ export class OrcaRuntimeService {
|
||||
candidates = worktrees.filter((worktree) =>
|
||||
runtimePathsEqual(worktree.path, selector.slice(5))
|
||||
)
|
||||
if (candidates.length > 1) {
|
||||
// Why: registering another worktree from the same Git repo makes git
|
||||
// report the same physical worktree path under multiple repo IDs.
|
||||
// A path selector is already exact, so prefer the first resolved row
|
||||
// instead of surfacing a duplicate-registration ambiguity.
|
||||
candidates = [candidates[0]]
|
||||
}
|
||||
} else if (selector.startsWith('branch:')) {
|
||||
const branchSelector = selector.slice(7)
|
||||
candidates = worktrees.filter((worktree) =>
|
||||
|
||||
@@ -328,7 +328,7 @@ export type BrowserApi = {
|
||||
onNavigationUpdate: (
|
||||
callback: (event: { browserPageId: string; url: string; title: string }) => void
|
||||
) => () => void
|
||||
onActivateView: (callback: (data: { worktreeId: string }) => void) => () => void
|
||||
onActivateView: (callback: (data: { worktreeId?: string }) => void) => () => void
|
||||
onPaneFocus: (
|
||||
callback: (data: { worktreeId: string | null; browserPageId: string }) => void
|
||||
) => () => void
|
||||
|
||||
@@ -1712,8 +1712,8 @@ const api = {
|
||||
return () => ipcRenderer.removeListener('browser:navigation-update', listener)
|
||||
},
|
||||
|
||||
onActivateView: (callback: (data: { worktreeId: string }) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) =>
|
||||
onActivateView: (callback: (data: { worktreeId?: string }) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId?: string }) =>
|
||||
callback(data)
|
||||
ipcRenderer.on('browser:activateView', listener)
|
||||
return () => ipcRenderer.removeListener('browser:activateView', listener)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useEffect, useCallback, useMemo, useRef, useState, lazy, Suspense } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT,
|
||||
TOGGLE_TERMINAL_PANE_EXPAND_EVENT,
|
||||
@@ -36,6 +37,7 @@ import type { TabGroupLayoutNode } from '../../../shared/types'
|
||||
import BrowserPane from './browser-pane/BrowserPane'
|
||||
import { destroyPersistentWebview } from './browser-pane/webview-registry'
|
||||
import BrowserPaneOverlayLayer from './browser-pane/BrowserPaneOverlayLayer'
|
||||
import { useBrowserAutomationVisibilityForAny } from './browser-pane/browser-automation-visibility'
|
||||
import TerminalPaneOverlayLayer from './terminal-pane/TerminalPaneOverlayLayer'
|
||||
import {
|
||||
collectBrowserWebviewIds,
|
||||
@@ -1788,15 +1790,28 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
|
||||
shouldMeasureHiddenWorktree: boolean
|
||||
activityTerminalPortals: ActivityTerminalPortalTarget[]
|
||||
}): React.JSX.Element {
|
||||
const browserPageIds = useAppStore(
|
||||
useShallow((state) =>
|
||||
(state.browserTabsByWorktree[worktreeId] ?? []).flatMap((tab) =>
|
||||
tab.pageIds && tab.pageIds.length > 0 ? tab.pageIds : [tab.activePageId ?? tab.id]
|
||||
)
|
||||
)
|
||||
)
|
||||
const hasAutomationVisibleBrowser = useBrowserAutomationVisibilityForAny(browserPageIds)
|
||||
const shouldKeepPaintable = shouldMeasureHiddenWorktree || hasAutomationVisibleBrowser
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
isVisible
|
||||
? 'absolute inset-0 flex'
|
||||
: shouldMeasureHiddenWorktree
|
||||
: shouldKeepPaintable
|
||||
? 'absolute inset-0 flex opacity-0 pointer-events-none'
|
||||
: 'absolute inset-0 hidden'
|
||||
}
|
||||
// Why: automation-visible panes must stay paintable for webviews, but
|
||||
// invisible controls cannot remain reachable by Tab or assistive tech.
|
||||
inert={!isVisible}
|
||||
aria-hidden={!isVisible}
|
||||
>
|
||||
<CodexRestartChip worktreeId={worktreeId} />
|
||||
|
||||
@@ -83,6 +83,7 @@ import {
|
||||
registeredWebContentsIds,
|
||||
webviewRegistry
|
||||
} from './webview-registry'
|
||||
import { useBrowserAutomationVisiblePageIds } from './browser-automation-visibility'
|
||||
import type {
|
||||
BrowserDownloadRequestedEvent,
|
||||
BrowserDownloadProgressEvent,
|
||||
@@ -784,6 +785,20 @@ export default function BrowserPane({
|
||||
const setBrowserPageUrl = useAppStore((s) => s.setBrowserPageUrl)
|
||||
const runtimeEnvironmentActive = Boolean(activeRuntimeEnvironmentId?.trim())
|
||||
const activeBrowserPageId = activeBrowserPage?.id ?? null
|
||||
const browserPageIds = useMemo(() => browserPages.map((page) => page.id), [browserPages])
|
||||
const automationVisiblePageIds = useBrowserAutomationVisiblePageIds(browserPageIds)
|
||||
const renderedBrowserPages = useMemo(() => {
|
||||
const pages: BrowserPageState[] = []
|
||||
if (activeBrowserPage) {
|
||||
pages.push(activeBrowserPage)
|
||||
}
|
||||
for (const page of browserPages) {
|
||||
if (page.id !== activeBrowserPage?.id && automationVisiblePageIds.has(page.id)) {
|
||||
pages.push(page)
|
||||
}
|
||||
}
|
||||
return pages
|
||||
}, [activeBrowserPage, automationVisiblePageIds, browserPages])
|
||||
const [activeBrowserDriver, setActiveBrowserDriver] = useState<BrowserDriverState>({
|
||||
kind: 'idle'
|
||||
})
|
||||
@@ -834,19 +849,22 @@ export default function BrowserPane({
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 flex-1 flex-col">
|
||||
{activeBrowserPage ? (
|
||||
{renderedBrowserPages.length > 0 ? (
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
<BrowserPagePane
|
||||
key={activeBrowserPage.id}
|
||||
browserTab={activeBrowserPage}
|
||||
workspaceId={browserTab.id}
|
||||
worktreeId={browserTab.worktreeId}
|
||||
sessionProfileId={browserTab.sessionProfileId ?? null}
|
||||
isActive={isActive}
|
||||
inputLocked={activeBrowserDriver.kind === 'mobile'}
|
||||
onUpdatePageState={updateBrowserPageState}
|
||||
onSetUrl={setBrowserPageUrl}
|
||||
/>
|
||||
{renderedBrowserPages.map((page) => (
|
||||
<BrowserPagePane
|
||||
key={page.id}
|
||||
browserTab={page}
|
||||
workspaceId={browserTab.id}
|
||||
worktreeId={browserTab.worktreeId}
|
||||
sessionProfileId={browserTab.sessionProfileId ?? null}
|
||||
isActive={isActive && page.id === activeBrowserPage?.id}
|
||||
isAutomationVisible={automationVisiblePageIds.has(page.id)}
|
||||
inputLocked={activeBrowserDriver.kind === 'mobile'}
|
||||
onUpdatePageState={updateBrowserPageState}
|
||||
onSetUrl={setBrowserPageUrl}
|
||||
/>
|
||||
))}
|
||||
<BrowserMobileDriverOverlay
|
||||
driver={activeBrowserDriver}
|
||||
onTakeBack={reclaimActiveBrowserForDesktop}
|
||||
@@ -2502,6 +2520,7 @@ function BrowserPagePane({
|
||||
worktreeId,
|
||||
sessionProfileId,
|
||||
isActive,
|
||||
isAutomationVisible,
|
||||
inputLocked,
|
||||
onUpdatePageState,
|
||||
onSetUrl
|
||||
@@ -2511,10 +2530,12 @@ function BrowserPagePane({
|
||||
worktreeId: string
|
||||
sessionProfileId: string | null
|
||||
isActive: boolean
|
||||
isAutomationVisible: boolean
|
||||
inputLocked: boolean
|
||||
onUpdatePageState: (tabId: string, updates: BrowserTabPageState) => void
|
||||
onSetUrl: (tabId: string, url: string) => void
|
||||
}): React.JSX.Element {
|
||||
const isPaintable = isActive || isAutomationVisible
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const addressBarInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const webviewRef = useRef<Electron.WebviewTag | null>(null)
|
||||
@@ -4171,8 +4192,16 @@ function BrowserPagePane({
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 flex min-h-0 flex-1 flex-col',
|
||||
isActive ? 'z-10' : 'pointer-events-none hidden'
|
||||
isActive
|
||||
? 'z-10'
|
||||
: isPaintable
|
||||
? 'pointer-events-none z-0 opacity-0'
|
||||
: 'pointer-events-none hidden'
|
||||
)}
|
||||
// Why: automation-visible webviews must remain mounted and paintable, but
|
||||
// their hidden toolbar and guest content cannot stay keyboard-focusable.
|
||||
inert={!isActive}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
{/* IPC-driven context menu — rendered in a Portal so position: fixed is
|
||||
relative to the viewport, not affected by ancestor backdrop-filter or
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useAppStore } from '../../store'
|
||||
import type { BrowserTab as BrowserTabState, Tab, TabGroup } from '../../../../shared/types'
|
||||
import BrowserPane from './BrowserPane'
|
||||
import { tabGroupBodyAnchorName } from '../tab-group/tab-group-body-anchor'
|
||||
import { useBrowserAutomationVisibilityForAny } from './browser-automation-visibility'
|
||||
|
||||
// Why: Electron `<webview>` destroys its guest contents whenever its DOM
|
||||
// parent changes. Rendering one BrowserPane per tab at the worktree level
|
||||
@@ -52,6 +53,12 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({
|
||||
onFocusOwningGroup
|
||||
}: BrowserOverlaySlotProps): React.JSX.Element {
|
||||
const anchorName = groupId !== undefined ? tabGroupBodyAnchorName(groupId) : undefined
|
||||
const automationVisible = useBrowserAutomationVisibilityForAny(
|
||||
browserTab.pageIds && browserTab.pageIds.length > 0
|
||||
? browserTab.pageIds
|
||||
: [browserTab.activePageId ?? browserTab.id]
|
||||
)
|
||||
const isPaintable = isActive || automationVisible
|
||||
// Why: each overlay pins itself to the owning TabGroupPanel's body via CSS
|
||||
// anchor positioning. `anchor()` resolves top/left relative to the viewport,
|
||||
// and the overlay's own `position: absolute` inside a positioned ancestor
|
||||
@@ -74,8 +81,9 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({
|
||||
left: `anchor(${anchorName} left)`,
|
||||
width: `anchor-size(${anchorName} width)`,
|
||||
height: `anchor-size(${anchorName} height)`,
|
||||
display: isActive ? 'flex' : 'none',
|
||||
pointerEvents: isActive ? 'auto' : 'none'
|
||||
display: isPaintable ? 'flex' : 'none',
|
||||
pointerEvents: isActive ? 'auto' : 'none',
|
||||
opacity: isActive ? 1 : 0
|
||||
}
|
||||
: {
|
||||
position: 'absolute',
|
||||
@@ -86,7 +94,7 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({
|
||||
display: 'none',
|
||||
pointerEvents: 'none'
|
||||
},
|
||||
[anchorName, isActive]
|
||||
[anchorName, isActive, isPaintable]
|
||||
)
|
||||
const handleFocus = useCallback(() => {
|
||||
if (groupId !== undefined && onFocusOwningGroup) {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('browser automation visibility leases', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.stubGlobal('window', {
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => {
|
||||
callback(0)
|
||||
return 1
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('keeps a page visible until every lease is released', async () => {
|
||||
const {
|
||||
acquireBrowserAutomationVisibility,
|
||||
isBrowserAutomationVisible,
|
||||
releaseBrowserAutomationVisibility
|
||||
} = await import('./browser-automation-visibility')
|
||||
|
||||
const first = acquireBrowserAutomationVisibility('page-1')
|
||||
const second = acquireBrowserAutomationVisibility('page-1')
|
||||
|
||||
expect(isBrowserAutomationVisible('page-1')).toBe(true)
|
||||
|
||||
expect(releaseBrowserAutomationVisibility(first)).toBe(true)
|
||||
expect(isBrowserAutomationVisible('page-1')).toBe(true)
|
||||
|
||||
expect(releaseBrowserAutomationVisibility(second)).toBe(true)
|
||||
expect(isBrowserAutomationVisible('page-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('installs a main-process bridge that waits for paint before returning a token', async () => {
|
||||
const animationFrameCallbacks: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('window', {
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => {
|
||||
animationFrameCallbacks.push(callback)
|
||||
return animationFrameCallbacks.length
|
||||
}
|
||||
})
|
||||
const { isBrowserAutomationVisible } = await import('./browser-automation-visibility')
|
||||
|
||||
const bridge = window.__orcaBrowserAutomationVisibility
|
||||
expect(bridge).toBeTruthy()
|
||||
|
||||
const acquirePromise = bridge?.acquire('page-2')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(isBrowserAutomationVisible('page-2')).toBe(false)
|
||||
expect(animationFrameCallbacks).toHaveLength(1)
|
||||
|
||||
animationFrameCallbacks.shift()?.(0)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(isBrowserAutomationVisible('page-2')).toBe(false)
|
||||
expect(animationFrameCallbacks).toHaveLength(1)
|
||||
|
||||
animationFrameCallbacks.shift()?.(16)
|
||||
const token = await acquirePromise
|
||||
|
||||
expect(typeof token).toBe('string')
|
||||
expect(isBrowserAutomationVisible('page-2')).toBe(true)
|
||||
expect(bridge?.release(token ?? '')).toBe(true)
|
||||
expect(isBrowserAutomationVisible('page-2')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not allocate a main-process bridge lease when the paint wait hangs', async () => {
|
||||
vi.stubGlobal('window', {
|
||||
requestAnimationFrame: () => 1
|
||||
})
|
||||
const { isBrowserAutomationVisible } = await import('./browser-automation-visibility')
|
||||
|
||||
const bridge = window.__orcaBrowserAutomationVisibility
|
||||
expect(bridge).toBeTruthy()
|
||||
|
||||
void bridge?.acquire('page-hung-paint')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(isBrowserAutomationVisible('page-hung-paint')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useMemo, useSyncExternalStore } from 'react'
|
||||
|
||||
type BrowserAutomationVisibilityBridge = {
|
||||
acquire: (browserPageId: string) => Promise<string | null>
|
||||
release: (token: string) => boolean
|
||||
}
|
||||
|
||||
declare global {
|
||||
// oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging requires interface
|
||||
interface Window {
|
||||
__orcaBrowserAutomationVisibility?: BrowserAutomationVisibilityBridge
|
||||
}
|
||||
}
|
||||
|
||||
const leaseCountsByPageId = new Map<string, number>()
|
||||
const pageIdByToken = new Map<string, string>()
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
let version = 0
|
||||
let nextLeaseId = 0
|
||||
|
||||
function emitChange(): void {
|
||||
version += 1
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
function getSnapshot(): number {
|
||||
return version
|
||||
}
|
||||
|
||||
function getServerSnapshot(): number {
|
||||
return 0
|
||||
}
|
||||
|
||||
function nextAnimationFrame(): Promise<void> {
|
||||
if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve) => window.requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
|
||||
export function isBrowserAutomationVisible(browserPageId: string): boolean {
|
||||
return (leaseCountsByPageId.get(browserPageId) ?? 0) > 0
|
||||
}
|
||||
|
||||
export function useBrowserAutomationVisibility(browserPageId: string | null | undefined): boolean {
|
||||
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
||||
return Boolean(browserPageId && isBrowserAutomationVisible(browserPageId))
|
||||
}
|
||||
|
||||
export function useBrowserAutomationVisibilityForAny(
|
||||
browserPageIds: readonly (string | null | undefined)[]
|
||||
): boolean {
|
||||
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
||||
return browserPageIds.some((pageId) => Boolean(pageId && isBrowserAutomationVisible(pageId)))
|
||||
}
|
||||
|
||||
export function getBrowserAutomationVisiblePageIds(browserPageIds: readonly string[]): Set<string> {
|
||||
const visible = new Set<string>()
|
||||
for (const pageId of browserPageIds) {
|
||||
if (isBrowserAutomationVisible(pageId)) {
|
||||
visible.add(pageId)
|
||||
}
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
export function useBrowserAutomationVisiblePageIds(browserPageIds: readonly string[]): Set<string> {
|
||||
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
||||
return useMemo(
|
||||
() => getBrowserAutomationVisiblePageIds(browserPageIds),
|
||||
[browserPageIds, snapshot]
|
||||
)
|
||||
}
|
||||
|
||||
export function acquireBrowserAutomationVisibility(browserPageId: string): string {
|
||||
const token = `browser-automation-${Date.now()}-${++nextLeaseId}`
|
||||
pageIdByToken.set(token, browserPageId)
|
||||
leaseCountsByPageId.set(browserPageId, (leaseCountsByPageId.get(browserPageId) ?? 0) + 1)
|
||||
emitChange()
|
||||
return token
|
||||
}
|
||||
|
||||
export function releaseBrowserAutomationVisibility(token: string): boolean {
|
||||
const browserPageId = pageIdByToken.get(token)
|
||||
if (!browserPageId) {
|
||||
return false
|
||||
}
|
||||
pageIdByToken.delete(token)
|
||||
const nextCount = (leaseCountsByPageId.get(browserPageId) ?? 1) - 1
|
||||
if (nextCount > 0) {
|
||||
leaseCountsByPageId.set(browserPageId, nextCount)
|
||||
} else {
|
||||
leaseCountsByPageId.delete(browserPageId)
|
||||
}
|
||||
emitChange()
|
||||
return true
|
||||
}
|
||||
|
||||
async function acquireForMainProcess(browserPageId: string): Promise<string | null> {
|
||||
if (typeof browserPageId !== 'string' || browserPageId.length === 0) {
|
||||
return null
|
||||
}
|
||||
// Why: only allocate the lease after the paint wait succeeds. If RAF hangs,
|
||||
// main times out without leaving a permanently visible hidden webview.
|
||||
await nextAnimationFrame()
|
||||
await nextAnimationFrame()
|
||||
return acquireBrowserAutomationVisibility(browserPageId)
|
||||
}
|
||||
|
||||
export function installBrowserAutomationVisibilityBridge(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
window.__orcaBrowserAutomationVisibility = {
|
||||
acquire: acquireForMainProcess,
|
||||
release: releaseBrowserAutomationVisibility
|
||||
}
|
||||
}
|
||||
|
||||
installBrowserAutomationVisibilityBridge()
|
||||
@@ -117,6 +117,241 @@ describe('resolveBrowserSessionTabTarget', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useIpcEvents browser tab create routing', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('leases the newly created browser page even when another page is active', async () => {
|
||||
const acquireBrowserAutomationVisibility = vi.fn(() => 'lease-new-page')
|
||||
const releaseBrowserAutomationVisibility = vi.fn()
|
||||
const replyTabCreate = vi.fn()
|
||||
const dispatchEvent = vi.fn()
|
||||
const requestTabCreateListenerRef: {
|
||||
current:
|
||||
| ((data: {
|
||||
requestId: string
|
||||
worktreeId?: string | null
|
||||
url: string
|
||||
sessionProfileId?: string
|
||||
}) => void)
|
||||
| null
|
||||
} = { current: null }
|
||||
const state = {
|
||||
setUpdateStatus: vi.fn(),
|
||||
fetchRepos: vi.fn(),
|
||||
fetchWorktrees: vi.fn(),
|
||||
setActiveView: vi.fn(),
|
||||
activeModal: null,
|
||||
closeModal: vi.fn(),
|
||||
openModal: vi.fn(),
|
||||
activeWorktreeId: 'wt-1',
|
||||
activeView: 'terminal',
|
||||
setActiveRepo: vi.fn(),
|
||||
setActiveWorktree: vi.fn(),
|
||||
revealWorktreeInSidebar: vi.fn(),
|
||||
setIsFullScreen: vi.fn(),
|
||||
updateBrowserTabPageState: vi.fn(),
|
||||
updateBrowserPageState: vi.fn(),
|
||||
activeTabType: 'terminal',
|
||||
editorFontZoomLevel: 0,
|
||||
setEditorFontZoomLevel: vi.fn(),
|
||||
setRateLimitsFromPush: vi.fn(),
|
||||
setSshConnectionState: vi.fn(),
|
||||
setSshTargetLabels: vi.fn(),
|
||||
setPortForwards: vi.fn(),
|
||||
clearPortForwards: vi.fn(),
|
||||
setDetectedPorts: vi.fn(),
|
||||
enqueueSshCredentialRequest: vi.fn(),
|
||||
removeSshCredentialRequest: vi.fn(),
|
||||
clearTabPtyId: vi.fn(),
|
||||
settings: { terminalFontSize: 13 },
|
||||
activeBrowserTabIdByWorktree: { 'wt-1': 'workspace-active' },
|
||||
browserTabsByWorktree: {
|
||||
'wt-1': [{ id: 'workspace-active', activePageId: 'page-active', pageIds: ['page-active'] }]
|
||||
},
|
||||
browserPagesByWorkspace: {
|
||||
'workspace-active': [{ id: 'page-active', worktreeId: 'wt-1' }]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'unified-active',
|
||||
groupId: 'group-1',
|
||||
contentType: 'browser',
|
||||
entityId: 'workspace-active'
|
||||
}
|
||||
]
|
||||
},
|
||||
createBrowserTab: vi.fn(
|
||||
(_worktreeId: string, _url: string, options: { activate?: boolean }) => {
|
||||
const workspace = { id: 'workspace-new', activePageId: 'page-new', pageIds: ['page-new'] }
|
||||
state.browserTabsByWorktree['wt-1'].push(workspace)
|
||||
state.browserPagesByWorkspace['workspace-new'] = [{ id: 'page-new', worktreeId: 'wt-1' }]
|
||||
expect(options.activate).toBe(false)
|
||||
return workspace
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
vi.doMock('react', async () => {
|
||||
const actual = await vi.importActual<typeof ReactModule>('react')
|
||||
return {
|
||||
...actual,
|
||||
useEffect: (effect: () => void | (() => void)) => {
|
||||
effect()
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
getState: () => state
|
||||
}
|
||||
}))
|
||||
vi.doMock('@/components/browser-pane/browser-automation-visibility', () => ({
|
||||
acquireBrowserAutomationVisibility,
|
||||
releaseBrowserAutomationVisibility
|
||||
}))
|
||||
vi.doMock('@/lib/ui-zoom', () => ({ applyUIZoom: vi.fn() }))
|
||||
vi.doMock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree: vi.fn(),
|
||||
ensureWorktreeHasInitialTerminal: vi.fn()
|
||||
}))
|
||||
vi.doMock('@/components/sidebar/visible-worktrees', () => ({ getVisibleWorktreeIds: () => [] }))
|
||||
vi.doMock('@/lib/editor-font-zoom', () => ({
|
||||
nextEditorFontZoomLevel: vi.fn(() => 0),
|
||||
computeEditorFontSize: vi.fn(() => 13)
|
||||
}))
|
||||
vi.doMock('@/components/settings/SettingsConstants', () => ({
|
||||
zoomLevelToPercent: vi.fn(() => 100),
|
||||
ZOOM_MIN: -3,
|
||||
ZOOM_MAX: 3
|
||||
}))
|
||||
vi.doMock('@/lib/zoom-events', () => ({ dispatchZoomLevelChanged: vi.fn() }))
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
dispatchEvent,
|
||||
setTimeout: vi.fn(() => 1),
|
||||
clearTimeout: vi.fn(),
|
||||
api: {
|
||||
repos: { onChanged: () => () => {} },
|
||||
worktrees: {
|
||||
onChanged: () => () => {},
|
||||
onBaseStatus: () => () => {},
|
||||
onRemoteBranchConflict: () => () => {}
|
||||
},
|
||||
ui: {
|
||||
onOpenSettings: () => () => {},
|
||||
onOpenFeatureTour: () => () => {},
|
||||
onToggleLeftSidebar: () => () => {},
|
||||
onToggleRightSidebar: () => () => {},
|
||||
onToggleWorktreePalette: () => () => {},
|
||||
onToggleFloatingTerminal: () => () => {},
|
||||
onOpenQuickOpen: () => () => {},
|
||||
onOpenNewWorkspace: () => () => {},
|
||||
onOpenTasks: () => () => {},
|
||||
onJumpToWorktreeIndex: () => () => {},
|
||||
onWorktreeHistoryNavigate: () => () => {},
|
||||
onActivateWorktree: () => () => {},
|
||||
onCreateTerminal: () => () => {},
|
||||
onRequestTerminalCreate: () => () => {},
|
||||
replyTerminalCreate: () => {},
|
||||
onSplitTerminal: () => () => {},
|
||||
onRenameTerminal: () => () => {},
|
||||
onFocusTerminal: () => () => {},
|
||||
onFocusEditorTab: () => () => {},
|
||||
onCloseSessionTab: () => () => {},
|
||||
onMoveSessionTab: () => () => {},
|
||||
onOpenFileFromMobile: () => () => {},
|
||||
onOpenDiffFromMobile: () => () => {},
|
||||
onCloseTerminal: () => () => {},
|
||||
onSleepWorktree: () => () => {},
|
||||
onNewBrowserTab: () => () => {},
|
||||
onRequestTabCreate: (
|
||||
listener: NonNullable<typeof requestTabCreateListenerRef.current>
|
||||
) => {
|
||||
requestTabCreateListenerRef.current = listener
|
||||
return () => {}
|
||||
},
|
||||
replyTabCreate,
|
||||
onRequestTabClose: () => () => {},
|
||||
replyTabClose: () => {},
|
||||
onRequestTabSetProfile: () => () => {},
|
||||
replyTabSetProfile: () => {},
|
||||
onNewTerminalTab: () => () => {},
|
||||
onCloseActiveTab: () => () => {},
|
||||
onSwitchTab: () => () => {},
|
||||
onSwitchTabAcrossAllTypes: () => () => {},
|
||||
onSwitchRecentTab: () => () => {},
|
||||
onSwitchTerminalTab: () => () => {},
|
||||
onToggleStatusBar: () => () => {},
|
||||
onFullscreenChanged: () => () => {},
|
||||
onTerminalZoom: () => () => {},
|
||||
getZoomLevel: () => 0,
|
||||
set: vi.fn()
|
||||
},
|
||||
settings: { onChanged: () => () => {} },
|
||||
updater: {
|
||||
getStatus: () => Promise.resolve({ state: 'idle' }),
|
||||
onStatus: () => () => {},
|
||||
onClearDismissal: () => () => {}
|
||||
},
|
||||
browser: {
|
||||
onGuestLoadFailed: () => () => {},
|
||||
onOpenLinkInOrcaTab: () => () => {},
|
||||
onNavigationUpdate: () => () => {},
|
||||
onActivateView: () => () => {},
|
||||
onPaneFocus: () => () => {}
|
||||
},
|
||||
rateLimits: {
|
||||
get: () => Promise.resolve({ limits: {}, lastUpdatedAt: Date.now() }),
|
||||
onUpdate: () => () => {}
|
||||
},
|
||||
ssh: {
|
||||
listTargets: () => Promise.resolve([]),
|
||||
listPortForwards: () => Promise.resolve([]),
|
||||
listDetectedPorts: () => Promise.resolve([]),
|
||||
getState: () => Promise.resolve(null),
|
||||
onStateChanged: () => () => {},
|
||||
onCredentialRequest: () => () => {},
|
||||
onPortForwardsChanged: () => () => {},
|
||||
onDetectedPortsChanged: () => () => {},
|
||||
onCredentialResolved: () => () => {}
|
||||
},
|
||||
runtime: {
|
||||
getTerminalFitOverrides: () => Promise.resolve([]),
|
||||
getTerminalDrivers: () => Promise.resolve([]),
|
||||
getBrowserDrivers: () => Promise.resolve([]),
|
||||
onTerminalFitOverrideChanged: () => () => {},
|
||||
onTerminalDriverChanged: () => () => {},
|
||||
onBrowserDriverChanged: () => () => {}
|
||||
},
|
||||
agentStatus: { onSet: () => () => {} }
|
||||
}
|
||||
})
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
useIpcEvents()
|
||||
|
||||
requestTabCreateListenerRef.current?.({
|
||||
requestId: 'req-create',
|
||||
worktreeId: 'wt-1',
|
||||
url: 'https://example.com'
|
||||
})
|
||||
|
||||
expect(acquireBrowserAutomationVisibility).toHaveBeenCalledWith('page-new')
|
||||
expect(acquireBrowserAutomationVisibility).not.toHaveBeenCalledWith('page-active')
|
||||
expect(replyTabCreate).toHaveBeenCalledWith({
|
||||
requestId: 'req-create',
|
||||
browserPageId: 'page-new'
|
||||
})
|
||||
expect(dispatchEvent).toHaveBeenCalled()
|
||||
expect(releaseBrowserAutomationVisibility).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useIpcEvents updater integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
|
||||
@@ -56,6 +56,10 @@ import {
|
||||
setDriverForBrowserPage
|
||||
} from '@/lib/pane-manager/browser-mobile-driver-state'
|
||||
import { destroyPersistentWebview } from '@/components/browser-pane/webview-registry'
|
||||
import {
|
||||
acquireBrowserAutomationVisibility,
|
||||
releaseBrowserAutomationVisibility
|
||||
} from '@/components/browser-pane/browser-automation-visibility'
|
||||
import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
@@ -92,6 +96,54 @@ function getShortcutPlatform(): NodeJS.Platform {
|
||||
return 'linux'
|
||||
}
|
||||
|
||||
const BROWSER_AUTOMATION_BOOTSTRAP_LEASE_MS = 10_000
|
||||
const browserAutomationBootstrapLeaseByPageId = new Map<string, { token: string; timer: number }>()
|
||||
|
||||
function releaseBrowserAutomationBootstrapLease(browserPageId: string): void {
|
||||
const existing = browserAutomationBootstrapLeaseByPageId.get(browserPageId)
|
||||
if (!existing) {
|
||||
return
|
||||
}
|
||||
window.clearTimeout(existing.timer)
|
||||
releaseBrowserAutomationVisibility(existing.token)
|
||||
browserAutomationBootstrapLeaseByPageId.delete(browserPageId)
|
||||
}
|
||||
|
||||
function acquireBrowserAutomationBootstrapLease(
|
||||
worktreeId: string | null | undefined,
|
||||
browserPageId?: string | null
|
||||
): void {
|
||||
const store = useAppStore.getState()
|
||||
const targetWorktreeId = worktreeId ?? store.activeWorktreeId
|
||||
if (!targetWorktreeId) {
|
||||
return
|
||||
}
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, {
|
||||
detail: { worktreeId: targetWorktreeId }
|
||||
})
|
||||
)
|
||||
let targetBrowserPageId = browserPageId ?? null
|
||||
if (!targetBrowserPageId) {
|
||||
const browserTabs = store.browserTabsByWorktree[targetWorktreeId] ?? []
|
||||
const activeWorkspaceId = store.activeBrowserTabIdByWorktree[targetWorktreeId] ?? null
|
||||
const workspace =
|
||||
browserTabs.find((tab) => tab.id === activeWorkspaceId) ?? browserTabs[0] ?? null
|
||||
targetBrowserPageId =
|
||||
workspace?.activePageId ?? workspace?.pageIds?.[0] ?? workspace?.id ?? null
|
||||
}
|
||||
if (!targetBrowserPageId) {
|
||||
return
|
||||
}
|
||||
|
||||
releaseBrowserAutomationBootstrapLease(targetBrowserPageId)
|
||||
const token = acquireBrowserAutomationVisibility(targetBrowserPageId)
|
||||
const timer = window.setTimeout(() => {
|
||||
releaseBrowserAutomationBootstrapLease(targetBrowserPageId)
|
||||
}, BROWSER_AUTOMATION_BOOTSTRAP_LEASE_MS)
|
||||
browserAutomationBootstrapLeaseByPageId.set(targetBrowserPageId, { token, timer })
|
||||
}
|
||||
|
||||
export { resolveZoomTarget } from './resolve-zoom-target'
|
||||
|
||||
const ZOOM_STEP = 0.5
|
||||
@@ -1282,15 +1334,14 @@ export function useIpcEvents(): void {
|
||||
)
|
||||
|
||||
// Why: browser webviews only start their guest process when the container
|
||||
// has display != none. After app restart, activeTabType defaults to 'terminal'
|
||||
// so persisted browser tabs never mount. The main process sends this IPC
|
||||
// before browser commands so the webview can start and registerGuest fires.
|
||||
// has display != none. Main sends this before browser automation commands
|
||||
// so persisted hidden tabs mount without changing the user's active pane.
|
||||
unsubs.push(
|
||||
window.api.browser.onActivateView(() => {
|
||||
window.api.browser.onActivateView(({ worktreeId }) => {
|
||||
if (isRuntimeEnvironmentActive()) {
|
||||
return
|
||||
}
|
||||
useAppStore.getState().setActiveTabType('browser')
|
||||
acquireBrowserAutomationBootstrapLease(worktreeId)
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1378,7 +1429,7 @@ export function useIpcEvents(): void {
|
||||
)
|
||||
|
||||
// Why: CLI-driven tab creation sends a request with a specific worktreeId and
|
||||
// url. The renderer creates the tab and replies with the workspace ID so the
|
||||
// url. The renderer creates the tab and replies with the page ID so the
|
||||
// main process can wait for registerGuest before returning to the CLI.
|
||||
unsubs.push(
|
||||
window.api.ui.onRequestTabCreate((data) => {
|
||||
@@ -1411,13 +1462,15 @@ export function useIpcEvents(): void {
|
||||
const workspace = store.createBrowserTab(worktreeId, data.url, {
|
||||
title: data.url,
|
||||
targetGroupId: activeBrowserUnifiedTab?.groupId,
|
||||
sessionProfileId: data.sessionProfileId
|
||||
sessionProfileId: data.sessionProfileId,
|
||||
activate: false
|
||||
})
|
||||
// Why: registerGuest fires with the page ID (not workspace ID) as
|
||||
// browserPageId. Return the page ID so waitForTabRegistration can
|
||||
// correlate correctly.
|
||||
const pages = useAppStore.getState().browserPagesByWorkspace[workspace.id] ?? []
|
||||
const browserPageId = pages[0]?.id ?? workspace.id
|
||||
acquireBrowserAutomationBootstrapLease(worktreeId, browserPageId)
|
||||
window.api.ui.replyTabCreate({ requestId: data.requestId, browserPageId })
|
||||
} catch (err) {
|
||||
window.api.ui.replyTabCreate({
|
||||
|
||||
@@ -159,6 +159,20 @@ describe('createBrowserSlice annotations', () => {
|
||||
expect(store.getState().browserAnnotationsByPageId[pageId]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('creates inactive browser unified tabs without stealing the visible tab', () => {
|
||||
const store = createTestStore()
|
||||
|
||||
store.getState().createBrowserTab('wt-1', 'https://example.com', { activate: false })
|
||||
|
||||
expect(store.getState().createUnifiedTab).toHaveBeenCalledWith(
|
||||
'wt-1',
|
||||
'browser',
|
||||
expect.objectContaining({ activate: false })
|
||||
)
|
||||
expect(store.getState().activeTabType).toBe('terminal')
|
||||
expect(store.getState().activeBrowserTabIdByWorktree['wt-1']).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves browser map references when a page-state update is unchanged', () => {
|
||||
const store = createTestStore()
|
||||
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
|
||||
|
||||
@@ -514,7 +514,8 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
||||
state.createUnifiedTab(worktreeId, 'browser', {
|
||||
entityId: workspaceId,
|
||||
label: browserTab.title,
|
||||
targetGroupId: options?.targetGroupId
|
||||
targetGroupId: options?.targetGroupId,
|
||||
activate: options?.activate ?? true
|
||||
})
|
||||
}
|
||||
return browserTab
|
||||
|
||||
@@ -209,6 +209,16 @@ describe('TabsSlice', () => {
|
||||
expect(group.tabOrder).toEqual([tab1.id, tab2.id])
|
||||
})
|
||||
|
||||
it('can create a tab without activating it', () => {
|
||||
const tab1 = store.getState().createUnifiedTab(WT, 'terminal')
|
||||
const tab2 = store.getState().createUnifiedTab(WT, 'browser', { activate: false })
|
||||
|
||||
const group = store.getState().groupsByWorktree[WT][0]
|
||||
expect(group.activeTabId).toBe(tab1.id)
|
||||
expect(group.tabOrder).toEqual([tab1.id, tab2.id])
|
||||
expect(group.recentTabIds).toEqual([tab1.id])
|
||||
})
|
||||
|
||||
it('replaces existing preview tab when creating a new preview', () => {
|
||||
const preview1 = store.getState().createUnifiedTab(WT, 'editor', {
|
||||
id: 'file-a.ts',
|
||||
|
||||
@@ -46,6 +46,7 @@ export type TabsSlice = {
|
||||
'id' | 'entityId' | 'label' | 'customLabel' | 'color' | 'isPreview' | 'isPinned'
|
||||
> & {
|
||||
targetGroupId: string
|
||||
activate: boolean
|
||||
}
|
||||
>
|
||||
) => Tab
|
||||
@@ -436,14 +437,14 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
||||
}
|
||||
|
||||
nextOrder = dedupeTabOrder([...nextOrder, created.id])
|
||||
// Why: creating a tab implicitly activates it, so extend the group's MRU
|
||||
// stack with the new id. Keeping MRU updates colocated with activation
|
||||
// writes preserves the invariant that `activeTabId` equals the tail of
|
||||
// `recentTabIds` for any tab we've actually seen.
|
||||
const nextRecent = pushRecentTabId(
|
||||
sanitizeRecentTabIds(group.recentTabIds, nextOrder),
|
||||
created.id
|
||||
)
|
||||
const shouldActivate = init?.activate ?? true
|
||||
const nextActiveTabId = shouldActivate ? created.id : (group.activeTabId ?? created.id)
|
||||
const sanitizedRecent = sanitizeRecentTabIds(group.recentTabIds, nextOrder)
|
||||
// Why: automation-created browser tabs need to exist and paint without
|
||||
// stealing the visible group selection from the user's current tab.
|
||||
const nextRecent = shouldActivate
|
||||
? pushRecentTabId(sanitizedRecent, created.id)
|
||||
: sanitizedRecent
|
||||
return {
|
||||
unifiedTabsByWorktree: {
|
||||
...state.unifiedTabsByWorktree,
|
||||
@@ -453,7 +454,7 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
||||
...groupsByWorktree,
|
||||
[worktreeId]: updateGroup(groupsByWorktree[worktreeId] ?? [], {
|
||||
...group,
|
||||
activeTabId: created.id,
|
||||
activeTabId: nextActiveTabId,
|
||||
tabOrder: nextOrder,
|
||||
recentTabIds: nextRecent
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user