fix(desktop): composite the workspace browser with the app UI (#1437)

The workspace browser was a native WebContentsView, which always paints
above the DOM. Switching to the skills or settings page left the page
floating over it, and menus over the page needed a screenshot swap that
showed up clipped and flashed white.

Pages are now <webview> guests kept in a layer inside the session panel
that is never unmounted, so other pages, menus and dialogs draw over
them like any element. The main process adopts each guest and keeps all
in-page behaviour: navigation, history, find, zoom, capture, PDF,
downloads, shortcuts and annotation.

- Guard every attach in the main window: browser partition only, start
  at about:blank, preload and sandbox pinned, reported ids validated.
- Match the native page: drop the blank history entry, keep page zoom
  independent of app zoom, allow popups so they still become tabs.
- Keep app drags working over a page and close menus on a click into it.
- Tell the side dock it is off screen when the session page is hidden.
- Remove the overlay snapshot machinery and native bounds syncing.
This commit is contained in:
程序员阿江-Relakkes
2026-10-04 03:24:50 +08:00
committed by GitHub
parent 8a94f09663
commit 7e50c31986
46 changed files with 2044 additions and 1247 deletions
+8 -16
View File
@@ -56,16 +56,17 @@ describe('Electron IPC capabilities', () => {
]) expect(validateElectronIpcPayload(channel, { ...payload, ...patch })).toBe(false)
})
it('accepts optional initial browser visibility without widening the create payload', () => {
it('requires the renderer guest id on create and accepts no geometry', () => {
const channel = ELECTRON_IPC_CHANNELS.workspaceBrowserCreate
const identity = { tabId: 'wb-1', storageId: 'store-1' }
const identity = { tabId: 'wb-1', storageId: 'store-1', webContentsId: 7 }
expect(validateElectronIpcPayload(channel, identity)).toBe(true)
expect(validateElectronIpcPayload(channel, { ...identity, visible: false })).toBe(true)
expect(validateElectronIpcPayload(channel, { ...identity, visible: true })).toBe(true)
for (const visible of ['false', 0, null, {}]) {
expect(validateElectronIpcPayload(channel, { ...identity, visible })).toBe(false)
expect(validateElectronIpcPayload(channel, { ...identity, url: 'https://example.com/' })).toBe(true)
for (const webContentsId of [undefined, 0, -1, 1.5, '7', Number.NaN, Number.MAX_SAFE_INTEGER + 1]) {
expect(validateElectronIpcPayload(channel, { ...identity, webContentsId })).toBe(false)
}
expect(validateElectronIpcPayload(channel, { ...identity, visible: false, unknown: true })).toBe(false)
// Pages are drawn by the renderer now; the old native-view options are gone.
expect(validateElectronIpcPayload(channel, { ...identity, visible: false })).toBe(false)
expect(validateElectronIpcPayload(channel, { ...identity, bounds: { x: 0, y: 0, width: 1, height: 1 } })).toBe(false)
})
it('has a validator for every exposed invoke channel', () => {
@@ -74,15 +75,6 @@ describe('Electron IPC capabilities', () => {
)
})
it('limits presentation snapshots to a single browser page id', () => {
const channel = ELECTRON_IPC_CHANNELS.workspaceBrowserSnapshot
expect(validateElectronIpcPayload(channel, { tabId: 'wb-1' })).toBe(true)
for (const payload of [{}, { tabId: '' }, { tabId: 'wb-1', kind: 'full' }, { tabId: 'wb-1', url: 'https://example.com' }]) {
expect(validateElectronIpcPayload(channel, payload)).toBe(false)
}
expect(isElectronIpcChannelAllowedForPetWindow(channel)).toBe(false)
})
it('rejects channels outside the desktop host contract', () => {
expect(isElectronIpcChannel(ELECTRON_IPC_CHANNELS.appGetVersion)).toBe(true)
expect(isElectronIpcChannel(ELECTRON_IPC_CHANNELS.appGetLocalePreference)).toBe(true)
+6 -11
View File
@@ -215,14 +215,17 @@ const workspaceBrowserTab: Validator = value =>
&& hasOnlyKeys(value, ['tabId'])
&& isWorkspaceBrowserId(value.tabId)
// `webContentsId` names the renderer's `<webview>` guest. It is only a claim:
// the main process adopts it solely if it is a browser guest of the main window.
const workspaceBrowserCreate: Validator = value =>
isRecord(value)
&& hasOnlyKeys(value, ['tabId', 'storageId', 'url', 'bounds', 'visible'])
&& hasOnlyKeys(value, ['tabId', 'storageId', 'url', 'webContentsId'])
&& isWorkspaceBrowserId(value.tabId)
&& isWorkspaceBrowserId(value.storageId)
&& (value.url === undefined || (typeof value.url === 'string' && value.url.length <= 8_192))
&& (value.bounds === undefined || boundsPayload(value.bounds))
&& (value.visible === undefined || typeof value.visible === 'boolean')
&& typeof value.webContentsId === 'number'
&& Number.isSafeInteger(value.webContentsId)
&& value.webContentsId > 0
const workspaceBrowserMenuLabelKeys = ['find', 'print', 'zoom', 'zoomIn', 'zoomOut', 'zoomReset', 'capture', 'pickElement', 'downloads', 'history', 'openExternal']
@@ -256,12 +259,6 @@ const workspaceBrowserReload: Validator = value =>
&& isWorkspaceBrowserId(value.tabId)
&& (value.ignoreCache === undefined || typeof value.ignoreCache === 'boolean')
const workspaceBrowserSetBounds: Validator = value =>
isRecord(value)
&& hasOnlyKeys(value, ['tabId', 'bounds'])
&& isWorkspaceBrowserId(value.tabId)
&& boundsPayload(value.bounds)
const workspaceBrowserSetVisible: Validator = value =>
isRecord(value)
&& hasOnlyKeys(value, ['tabId', 'visible'])
@@ -402,13 +399,11 @@ export const ELECTRON_IPC_VALIDATORS = {
[ELECTRON_IPC_CHANNELS.workspaceBrowserGoForward]: workspaceBrowserTab,
[ELECTRON_IPC_CHANNELS.workspaceBrowserReload]: workspaceBrowserReload,
[ELECTRON_IPC_CHANNELS.workspaceBrowserStop]: workspaceBrowserTab,
[ELECTRON_IPC_CHANNELS.workspaceBrowserSetBounds]: workspaceBrowserSetBounds,
[ELECTRON_IPC_CHANNELS.workspaceBrowserSetVisible]: workspaceBrowserSetVisible,
[ELECTRON_IPC_CHANNELS.workspaceBrowserSetZoom]: workspaceBrowserSetZoom,
[ELECTRON_IPC_CHANNELS.workspaceBrowserFind]: workspaceBrowserFind,
[ELECTRON_IPC_CHANNELS.workspaceBrowserStopFind]: workspaceBrowserTab,
[ELECTRON_IPC_CHANNELS.workspaceBrowserCapture]: workspaceBrowserCapture,
[ELECTRON_IPC_CHANNELS.workspaceBrowserSnapshot]: workspaceBrowserTab,
[ELECTRON_IPC_CHANNELS.workspaceBrowserMessage]: workspaceBrowserMessage,
[ELECTRON_IPC_CHANNELS.workspaceBrowserPrintToPdf]: workspaceBrowserTab,
[ELECTRON_IPC_CHANNELS.workspaceBrowserClose]: workspaceBrowserTab,
-2
View File
@@ -71,13 +71,11 @@ export const ELECTRON_IPC_CHANNELS = {
workspaceBrowserGoForward: 'desktop:workspace-browser:go-forward',
workspaceBrowserReload: 'desktop:workspace-browser:reload',
workspaceBrowserStop: 'desktop:workspace-browser:stop',
workspaceBrowserSetBounds: 'desktop:workspace-browser:set-bounds',
workspaceBrowserSetVisible: 'desktop:workspace-browser:set-visible',
workspaceBrowserSetZoom: 'desktop:workspace-browser:set-zoom',
workspaceBrowserFind: 'desktop:workspace-browser:find',
workspaceBrowserStopFind: 'desktop:workspace-browser:stop-find',
workspaceBrowserCapture: 'desktop:workspace-browser:capture',
workspaceBrowserSnapshot: 'desktop:workspace-browser:snapshot',
workspaceBrowserMessage: 'desktop:workspace-browser:message',
workspaceBrowserPrintToPdf: 'desktop:workspace-browser:print-to-pdf',
workspaceBrowserClose: 'desktop:workspace-browser:close',
+23 -6
View File
@@ -7,6 +7,11 @@ import {
createPreviewSessionPartition,
isAllowlistedMainRendererMediaRequest,
} from './services/previewSession'
import { applyWorkspaceBrowserAttachPolicy } from './services/workspaceBrowserGuest'
import {
WORKSPACE_BROWSER_INITIAL_SRC,
WORKSPACE_BROWSER_PARTITION,
} from '../src/lib/workspace/browserGuestContract'
const desktopRoot = existsSync(path.resolve(process.cwd(), 'electron', 'main.ts'))
? process.cwd()
@@ -80,9 +85,15 @@ describe('Electron preview security boundary', () => {
})
it('locks workspace browser sandboxing on', () => {
expect(workspaceBrowserServiceSource).toContain('sandbox: true')
expect(workspaceBrowserServiceSource).toContain('contextIsolation: true')
expect(workspaceBrowserServiceSource).toContain('nodeIntegration: false')
// Pages are `<webview>` guests now; their preferences are pinned by the
// attach policy, which the main window must install.
const preferences: Record<string, unknown> = { sandbox: false, contextIsolation: false, nodeIntegration: true }
expect(applyWorkspaceBrowserAttachPolicy(preferences, {
partition: WORKSPACE_BROWSER_PARTITION,
src: WORKSPACE_BROWSER_INITIAL_SRC,
}, { preload: '/preview-preload.cjs' })).toBe(true)
expect(preferences).toMatchObject({ sandbox: true, contextIsolation: true, nodeIntegration: false })
expect(mainWindowSource).toContain('installWorkspaceBrowserGuestPolicy(mainWindow.webContents')
})
it('lets only the main window host workspace browser pages', () => {
@@ -132,9 +143,15 @@ describe('Electron preview security boundary', () => {
})
it('locks workspace browser sandboxing on', () => {
expect(workspaceBrowserServiceSource).toContain('sandbox: true')
expect(workspaceBrowserServiceSource).toContain('contextIsolation: true')
expect(workspaceBrowserServiceSource).toContain('nodeIntegration: false')
// Pages are `<webview>` guests now; their preferences are pinned by the
// attach policy, which the main window must install.
const preferences: Record<string, unknown> = { sandbox: false, contextIsolation: false, nodeIntegration: true }
expect(applyWorkspaceBrowserAttachPolicy(preferences, {
partition: WORKSPACE_BROWSER_PARTITION,
src: WORKSPACE_BROWSER_INITIAL_SRC,
}, { preload: '/preview-preload.cjs' })).toBe(true)
expect(preferences).toMatchObject({ sandbox: true, contextIsolation: true, nodeIntegration: false })
expect(mainWindowSource).toContain('installWorkspaceBrowserGuestPolicy(mainWindow.webContents')
})
it('lets only the main window host workspace browser pages', () => {
+52 -40
View File
@@ -1,5 +1,5 @@
import { PublicAccessManager } from './services/publicAccess'
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, nativeImage, nativeTheme, Notification, screen, session, systemPreferences, WebContentsView } from 'electron'
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, nativeImage, nativeTheme, Notification, screen, session, systemPreferences, WebContentsView, webContents } from 'electron'
import { autoUpdater } from 'electron-updater'
import path from 'node:path'
import { ELECTRON_EVENT_CHANNELS, ELECTRON_INTERNAL_CHANNELS, ELECTRON_IPC_CHANNELS, type ElectronIpcChannel } from './ipc/channels'
@@ -33,11 +33,15 @@ import { ElectronPreviewService, type PreviewBounds } from './services/preview'
import {
ElectronWorkspaceBrowserService,
WORKSPACE_BROWSER_PARTITION,
type WorkspaceBrowserBounds,
type WorkspaceBrowserCaptureKind,
type WorkspaceBrowserCreateOptions,
type WorkspaceBrowserFindOptions,
type WorkspaceBrowserWebContentsLike,
} from './services/workspaceBrowser'
import {
installWorkspaceBrowserGuestPolicy,
resolveWorkspaceBrowserGuest,
} from './services/workspaceBrowserGuest'
import {
configureLocalServerRequestAuth,
configurePreviewSessionPermissions,
@@ -113,6 +117,7 @@ let updaterService: ElectronUpdaterService | null = null
let terminalService: ElectronTerminalService | null = null
let previewService: ElectronPreviewService | null = null
let workspaceBrowserService: ElectronWorkspaceBrowserService | null = null
let workspaceBrowserSessionConfigured = false
let petWindowController: PetWindowController | null = null
const traceWindows = new Map<string, BrowserWindow>()
let isQuitting = false
@@ -445,10 +450,6 @@ function getWorkspaceBrowserService() {
emit: event => {
mainWindow?.webContents.send(ELECTRON_EVENT_CHANNELS.workspaceBrowserEvent, event)
},
resolveScaleFactor: parent => {
const bounds = parent.getBounds?.()
return bounds ? screen.getDisplayMatching(bounds).scaleFactor : 1
},
writePdf: async ({ data, filename }) => {
return saveWorkspaceBrowserPdf(data, async () => {
if (!mainWindow || mainWindow.isDestroyed()) return null
@@ -459,31 +460,37 @@ function getWorkspaceBrowserService() {
return result.canceled ? null : result.filePath ?? null
})
},
createView: () => {
const view = new WebContentsView({
webPreferences: {
preload: previewPreloadPath(),
// One shared persistent partition for every workspace page: a login in
// one tab has to still be there in the next one. Per-tab partitions
// would turn every new tab into a fresh, logged-out browser.
partition: WORKSPACE_BROWSER_PARTITION,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
})
// Same boundary as the singleton preview: OS permissions are denied, and
// `configureLocalServerRequestAuth` is deliberately NOT installed here.
// These pages render arbitrary remote sites, so attaching the desktop's
// local access token to their loopback requests would hand any visited
// site the local API.
configurePreviewSessionPermissions(view.webContents.session)
return view
},
// Pages are `<webview>` guests the renderer creates; their preferences are
// fixed by `installWorkspaceBrowserGuestPolicy` on the main window. The id
// the renderer reports is only adopted if it names one of those guests.
resolveGuest: webContentsId => resolveWorkspaceBrowserGuest<Electron.WebContents>(webContentsId, {
fromId: id => webContents.fromId(id),
host: mainWindow?.webContents,
session: workspaceBrowserSession(),
}) as WorkspaceBrowserWebContentsLike,
})
return workspaceBrowserService
}
/**
* One shared persistent partition for every workspace page: a login in one
* tab has to still be there in the next one. Per-tab partitions would turn
* every new tab into a fresh, logged-out browser.
*/
function workspaceBrowserSession() {
const browserSession = session.fromPartition(WORKSPACE_BROWSER_PARTITION)
// Same boundary as the singleton preview: OS permissions are denied, and
// `configureLocalServerRequestAuth` is deliberately NOT installed here.
// These pages render arbitrary remote sites, so attaching the desktop's
// local access token to their loopback requests would hand any visited
// site the local API.
if (!workspaceBrowserSessionConfigured) {
workspaceBrowserSessionConfigured = true
configurePreviewSessionPermissions(browserSession)
}
return browserSession
}
async function listCustomPets() {
const { pets, errors } = await loadCustomPetCatalog()
return { pets, errors }
@@ -830,11 +837,10 @@ function registerIpcHandlers() {
registerHandler(ELECTRON_IPC_CHANNELS.previewClose, () => getPreviewService().close())
registerHandler(ELECTRON_IPC_CHANNELS.previewMessage, (event, payload) => getPreviewService().message(payload, event.sender))
registerHandler(ELECTRON_IPC_CHANNELS.workspaceBrowserCreate, (event, payload) => {
// The service keeps a single parent window, so whichever renderer calls
// `create` last owns where every page is attached and detached. Trace and
// pet windows load the same preload, so without this a secondary window
// could adopt the pages and strand them as unremovable children of the main
// window. Same guard shape as `appSetLocalePreference`.
// Pages are guests of the main window's renderer. Trace and pet windows
// load the same preload, so without this a secondary window could register
// pages it does not host — and own the menu parent of every page. Same
// guard shape as `appSetLocalePreference`.
if (!mainWindow || currentWindow(event) !== mainWindow) {
throw new Error('Only the main window can host workspace browser pages')
}
@@ -864,10 +870,6 @@ function registerIpcHandlers() {
})
registerHandler(ELECTRON_IPC_CHANNELS.workspaceBrowserStop, (_event, payload) =>
getWorkspaceBrowserService().stop((payload as { tabId: string }).tabId))
registerHandler(ELECTRON_IPC_CHANNELS.workspaceBrowserSetBounds, (_event, payload) => {
const { tabId, bounds } = payload as { tabId: string, bounds: WorkspaceBrowserBounds }
return getWorkspaceBrowserService().setBounds(tabId, bounds)
})
registerHandler(ELECTRON_IPC_CHANNELS.workspaceBrowserSetVisible, (_event, payload) => {
const { tabId, visible } = payload as { tabId: string, visible: boolean }
return getWorkspaceBrowserService().setVisible(tabId, visible)
@@ -890,8 +892,6 @@ function registerIpcHandlers() {
const { tabId, kind } = payload as { tabId: string, kind: WorkspaceBrowserCaptureKind }
return getWorkspaceBrowserService().capture(tabId, kind)
})
registerHandler(ELECTRON_IPC_CHANNELS.workspaceBrowserSnapshot, (_event, payload) =>
getWorkspaceBrowserService().snapshot((payload as { tabId: string }).tabId))
registerHandler(ELECTRON_IPC_CHANNELS.workspaceBrowserMessage, (_event, payload) => {
const { tabId, payload: message } = payload as { tabId: string, payload: unknown }
return getWorkspaceBrowserService().message(tabId, message)
@@ -913,7 +913,13 @@ function registerIpcHandlers() {
app.quit()
})
registerHandler(ELECTRON_IPC_CHANNELS.adaptersRestartSidecar, () => getServerRuntime().restartAdaptersSidecars())
registerHandler(ELECTRON_IPC_CHANNELS.zoomSet, (event, payload) => currentWindow(event).webContents.setZoomFactor(normalizeZoomFactor(payload)))
registerHandler(ELECTRON_IPC_CHANNELS.zoomSet, (event, payload) => {
const window = currentWindow(event)
window.webContents.setZoomFactor(normalizeZoomFactor(payload))
// Electron pushes the embedder's zoom into every browser guest; app zoom
// must not change the pages' own zoom.
if (window === mainWindow) workspaceBrowserService?.restorePageZoom()
})
registerHandler(ELECTRON_IPC_CHANNELS.appearanceSetApplied, (_event, payload) => {
if (!isAppliedAppearance(payload)) return
lastAppliedAppearance = payload
@@ -942,8 +948,15 @@ async function createMainWindow() {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
// Only for the workspace browser, whose pages must be composited with
// the DOM. Every attach is decided by the guest policy installed below.
webviewTag: true,
},
})
// Before any guest can exist: its session denies OS permissions, and its
// preferences are replaced with the sandboxed set the policy pins.
workspaceBrowserSession()
installWorkspaceBrowserGuestPolicy(mainWindow.webContents, { preload: previewPreloadPath() })
configureLocalServerRequestAuth(
mainWindow.webContents.session.webRequest,
resolveMainRendererServerAccess,
@@ -1051,7 +1064,6 @@ app.whenReady().then(async () => {
screen.on('display-metrics-changed', (_event, _display, changedMetrics) => {
if (changedMetrics.includes('scaleFactor') || changedMetrics.includes('bounds')) {
previewService?.refreshBounds()
workspaceBrowserService?.refreshBounds()
}
})
try {
File diff suppressed because it is too large Load Diff
+215 -168
View File
@@ -12,38 +12,29 @@ import type {
} from '../../src/lib/desktopHost/types'
import { parsePreviewAgentMessage, type PreviewAgentMessage } from '../ipc/previewMessage'
import { parseHostMessage, type HostMessage } from '../../src/preview-agent/protocol'
import { WORKSPACE_BROWSER_INITIAL_SRC } from '../../src/lib/workspace/browserGuestContract'
import { isHttpUrl } from './navigationGuards'
import {
normalizePreviewBounds,
normalizePreviewUrl,
resolvePreviewScriptPath,
snapPreviewBoundsToScaleFactor,
type PreviewBounds,
} from './preview'
import { normalizePreviewUrl, resolvePreviewScriptPath } from './preview'
import { normalizeZoomFactor } from './zoom'
import { WorkspaceBrowserMenuController, type WorkspaceBrowserMenuFactory } from './workspaceBrowserMenu'
export type { WorkspaceBrowserCaptureKind, WorkspaceBrowserEvent, WorkspaceBrowserFindOptions }
/**
* One persistent partition for every workspace browser page of this user.
*
* Codex does the same with `persist:codex-browser-app`: a login performed in
* one tab has to be there in the next one. The per-tab `storageId` is a *page
* restore identity* — which page to reopen after a restart — and must never be
* turned into a partition name, or every tab would get its own cookie jar.
*/
export const WORKSPACE_BROWSER_PARTITION = 'persist:cc-haha-browser-app'
export { WORKSPACE_BROWSER_PARTITION } from '../../src/lib/workspace/browserGuestContract'
/** Mirrors the preview capture guard rails; a page controls these dimensions. */
const FULL_CAPTURE_MAX_EDGE = 16_384
const FULL_CAPTURE_MAX_PIXELS = 32_000_000
/**
* A guest that is not on screen has no compositor frame, and `capturePage`
* then waits ~30s before failing. Without a bound, a capture racing a tab
* switch would keep the page's zoom chrome hidden for that long.
*/
const VIEWPORT_CAPTURE_TIMEOUT_MS = 5_000
/** Visit log bound. The native back/forward stack is unaffected by this cap. */
const MAX_HISTORY_ENTRIES = 200
export type WorkspaceBrowserBounds = PreviewBounds
type WorkspaceBrowserDebuggerLike = {
isAttached(): boolean
attach(protocolVersion?: string): void
@@ -77,6 +68,7 @@ type WorkspaceBrowserNavigationHistoryLike = {
canGoForward(): boolean
goBack(): void
goForward(): void
clear?(): void
}
export type WorkspaceBrowserWebContentsLike = {
@@ -124,6 +116,7 @@ export type WorkspaceBrowserWebContentsLike = {
event: 'will-navigate',
handler: (event: { preventDefault: () => void }, url: string) => void,
): unknown
on(event: 'destroyed', handler: () => void): unknown
navigationHistory?: WorkspaceBrowserNavigationHistoryLike
canGoBack?(): boolean
canGoForward?(): boolean
@@ -140,49 +133,50 @@ export type WorkspaceBrowserWebContentsLike = {
isFocused?(): boolean
}
export type WorkspaceBrowserViewLike = {
webContents: WorkspaceBrowserWebContentsLike
setBounds(bounds: PreviewBounds): void
setVisible?(visible: boolean): void
}
export type WorkspaceBrowserParentWindowLike = {
webContents?: {
focus(): void
isDestroyed?(): boolean
}
isDestroyed?(): boolean
contentView: {
addChildView(view: unknown): void
removeChildView(view: unknown): void
}
getBounds?(): PreviewBounds
}
export type WorkspaceBrowserCreateOptions = {
storageId: string
url?: string
bounds?: WorkspaceBrowserBounds
visible?: boolean
/** The `<webview>` guest the renderer created for this page. */
webContentsId: number
}
export type ElectronWorkspaceBrowserServiceOptions = {
createView: () => WorkspaceBrowserViewLike
/**
* Turns a renderer-reported id into the guest it names. It must throw for
* anything that is not a workspace browser webview of the main window.
*/
resolveGuest: (webContentsId: number) => WorkspaceBrowserWebContentsLike
previewScriptPath: string
emit: (event: WorkspaceBrowserEvent) => void
resolveScaleFactor?: (parent: WorkspaceBrowserParentWindowLike) => number
/** Writes an exported PDF and resolves with the path it landed on. */
writePdf?: (input: { data: Uint8Array, filename: string }) => Promise<string | null>
platform?: NodeJS.Platform
menuFactory?: WorkspaceBrowserMenuFactory
/**
* Runs a task after the current native dispatch. Electron applies its own
* navigation zoom after `did-navigate` listeners return, so a correction made
* inside the listener would be overwritten.
*/
defer?: (task: () => void) => void
/** Bounds a viewport capture; a hidden guest otherwise stalls for ~30s. */
captureTimeoutMs?: number
}
type WorkspaceBrowserPage = {
tabId: string
storageId: string
view: WorkspaceBrowserViewLike
attached: boolean
requestedBounds: PreviewBounds | null
webContents: WorkspaceBrowserWebContentsLike
/** Whether the renderer currently shows this page. Only it can take input. */
presented: boolean
/** The guest starts on a blank document whose entry must not be "Back". */
initialEntryCleared: boolean
zoomFactor: number
controls: PreviewBrowserControlsMessage | null
controlsSignature: string | null
@@ -234,32 +228,45 @@ export function workspaceBrowserPdfFilename(url: string, title: string): string
/**
* Multi-page browser host.
*
* The whole point of this service is that a page's lifetime is decided by
* `close(tabId)` and nothing else. Hiding a tab, re-bounding it, moving the
* panel or unmounting the React surface only change where — or whether — a page
* is drawn; the `webContents` behind it keeps its form state, scroll position
* and navigation history the entire time.
* Each page is a `<webview>` guest the renderer keeps in a layer that is never
* unmounted, so the page is composited with the DOM: menus, dialogs and other
* pages draw over it like over any element. This service adopts the guest and
* owns everything that happens *inside* it — navigation, history, find, zoom,
* capture, downloads and the annotation agent.
*
* A page's lifetime is still decided by `close(tabId)` and nothing else.
* Hiding a tab, moving the panel or unmounting the React surface only change
* whether a page is drawn; the guest keeps its form state, scroll position and
* navigation history the entire time.
*/
export class ElectronWorkspaceBrowserService {
private readonly createView: () => WorkspaceBrowserViewLike
private readonly resolveGuest: (webContentsId: number) => WorkspaceBrowserWebContentsLike
private readonly previewScriptPath: string
private readonly emit: (event: WorkspaceBrowserEvent) => void
private readonly resolveScaleFactor?: (parent: WorkspaceBrowserParentWindowLike) => number
private readonly writePdf?: (input: { data: Uint8Array, filename: string }) => Promise<string | null>
private readonly platform: NodeJS.Platform
private readonly defer: (task: () => void) => void
private readonly captureTimeoutMs: number
private readonly pages = new Map<string, WorkspaceBrowserPage>()
private readonly hookedSessions = new Set<WorkspaceBrowserSessionLike>()
/**
* Page zoom as Chromium keeps it: per host, shared by every page of the
* partition. Electron overwrites a guest's zoom with its embedder's whenever
* app zoom changes or the guest navigates, so this is the value to restore.
*/
private readonly zoomByHost = new Map<string, number>()
private parent: WorkspaceBrowserParentWindowLike | null = null
private downloadSequence = 0
private readonly menu?: WorkspaceBrowserMenuController
constructor(options: ElectronWorkspaceBrowserServiceOptions) {
this.createView = options.createView
this.resolveGuest = options.resolveGuest
this.previewScriptPath = options.previewScriptPath
this.emit = options.emit
this.resolveScaleFactor = options.resolveScaleFactor
this.writePdf = options.writePdf
this.platform = options.platform ?? process.platform
this.defer = options.defer ?? (task => { setImmediate(task) })
this.captureTimeoutMs = options.captureTimeoutMs ?? VIEWPORT_CAPTURE_TIMEOUT_MS
if (options.menuFactory) this.menu = new WorkspaceBrowserMenuController(options.menuFactory)
}
@@ -268,35 +275,38 @@ export class ElectronWorkspaceBrowserService {
tabId: string,
options: WorkspaceBrowserCreateOptions,
): Promise<void> {
// Validate before anything is constructed or registered: `openPage` inserts
// a live view into `this.pages`, and a throw after that point strands a
// `webContents` that was never attached and can never be addressed again.
const bounds = options.bounds ? normalizePreviewBounds(options.bounds) : null
// Validate before anything is registered: a throw after `openPage` would
// leave a page this service can neither address nor release.
const url = options.url ? normalizePreviewUrl(options.url) : null
if (options.visible !== undefined && typeof options.visible !== 'boolean') throw new Error('visible must be a boolean')
const webContents = this.resolveGuest(options.webContentsId)
this.parent = parent
const existing = this.pages.get(tabId)
// Re-creating a live id would strand its `webContents` with no way to close
// it, so an already-known tab keeps its page.
const page = existing ?? this.openPage(tabId, options)
if (bounds) page.requestedBounds = bounds
if (options.visible === false) {
this.detach(page)
// Registration is complete even while the first navigation is pending.
// The renderer may now safely send geometry, visibility and Stop.
this.emitState(page)
} else this.showExclusively(page)
// A live page is NEVER re-navigated from `create`. The renderer re-mounts
// this component every time its tab is re-activated, and `loadURL` on an
// existing `webContents` is a hard navigation: it would wipe the form the
// user had filled in, reset the scroll position and push a duplicate entry
// onto the native back stack — destroying exactly the state that keeping
// the page alive exists to preserve. Navigation is `navigate()`'s job.
if (url && !existing) {
await page.view.webContents.loadURL(url)
if (existing) {
// A live page is NEVER re-navigated from `create`. The renderer asks
// again whenever it has to re-establish a page, and `loadURL` on a live
// guest is a hard navigation: it would wipe the form the user filled in,
// reset the scroll position and push a duplicate back entry.
if (existing.webContents !== webContents) {
throw new Error(`workspace browser tab already has a live page: ${tabId}`)
}
this.emitState(existing)
return
}
const owner = this.findPageByWebContents(webContents)
if (owner) throw new Error(`workspace browser guest already belongs to ${owner.tabId}`)
const page = this.openPage(tabId, options.storageId, webContents)
// Resolving means "registered": the renderer may now send visibility,
// Stop and navigation. The first load is an ordinary navigation, so its
// failure reaches the tab as a `failed` event like any other — answering
// `create` with it would make an adopted page look unregistered.
this.emitState(page)
if (url) {
void webContents.loadURL(url).catch(() => {
// Reported through `did-fail-load`, or superseded by a newer navigation.
})
}
}
async navigate(tabId: string, url: string): Promise<void> {
@@ -304,7 +314,7 @@ export class ElectronWorkspaceBrowserService {
page.pickerArmed = false
page.persistentPicker = null
page.pickerGeneration += 1
await page.view.webContents.loadURL(normalizePreviewUrl(url))
await page.webContents.loadURL(normalizePreviewUrl(url))
}
async showMenu(
@@ -317,7 +327,7 @@ export class ElectronWorkspaceBrowserService {
throw new Error('Workspace browser menu requires its live owner window')
}
if (!this.menu) throw new Error('Workspace browser native menu unavailable')
const nativeZoom = page.view.webContents.getZoomFactor?.()
const nativeZoom = page.webContents.getZoomFactor?.()
if (nativeZoom !== undefined && Number.isFinite(nativeZoom) && nativeZoom > 0 && nativeZoom !== page.zoomFactor) {
page.zoomFactor = nativeZoom
// Menu actions return to the renderer. Its next zoom step must start
@@ -328,7 +338,7 @@ export class ElectronWorkspaceBrowserService {
}
goBack(tabId: string): void {
const webContents = this.requirePage(tabId).view.webContents
const webContents = this.requirePage(tabId).webContents
if (webContents.navigationHistory) {
webContents.navigationHistory.goBack()
return
@@ -337,7 +347,7 @@ export class ElectronWorkspaceBrowserService {
}
goForward(tabId: string): void {
const webContents = this.requirePage(tabId).view.webContents
const webContents = this.requirePage(tabId).webContents
if (webContents.navigationHistory) {
webContents.navigationHistory.goForward()
return
@@ -346,55 +356,69 @@ export class ElectronWorkspaceBrowserService {
}
reload(tabId: string, options?: { ignoreCache?: boolean }): void {
const webContents = this.requirePage(tabId).view.webContents
const webContents = this.requirePage(tabId).webContents
if (options?.ignoreCache) webContents.reloadIgnoringCache()
else webContents.reload()
}
stop(tabId: string): void {
this.requirePage(tabId).view.webContents.stop()
}
setBounds(tabId: string, bounds: WorkspaceBrowserBounds): void {
const page = this.requirePage(tabId)
page.requestedBounds = normalizePreviewBounds(bounds)
this.applyBounds(page)
this.requirePage(tabId).webContents.stop()
}
/**
* Hiding detaches the native view from the window so it cannot cover a modal
* or steal clicks — it never destroys the page.
* Records whether the renderer shows this page. Drawing is the renderer's
* job; this decides which page may act as browser chrome (shortcuts, zoom
* capsule) and closes a native menu that belonged to a page going away.
*/
setVisible(tabId: string, visible: boolean): void {
// Controller close may precede the component's passive unmount cleanup.
// Only hide is an idempotent teardown; showing a missing page is still an error.
if (!visible && !this.pages.has(tabId)) return
const page = this.requirePage(tabId)
if (visible) this.showExclusively(page)
else this.detach(page)
page.presented = visible
if (!visible) {
this.menu?.cancel(page.tabId)
return
}
// Chromium may have changed this page's zoom while it was off screen (a
// same-host page zoomed); the controls it shows must read the real value.
this.emitState(page)
}
setZoom(tabId: string, factor: unknown): void {
const page = this.requirePage(tabId)
page.zoomFactor = normalizeZoomFactor(factor)
page.view.webContents.setZoomFactor?.(page.zoomFactor)
this.zoomByHost.set(zoomHostKey(page.webContents.getURL()), page.zoomFactor)
page.webContents.setZoomFactor?.(page.zoomFactor)
// Chromium may apply zoom to another live page on the same origin.
// Read every native value so controls never report an invented factor.
for (const current of this.pages.values()) this.emitState(current)
}
/**
* Electron copies the embedder's zoom into every guest when app zoom
* changes. App zoom never scaled the old native page, so put each page back
* at the zoom it had. Must run right after the embedder zoom changes.
*/
restorePageZoom(): void {
for (const page of this.pages.values()) {
if (page.closed || page.webContents.isDestroyed?.()) continue
page.webContents.setZoomFactor?.(page.zoomFactor)
}
}
find(tabId: string, text: string, options?: WorkspaceBrowserFindOptions): void {
const trimmed = text.trim()
const page = this.requirePage(tabId)
if (!trimmed) {
page.view.webContents.stopFindInPage('clearSelection')
page.webContents.stopFindInPage('clearSelection')
return
}
page.view.webContents.findInPage(trimmed, options)
page.webContents.findInPage(trimmed, options)
}
stopFind(tabId: string): void {
this.requirePage(tabId).view.webContents.stopFindInPage('clearSelection')
this.requirePage(tabId).webContents.stopFindInPage('clearSelection')
}
async capture(tabId: string, kind: WorkspaceBrowserCaptureKind): Promise<void> {
@@ -403,17 +427,6 @@ export class ElectronWorkspaceBrowserService {
this.emitFor(page, { type: 'screenshot', tabId: page.tabId, dataUrl, kind })
}
/** Presentation-only image: never enters the screenshot/chat event stream. */
async snapshot(tabId: string): Promise<string> {
const page = this.requirePage(tabId)
const navigationId = page.navigationId
const dataUrl = await this.captureDataUrl(page, 'viewport')
if (page.closed || navigationId !== page.navigationId) {
throw new Error('Browser page changed during snapshot')
}
return dataUrl
}
async message(tabId: string, payload: unknown): Promise<void> {
const page = this.requirePage(tabId)
const controls = parseHostMessage(JSON.stringify(payload))
@@ -436,7 +449,7 @@ export class ElectronWorkspaceBrowserService {
const raw = JSON.stringify(isHostPickerMessage(payload) ? { ...payload, generation: page.pickerGeneration } : payload)
const generation = page.pickerGeneration
try {
await page.view.webContents.executeJavaScript(
await page.webContents.executeJavaScript(
`globalThis.__PREVIEW_BRIDGE__?.handleHostRaw(${JSON.stringify(raw)})`,
)
} catch (error) {
@@ -452,7 +465,7 @@ export class ElectronWorkspaceBrowserService {
async printToPdf(tabId: string): Promise<void> {
const page = this.requirePage(tabId)
const webContents = page.view.webContents
const webContents = page.webContents
if (!webContents.printToPDF || !this.writePdf) throw new Error('pdf export unavailable')
const filename = workspaceBrowserPdfFilename(webContents.getURL(), webContents.getTitle())
const data = await webContents.printToPDF({ printBackground: true })
@@ -472,15 +485,19 @@ export class ElectronWorkspaceBrowserService {
})
}
/** The only call that ends a page's life. */
/**
* The only call that ends a page's life. The renderer removes the guest's
* element as well; closing here too means a page can never outlive its tab
* even if that removal never happens.
*/
close(tabId: string): void {
const page = this.pages.get(tabId)
if (!page) return
this.pages.delete(tabId)
page.closed = true
this.detach(page)
if (!page.view.webContents.isDestroyed?.()) {
page.view.webContents.close?.()
this.menu?.cancel(page.tabId)
if (!page.webContents.isDestroyed?.()) {
page.webContents.close?.()
}
}
@@ -490,11 +507,6 @@ export class ElectronWorkspaceBrowserService {
this.parent = null
}
/** Re-snaps every live page after a display scale-factor or bounds change. */
refreshBounds(): void {
for (const page of this.pages.values()) this.applyBounds(page)
}
/**
* Routes an in-page agent message. Returns whether this service owns the
* sender, so the caller can fall through to the legacy singleton preview.
@@ -506,14 +518,17 @@ export class ElectronWorkspaceBrowserService {
return true
}
private openPage(tabId: string, options: WorkspaceBrowserCreateOptions): WorkspaceBrowserPage {
const view = this.createView()
private openPage(
tabId: string,
storageId: string,
webContents: WorkspaceBrowserWebContentsLike,
): WorkspaceBrowserPage {
const page: WorkspaceBrowserPage = {
tabId,
storageId: options.storageId,
view,
attached: false,
requestedBounds: null,
storageId,
webContents,
presented: false,
initialEntryCleared: false,
zoomFactor: 1,
controls: null,
controlsSignature: null,
@@ -532,12 +547,15 @@ export class ElectronWorkspaceBrowserService {
}
this.pages.set(tabId, page)
this.installPageListeners(page)
this.hookSession(view.webContents.session)
this.hookSession(webContents.session)
// The guest attached at its embedder's zoom; a page starts at 100% or at
// whatever its host was last zoomed to, exactly as a fresh native page did.
this.restoreNavigationZoom(page)
return page
}
private installPageListeners(page: WorkspaceBrowserPage): void {
const webContents = page.view.webContents
const webContents = page.webContents
// Popups become tabs in this window instead of native child windows, which
// would escape the workspace and the preload/permission boundary with it.
@@ -548,9 +566,19 @@ export class ElectronWorkspaceBrowserService {
webContents.on('will-navigate', (event, url) => {
if (!isHttpUrl(url)) event.preventDefault()
})
webContents.on('destroyed', () => {
// A guest dies with its element. Unless the tab closed it, the renderer
// lost the page (its layer went away) and the tab must offer a retry
// instead of addressing a page that no longer exists.
if (page.closed || this.pages.get(page.tabId) !== page) return
this.pages.delete(page.tabId)
page.closed = true
this.menu?.cancel(page.tabId)
this.emit({ type: 'destroyed', tabId: page.tabId, reason: 'closed' })
})
webContents.on('before-input-event', (event, input) => {
if (input.type !== 'keyDown' || !page.attached || page.closed) return
if (input.type !== 'keyDown' || !page.presented || page.closed) return
const action = matchWorkspaceShortcut({
key: input.key, code: input.code, metaKey: input.meta,
ctrlKey: input.control, shiftKey: input.shift, altKey: input.alt,
@@ -597,8 +625,17 @@ export class ElectronWorkspaceBrowserService {
page.pickerGeneration += 1
page.navigationUrl = url
page.navigationCommitted = true
if (!page.initialEntryCleared && url !== WORKSPACE_BROWSER_INITIAL_SRC) {
// The blank document the guest attached with is a real history entry;
// a native page had none, so the first page must not be able to go
// "Back" to it.
page.initialEntryCleared = true
webContents.navigationHistory?.clear?.()
}
this.recordVisit(page, url)
this.emitState(page)
// Electron re-applies the embedder's zoom once these listeners return.
this.defer(() => this.restoreNavigationZoom(page))
})
webContents.on('did-navigate-in-page', (_event, url, isMainFrame) => {
if (isMainFrame === false) return
@@ -688,8 +725,8 @@ export class ElectronWorkspaceBrowserService {
if (message.type === 'browser-zoom') {
// Only the attached page can act as browser chrome. Background pages
// cannot modify another tab, and no zoom event enters the chat pipeline.
if (page.attached && page.controls) {
const current = page.view.webContents.getZoomFactor?.() ?? page.zoomFactor
if (page.presented && page.controls) {
const current = page.webContents.getZoomFactor?.() ?? page.zoomFactor
this.setZoom(page.tabId, message.action === 'reset' ? 1 : Math.round((current + (message.action === 'in' ? 0.1 : -0.1)) * 10) / 10)
}
return
@@ -753,7 +790,7 @@ export class ElectronWorkspaceBrowserService {
}
private async clearSelectionOverlay(page: WorkspaceBrowserPage, captureId?: number): Promise<void> {
const webContents = page.view.webContents
const webContents = page.webContents
if (page.closed || webContents.isDestroyed?.()) return
try {
await webContents.executeJavaScript(`globalThis.__PREVIEW_AGENT_CLEAR_SELECTION_OVERLAY__?.(${captureId === undefined ? '' : JSON.stringify(captureId)})`)
@@ -763,7 +800,7 @@ export class ElectronWorkspaceBrowserService {
}
private async injectPreviewAgent(page: WorkspaceBrowserPage): Promise<void> {
const webContents = page.view.webContents
const webContents = page.webContents
if (page.closed || webContents.isDestroyed?.()) return
page.pickerArmed = false
page.persistentPicker = null
@@ -777,12 +814,12 @@ export class ElectronWorkspaceBrowserService {
}
private async syncBrowserControls(page: WorkspaceBrowserPage): Promise<void> {
if (!page.controls || page.closed || page.view.webContents.isDestroyed?.()) return
if (!page.controls || page.closed || page.webContents.isDestroyed?.()) return
const raw = JSON.stringify({ ...page.controls, zoomFactor: page.zoomFactor })
if (page.controlsSignature === raw) return
page.controlsSignature = raw
try {
await page.view.webContents.executeJavaScript(`globalThis.__PREVIEW_BRIDGE__?.handleHostRaw(${JSON.stringify(raw)})`)
await page.webContents.executeJavaScript(`globalThis.__PREVIEW_BRIDGE__?.handleHostRaw(${JSON.stringify(raw)})`)
} catch (error) {
if (page.controlsSignature === raw) page.controlsSignature = null
throw error
@@ -793,7 +830,7 @@ export class ElectronWorkspaceBrowserService {
page: WorkspaceBrowserPage,
kind: WorkspaceBrowserCaptureKind,
): Promise<string> {
const webContents = page.view.webContents
const webContents = page.webContents
const hideChrome = async (hidden: boolean) => {
if (page.closed || webContents.isDestroyed?.()) return
await webContents.executeJavaScript(`globalThis.__PREVIEW_AGENT_SET_CHROME_HIDDEN__?.(${hidden})`)
@@ -803,7 +840,11 @@ export class ElectronWorkspaceBrowserService {
await hideChrome(true)
if (kind === 'full') return await this.captureFullPageDataUrl(page)
if (!webContents.capturePage) throw new Error('native browser capture unavailable')
const image = await webContents.capturePage()
const image = await withTimeout(
webContents.capturePage(),
this.captureTimeoutMs,
'browser capture timed out',
)
return image.toDataURL()
} finally {
page.captureCount -= 1
@@ -825,7 +866,7 @@ export class ElectronWorkspaceBrowserService {
}
private async captureFullPageDataUrlOnce(page: WorkspaceBrowserPage): Promise<string> {
const debuggerApi = page.view.webContents.debugger
const debuggerApi = page.webContents.debugger
if (!debuggerApi) throw new Error('full browser capture unavailable')
let attachedHere = false
@@ -876,42 +917,22 @@ export class ElectronWorkspaceBrowserService {
}
}
private showExclusively(page: WorkspaceBrowserPage): void {
// A second attached view would sit on top of the first and swallow its
// input, so exactly one page occupies the window's single browser slot.
for (const other of this.pages.values()) {
if (other !== page) this.detach(other)
}
if (this.parent && !page.attached) {
this.parent.contentView.addChildView(page.view)
page.attached = true
}
page.view.setVisible?.(true)
this.applyBounds(page)
/**
* Put a page back at the zoom Chromium would have given it as a native
* page: whatever its host was last zoomed to, otherwise 100%. Electron
* instead resets a guest to its embedder's zoom, i.e. the app zoom.
*/
private restoreNavigationZoom(page: WorkspaceBrowserPage): void {
const webContents = page.webContents
if (page.closed || webContents.isDestroyed?.() || !webContents.setZoomFactor) return
const wanted = this.zoomByHost.get(zoomHostKey(webContents.getURL())) ?? 1
const actual = webContents.getZoomFactor?.()
// Factors round-trip through zoom levels, so compare with a tolerance.
if (actual !== undefined && Math.abs(actual - wanted) < 0.001) return
webContents.setZoomFactor(wanted)
this.emitState(page)
}
private detach(page: WorkspaceBrowserPage): void {
this.menu?.cancel(page.tabId)
// A DOM focus request cannot move macOS's native responder out of a
// WebContentsView. Capture ownership before hiding/removing the view drops
// it, and do not steal focus when a newer page or a host input already owns it.
const returnFocus = page.attached && !page.view.webContents.isDestroyed?.() && page.view.webContents.isFocused?.()
page.view.setVisible?.(false)
if (!page.attached) return
this.parent?.contentView.removeChildView(page.view)
page.attached = false
if (returnFocus && !this.parent?.isDestroyed?.() && !this.parent?.webContents?.isDestroyed?.()) {
this.parent?.webContents?.focus()
}
}
private applyBounds(page: WorkspaceBrowserPage): void {
if (!page.requestedBounds || !this.parent) return
const scaleFactor = this.resolveScaleFactor?.(this.parent) ?? 1
page.view.setBounds(snapPreviewBoundsToScaleFactor(page.requestedBounds, scaleFactor))
}
private recordVisit(page: WorkspaceBrowserPage, url: string): void {
// A visit log, not a back/forward stack: Electron's navigation entries carry
// no timestamps, and the native history stays the source of truth for
@@ -919,24 +940,27 @@ export class ElectronWorkspaceBrowserService {
if (!isHttpUrl(url)) return
const last = page.history[page.history.length - 1]
if (last?.url === url) return
page.history.push({ url, title: page.view.webContents.getTitle(), visitedAt: Date.now() })
page.history.push({ url, title: page.webContents.getTitle(), visitedAt: Date.now() })
if (page.history.length > MAX_HISTORY_ENTRIES) page.history.shift()
this.emitFor(page, { type: 'history', tabId: page.tabId, entries: [...page.history] })
}
private emitState(page: WorkspaceBrowserPage): void {
const webContents = page.view.webContents
const webContents = page.webContents
if (webContents.isDestroyed?.()) return
const actualZoom = webContents.getZoomFactor?.()
if (actualZoom !== undefined && Number.isFinite(actualZoom) && actualZoom > 0) page.zoomFactor = actualZoom
void this.syncBrowserControls(page).catch(error => {
if (!page.closed) console.error('Failed to update workspace browser controls', error)
})
// Until its first real navigation the guest shows the blank document it
// attached with. A tab without a page has no address and no title.
const blank = webContents.getURL() === WORKSPACE_BROWSER_INITIAL_SRC
this.emitFor(page, {
type: 'state',
tabId: page.tabId,
url: webContents.getURL(),
title: webContents.getTitle(),
url: blank ? '' : webContents.getURL(),
title: blank ? '' : webContents.getTitle(),
canGoBack: readCanGoBack(webContents),
canGoForward: readCanGoForward(webContents),
loading: webContents.isLoading(),
@@ -960,7 +984,7 @@ export class ElectronWorkspaceBrowserService {
private findPageByWebContents(webContents: unknown): WorkspaceBrowserPage | null {
if (!webContents) return null
for (const page of this.pages.values()) {
if (page.view.webContents === webContents) return page
if (page.webContents === webContents) return page
}
return null
}
@@ -977,6 +1001,29 @@ export class ElectronWorkspaceBrowserService {
}
}
/**
* Chromium keys page zoom by host name alone — port and scheme do not count —
* or by the whole URL when there is no host (`net::GetHostOrSpecFromURL`).
*/
function zoomHostKey(url: string): string {
try {
const { hostname } = new URL(url)
return hostname ? hostname.replace(/\.$/, '') : url
} catch {
return url
}
}
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined
return Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), ms)
}),
]).finally(() => clearTimeout(timer))
}
function readCanGoBack(webContents: WorkspaceBrowserWebContentsLike): boolean {
return webContents.navigationHistory?.canGoBack() ?? webContents.canGoBack?.() ?? false
}
@@ -0,0 +1,127 @@
import { describe, expect, it, vi } from 'vitest'
import {
WORKSPACE_BROWSER_INITIAL_SRC,
WORKSPACE_BROWSER_PARTITION,
} from '../../src/lib/workspace/browserGuestContract'
import {
applyWorkspaceBrowserAttachPolicy,
installWorkspaceBrowserGuestPolicy,
resolveWorkspaceBrowserGuest,
} from './workspaceBrowserGuest'
const PRELOAD = '/app/electron-dist/preview-preload.cjs'
const browserAttach = { partition: WORKSPACE_BROWSER_PARTITION, src: WORKSPACE_BROWSER_INITIAL_SRC }
describe('workspace browser attach policy', () => {
it('pins the sandboxed preferences and the preview preload, whatever the element asked for', () => {
// Everything a `<webview>` attribute or `webpreferences` string could
// request on its way here.
const requested: Record<string, unknown> = {
preload: '/tmp/evil.js',
preloadURL: 'file:///tmp/evil.js',
nodeIntegration: true,
nodeIntegrationInSubFrames: true,
nodeIntegrationInWorker: true,
contextIsolation: false,
sandbox: false,
webSecurity: false,
allowRunningInsecureContent: true,
experimentalFeatures: true,
enableBlinkFeatures: 'Foo',
disableBlinkFeatures: 'Bar',
additionalArguments: ['--evil'],
plugins: true,
webviewTag: true,
zoomFactor: 3,
}
expect(applyWorkspaceBrowserAttachPolicy(requested, browserAttach, { preload: PRELOAD })).toBe(true)
expect(requested).toEqual({
preload: PRELOAD,
nodeIntegration: false,
nodeIntegrationInSubFrames: false,
contextIsolation: true,
sandbox: true,
webSecurity: true,
allowRunningInsecureContent: false,
experimentalFeatures: false,
plugins: false,
webviewTag: false,
zoomFactor: 1,
})
})
it.each([
['the renderer session', { partition: undefined, src: WORKSPACE_BROWSER_INITIAL_SRC }],
['another partition', { partition: 'persist:other', src: WORKSPACE_BROWSER_INITIAL_SRC }],
['a page the renderer picked', { partition: WORKSPACE_BROWSER_PARTITION, src: 'https://example.com/' }],
['a local file', { partition: WORKSPACE_BROWSER_PARTITION, src: 'file:///etc/passwd' }],
])('denies a guest for %s', (_name, params) => {
const preferences: Record<string, unknown> = { nodeIntegration: true }
expect(applyWorkspaceBrowserAttachPolicy(preferences, params, { preload: PRELOAD })).toBe(false)
})
it('cancels a denied attach and confines every attached guest until it is adopted', () => {
const handlers = new Map<string, (...args: never[]) => void>()
installWorkspaceBrowserGuestPolicy({
on: (event, handler) => { handlers.set(event, handler) },
}, { preload: PRELOAD })
const willAttach = handlers.get('will-attach-webview') as unknown as (
event: { preventDefault(): void }, preferences: Record<string, unknown>, params: unknown,
) => void
const denied = { preventDefault: vi.fn() }
willAttach(denied, {}, { partition: 'persist:other', src: WORKSPACE_BROWSER_INITIAL_SRC })
expect(denied.preventDefault).toHaveBeenCalledTimes(1)
const allowed = { preventDefault: vi.fn() }
willAttach(allowed, {}, browserAttach)
expect(allowed.preventDefault).not.toHaveBeenCalled()
let openHandler: ((details: { url: string }) => unknown) | undefined
const navigateHandlers: Array<(event: { preventDefault(): void }, url: string) => void> = []
const didAttach = handlers.get('did-attach-webview') as unknown as (event: unknown, guest: unknown) => void
didAttach({}, {
setWindowOpenHandler: (handler: (details: { url: string }) => unknown) => { openHandler = handler },
on: (_event: string, handler: (event: { preventDefault(): void }, url: string) => void) => { navigateHandlers.push(handler) },
})
expect(openHandler?.({ url: 'https://popup.example/' })).toEqual({ action: 'deny' })
const blocked = { preventDefault: vi.fn() }
navigateHandlers[0]!(blocked, 'file:///etc/passwd')
const allowedNavigation = { preventDefault: vi.fn() }
navigateHandlers[0]!(allowedNavigation, 'https://example.com/')
expect(blocked.preventDefault).toHaveBeenCalledTimes(1)
expect(allowedNavigation.preventDefault).not.toHaveBeenCalled()
})
})
describe('workspace browser guest resolution', () => {
const host = { id: 1 }
const browserSession = { name: 'browser' }
const guest = (overrides: Partial<{ type: string, host: unknown, session: unknown, destroyed: boolean }> = {}) => ({
isDestroyed: () => overrides.destroyed ?? false,
getType: () => overrides.type ?? 'webview',
hostWebContents: 'host' in overrides ? overrides.host : host,
session: 'session' in overrides ? overrides.session : browserSession,
})
it('adopts a webview of the main window in the browser partition', () => {
const page = guest()
expect(resolveWorkspaceBrowserGuest(7, { fromId: () => page, host, session: browserSession })).toBe(page)
})
it.each([
['a malformed id', 0, guest()],
['a fractional id', 1.5, guest()],
['a string id', '7', guest()],
['a missing guest', 7, null],
['a destroyed guest', 7, guest({ destroyed: true })],
// The renderer's own webContents carries the local access token.
['the renderer itself', 7, guest({ type: 'window', host: null })],
['another window’s webview', 7, guest({ host: { id: 2 } })],
['a webview in another session', 7, guest({ session: { name: 'renderer' } })],
])('refuses %s', (_name, id, candidate) => {
expect(() => resolveWorkspaceBrowserGuest(id, {
fromId: () => candidate,
host,
session: browserSession,
})).toThrow()
})
})
@@ -0,0 +1,122 @@
import {
WORKSPACE_BROWSER_INITIAL_SRC,
WORKSPACE_BROWSER_PARTITION,
} from '../../src/lib/workspace/browserGuestContract'
import { isHttpUrl } from './navigationGuards'
/**
* Attach boundary for workspace browser `<webview>` guests.
*
* The main window enables `webviewTag` only so the workspace browser can be
* composited with the DOM: a native `WebContentsView` always paints above the
* page, which is what made menus, dialogs and other pages flicker behind or
* underneath it. In exchange, the renderer may now ask for a guest, so every
* attach is decided here, deny by default:
*
* - only the shared browser partition, starting from the fixed blank document;
* - every preference the element could have requested is replaced with the
* sandboxed set the old `WebContentsView` used, plus the preview preload the
* annotation agent relies on.
*/
/** Keys the embedding element may set that must never reach a guest. */
const STRIPPED_PREFERENCE_KEYS = [
'preloadURL',
'enableBlinkFeatures',
'disableBlinkFeatures',
'additionalArguments',
'nodeIntegrationInWorker',
] as const
export type WorkspaceBrowserAttachParams = {
partition?: unknown
src?: unknown
}
export function applyWorkspaceBrowserAttachPolicy(
webPreferences: Record<string, unknown>,
params: WorkspaceBrowserAttachParams,
options: { preload: string },
): boolean {
if (params.partition !== WORKSPACE_BROWSER_PARTITION) return false
if (params.src !== WORKSPACE_BROWSER_INITIAL_SRC) return false
for (const key of STRIPPED_PREFERENCE_KEYS) delete webPreferences[key]
Object.assign(webPreferences, {
preload: options.preload,
contextIsolation: true,
nodeIntegration: false,
nodeIntegrationInSubFrames: false,
sandbox: true,
webSecurity: true,
allowRunningInsecureContent: false,
experimentalFeatures: false,
plugins: false,
// A page cannot nest a guest of its own.
webviewTag: false,
// Electron starts a guest at its embedder's zoom. App zoom never applied to
// the old native view, so a page still starts at 100%.
zoomFactor: 1,
})
return true
}
export type WorkspaceBrowserGuestLike = {
isDestroyed(): boolean
getType(): string
hostWebContents?: unknown
session?: unknown
}
export function resolveWorkspaceBrowserGuest<Guest extends WorkspaceBrowserGuestLike>(
webContentsId: unknown,
options: {
fromId: (id: number) => Guest | undefined | null
host: unknown
session: unknown
},
): Guest {
if (typeof webContentsId !== 'number' || !Number.isSafeInteger(webContentsId) || webContentsId <= 0) {
throw new Error('invalid workspace browser guest id')
}
const guest = options.fromId(webContentsId)
if (!guest || guest.isDestroyed()) throw new Error('workspace browser guest is gone')
// The id travels through the renderer, so it is only a claim. Adopt nothing
// that is not a webview of the main window in the browser's own partition —
// least of all the renderer itself, whose session carries the local token.
if (guest.getType() !== 'webview' || guest.hostWebContents !== options.host || guest.session !== options.session) {
throw new Error('not a workspace browser guest of the main window')
}
return guest
}
type AttachEvent = { preventDefault(): void }
type GuestBaseline = {
setWindowOpenHandler(handler: (details: { url: string }) => { action: 'deny' }): void
on(event: 'will-navigate', handler: (event: AttachEvent, url: string) => void): unknown
}
/** Electron's `WebContents` declares each event as its own overload. */
export type WorkspaceBrowserGuestHostLike = {
on(event: string, handler: (...args: never[]) => void): unknown
}
export function installWorkspaceBrowserGuestPolicy(
host: WorkspaceBrowserGuestHostLike,
options: { preload: string },
): void {
host.on('will-attach-webview', (
event: AttachEvent,
webPreferences: Record<string, unknown>,
params: WorkspaceBrowserAttachParams,
) => {
if (!applyWorkspaceBrowserAttachPolicy(webPreferences, params, options)) event.preventDefault()
})
host.on('did-attach-webview', (_event: unknown, guest: GuestBaseline) => {
// Until the browser service adopts it, a guest can neither open a window
// nor leave http(s). Adoption replaces the popup handler with its own.
guest.setWindowOpenHandler(() => ({ action: 'deny' }))
guest.on('will-navigate', (event, url) => {
if (!isHttpUrl(url)) event.preventDefault()
})
})
}
@@ -52,7 +52,7 @@ export function buildWorkspaceBrowserMenuTemplate(
]
}
/** One popup belongs to one live page; dismissal never hides its WebContentsView. */
/** One popup belongs to one live page; dismissal never hides or reloads that page. */
export class WorkspaceBrowserMenuController {
private pending: { tabId: string; cancel: () => void } | null = null
@@ -1,19 +0,0 @@
import { describe, expect, it } from 'vitest'
import { computeWebviewBounds } from './computeWebviewBounds'
describe('computeWebviewBounds', () => {
it('maps a DOMRect to logical bounds without rounding away high-DPI fractions', () => {
const rect = { left: 100.4, top: 50.6, width: 800.2, height: 600.9 } as DOMRect
expect(computeWebviewBounds(rect)).toEqual({ x: 100.4, y: 50.6, width: 800.2, height: 600.9 })
})
it('maps zoomed CSS coordinates back to the native parent coordinate space', () => {
const rect = { left: 180, top: 150, width: 420, height: 300 } as DOMRect
expect(computeWebviewBounds(rect, 1.25)).toEqual({ x: 225, y: 187.5, width: 525, height: 375 })
})
it('clamps negative/zero sizes to 0', () => {
const rect = { left: -5, top: -5, width: -10, height: 0 } as DOMRect
expect(computeWebviewBounds(rect)).toEqual({ x: -5, y: -5, width: 0, height: 0 })
})
})
@@ -1,13 +0,0 @@
export type WebviewBounds = { x: number; y: number; width: number; height: number }
export function computeWebviewBounds(
rect: Pick<DOMRect, 'left' | 'top' | 'width' | 'height'>,
appZoom = 1,
): WebviewBounds {
return {
x: rect.left * appZoom,
y: rect.top * appZoom,
width: Math.max(0, rect.width * appZoom),
height: Math.max(0, rect.height * appZoom),
}
}
@@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WorkspaceStatusResult } from '../../api/sessions'
import { browserHost } from '../../lib/desktopHost/browserHost'
import { openLocalFileWithSystem } from '../../lib/systemFileOpen'
import { useOverlayStore } from '../../stores/overlayStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { useWorkspaceContentStore } from '../../stores/workspaceContentStore'
import { AssistantMessage } from './AssistantMessage'
@@ -50,7 +49,6 @@ beforeEach(() => {
vi.spyOn(window, 'open').mockImplementation(() => null)
apiGetBlob.mockReset().mockRejectedValue(new Error('404'))
useSettingsStore.setState({ locale: 'en' })
useOverlayStore.setState(useOverlayStore.getInitialState(), true)
withWorkDir('/repo')
vi.mocked(openLocalFileWithSystem).mockReset().mockResolvedValue(undefined)
Reflect.deleteProperty(window, 'desktopHost')
@@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ImageGalleryModal } from './ImageGalleryModal'
import { browserHost } from '../../lib/desktopHost/browserHost'
import { openLocalFileWithSystem, reportOpenFailure } from '../../lib/systemFileOpen'
import { useOverlayStore } from '../../stores/overlayStore'
import { useSettingsStore } from '../../stores/settingsStore'
vi.mock('../../lib/systemFileOpen', () => ({
@@ -22,86 +21,12 @@ const gallery = [
]
const reset = () => {
useOverlayStore.setState(useOverlayStore.getInitialState(), true)
useSettingsStore.setState({ locale: 'en' })
}
beforeEach(reset)
afterEach(reset)
describe('ImageGalleryModal · overlay suppression', () => {
it('increments overlay count while open and decrements on unmount', () => {
expect(useOverlayStore.getState().count).toBe(0)
const { unmount } = render(
<ImageGalleryModal
open
images={images}
activeIndex={0}
onClose={() => {}}
onSelect={() => {}}
/>,
)
expect(useOverlayStore.getState().count).toBe(1)
unmount()
expect(useOverlayStore.getState().count).toBe(0)
})
it('does not increment when rendered with open=false', () => {
const { unmount } = render(
<ImageGalleryModal
open={false}
images={images}
activeIndex={0}
onClose={() => {}}
onSelect={() => {}}
/>,
)
expect(useOverlayStore.getState().count).toBe(0)
unmount()
expect(useOverlayStore.getState().count).toBe(0)
})
it('toggles count when open prop flips closed → open → closed', () => {
const { rerender, unmount } = render(
<ImageGalleryModal
open={false}
images={images}
activeIndex={0}
onClose={() => {}}
onSelect={() => {}}
/>,
)
expect(useOverlayStore.getState().count).toBe(0)
rerender(
<ImageGalleryModal
open
images={images}
activeIndex={0}
onClose={() => {}}
onSelect={() => {}}
/>,
)
expect(useOverlayStore.getState().count).toBe(1)
rerender(
<ImageGalleryModal
open={false}
images={images}
activeIndex={0}
onClose={() => {}}
onSelect={() => {}}
/>,
)
expect(useOverlayStore.getState().count).toBe(0)
unmount()
expect(useOverlayStore.getState().count).toBe(0)
})
})
describe('ImageGalleryModal · navigation', () => {
it('names both arrows, which an icon-only control otherwise lacks', () => {
render(<ImageGalleryModal open images={gallery} activeIndex={0} onClose={() => {}} onSelect={() => {}} />)
@@ -8,7 +8,6 @@ import { AuthedImage } from './AuthedImage'
import { getDesktopHost } from '@/lib/desktopHost'
import { isRootedLocalPath } from '@/lib/handlePreviewLink'
import { openLocalFileWithSystem, reportOpenFailure } from '@/lib/systemFileOpen'
import { useOverlayStore } from '../../stores/overlayStore'
import { useTranslation } from '../../i18n'
type GalleryImage = {
@@ -39,16 +38,6 @@ export function ImageGalleryModal({ open, images, activeIndex, onClose, onSelect
const t = useTranslation()
const activeImage = images[activeIndex]
// Native child webviews (e.g. the in-app browser preview) always render
// ABOVE the DOM, so this fullscreen overlay would be partially covered.
// Bump the overlay count while open so BrowserSurface can hide the webview.
useEffect(() => {
if (!open) return
const { push, pop } = useOverlayStore.getState()
push()
return () => pop()
}, [open])
useEffect(() => {
if (!open || images.length <= 1) return
const handleKeyDown = (event: KeyboardEvent) => {
@@ -97,6 +97,39 @@ describe('ContentRouter tab surfaces', () => {
expect(screen.getByTestId('terminal-host-__terminal__1')).toHaveAttribute('data-runtime-id', '__session_terminal__session-1')
})
it('keeps one browser page layer for the life of the window, inside the session panel', () => {
// A workspace browser page dies when its element leaves the DOM and
// reloads when it moves, so the layer holding them must never be
// re-created — not on a page switch, a task switch, or with no task open.
useTabStore.setState({
tabs: [
{ sessionId: 'session-1', title: 'One', type: 'session', status: 'idle' },
{ sessionId: 'session-2', title: 'Two', type: 'session', status: 'idle' },
{ sessionId: MARKET_TAB_ID, title: 'Market', type: 'market', status: 'idle' },
],
activeTabId: 'session-1',
})
render(<ContentRouter />)
const layer = screen.getByTestId('workspace-browser-guest-layer')
// Same stacking context as the session content: it fades and goes inert
// with the panel when another page takes the window.
expect(screen.getByTestId('session-tab-panel')).toContainElement(layer)
act(() => useTabStore.getState().setActiveTab(MARKET_TAB_ID))
expect(screen.getByTestId('session-tab-panel')).toHaveAttribute('inert')
expect(screen.getByTestId('workspace-browser-guest-layer')).toBe(layer)
act(() => useTabStore.getState().setActiveTab('session-2'))
expect(screen.getByTestId('workspace-browser-guest-layer')).toBe(layer)
act(() => useTabStore.setState({
tabs: [{ sessionId: MARKET_TAB_ID, title: 'Market', type: 'market', status: 'idle' }],
activeTabId: MARKET_TAB_ID,
}))
expect(screen.queryByTestId('active-session')).toBeNull()
expect(screen.getByTestId('workspace-browser-guest-layer')).toBe(layer)
})
it('keeps terminal tabs mounted while chat content is active', () => {
useTabStore.setState({
tabs: [
+18 -11
View File
@@ -10,6 +10,7 @@ import { TraceList } from '../../pages/TraceList'
import { TraceSession } from '../../pages/TraceSession'
import { SubagentRunPage, TeamMemberRunPage } from '../../pages/SubagentRunPage'
import { AgentTeamsWorkbenchTab } from '../agentTeams/AgentTeamsWorkbenchTab'
import { WorkspaceBrowserGuestLayer } from '../workbench/WorkspaceBrowserGuestLayer'
import { returnToTraceList } from '../../lib/traceNavigation'
export function ContentRouter() {
@@ -97,18 +98,24 @@ export function ContentRouter() {
return (
<div className="relative min-h-0 flex-1 overflow-hidden">
{retainedSessionId && (
<div
aria-hidden={!activeSessionId}
{...(activeSessionId ? {} : { inert: '' })}
data-testid="session-tab-panel"
className={`absolute inset-0 flex min-h-0 flex-col overflow-hidden ${
activeSessionId ? 'z-10 opacity-100' : 'pointer-events-none z-0 opacity-0'
}`}
>
{/*
Always rendered, even with no task to retain: the browser page layer
inside it must outlive every task switch, because a page whose element
is removed is destroyed and one that is moved reloads.
*/}
<div
aria-hidden={!activeSessionId}
{...(activeSessionId ? {} : { inert: '' })}
data-testid="session-tab-panel"
className={`absolute inset-0 flex min-h-0 flex-col overflow-hidden ${
activeSessionId ? 'z-10 opacity-100' : 'pointer-events-none z-0 opacity-0'
}`}
>
{retainedSessionId ? (
<ActiveSession sessionId={retainedSessionId} active={Boolean(activeSessionId)} />
</div>
)}
) : null}
<WorkspaceBrowserGuestLayer />
</div>
{page && (
<div className="absolute inset-0 z-10 flex min-h-0 flex-col overflow-hidden">
{page}
@@ -50,11 +50,9 @@ vi.mock('../../stores/openTargetStore', () => ({
}))
import { OpenProjectMenu } from './OpenProjectMenu'
import { useOverlayStore } from '../../stores/overlayStore'
describe('OpenProjectMenu', () => {
beforeEach(() => {
useOverlayStore.setState(useOverlayStore.getInitialState(), true)
storeMocks.ensureTargets.mockReset()
storeMocks.openTarget.mockReset()
storeMocks.state = {
@@ -109,25 +107,6 @@ describe('OpenProjectMenu', () => {
expect(storeMocks.openTarget).toHaveBeenCalledWith('finder', '/repo')
})
it('suppresses the native browser preview while the target menu is open', async () => {
storeMocks.state.targets = [
{ id: 'vscode', kind: 'ide', label: 'VS Code', icon: 'vscode', platform: 'darwin' },
{ id: 'finder', kind: 'file_manager', label: 'Finder', icon: 'finder', platform: 'darwin' },
]
storeMocks.state.primaryTargetId = 'vscode'
const { unmount } = render(<OpenProjectMenu path="/repo" />)
expect(useOverlayStore.getState().count).toBe(0)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Open project' }))
})
expect(useOverlayStore.getState().count).toBe(1)
unmount()
expect(useOverlayStore.getState().count).toBe(0)
})
it('does not render without a path', () => {
const { container } = render(<OpenProjectMenu path={null} />)
expect(container).toBeEmptyDOMElement()
@@ -3,7 +3,6 @@ import { createPortal } from 'react-dom'
import { ChevronDown } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useOpenTargetStore } from '../../stores/openTargetStore'
import { useOverlayStore } from '../../stores/overlayStore'
import { useDismissable } from '@/hooks/useDismissable'
import { TargetIcon } from '@/components/composite/TargetIcon'
@@ -29,15 +28,6 @@ export function OpenProjectMenu({ path }: Props) {
void ensureTargets()
}, [ensureTargets, path])
// The native browser preview always renders above DOM portals. Suppress it
// while this menu is open so targets below the browser toolbar stay visible.
useEffect(() => {
if (!open) return
const { push, pop } = useOverlayStore.getState()
push()
return () => pop()
}, [open])
const handleDismiss = useCallback(() => setOpen(false), [])
useDismissable({
@@ -2,7 +2,6 @@ import { StrictMode, useRef, useState } from 'react'
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useOverlayStore } from '@/stores/overlayStore'
import { WorkspaceAddMenu } from './WorkspaceAddMenu'
const selected = vi.fn()
@@ -21,7 +20,6 @@ function Harness({ last = false, disabled = false }: { last?: boolean; disabled?
beforeEach(() => {
selected.mockClear()
useOverlayStore.setState({ count: 0, snapshotCount: 0 })
})
afterEach(() => { cleanup(); vi.restoreAllMocks() })
@@ -120,16 +118,11 @@ describe('WorkspaceAddMenu', () => {
expect(selected).not.toHaveBeenCalled()
})
it('selects one resource and balances native snapshot suppression in StrictMode', () => {
const { unmount } = render(<StrictMode><Harness /></StrictMode>)
it('selects one resource exactly once in StrictMode', () => {
render(<StrictMode><Harness /></StrictMode>)
fireEvent.click(screen.getByText('Add'))
expect(useOverlayStore.getState()).toMatchObject({ count: 1, snapshotCount: 1 })
fireEvent.click(screen.getByTestId('workspace-menu-browser'))
expect(selected).toHaveBeenCalledExactlyOnceWith('browser')
expect(screen.queryByRole('menu')).toBeNull()
expect(useOverlayStore.getState()).toMatchObject({ count: 0, snapshotCount: 0 })
fireEvent.click(screen.getByText('Add'))
unmount()
expect(useOverlayStore.getState()).toMatchObject({ count: 0, snapshotCount: 0 })
})
})
@@ -4,7 +4,6 @@ import { useAnchoredPosition } from '@/hooks/useAnchoredPosition'
import { useDismissable } from '@/hooks/useDismissable'
import { useTranslation } from '@/i18n'
import type { WorkspaceDock, WorkspaceTabKind } from '@/lib/workspace/types'
import { useSuppressBrowserOverlay } from '@/stores/overlayStore'
import { useMenuKeyboard } from './menuKeyboard'
import { WorkspaceLauncher } from './WorkspaceLauncher'
@@ -24,7 +23,6 @@ export function WorkspaceAddMenu({
}: WorkspaceAddMenuProps) {
const t = useTranslation()
const menuRef = useRef<HTMLDivElement>(null)
useSuppressBrowserOverlay({ preserveSnapshot: true })
const position = useAnchoredPosition({
open: true, anchorRef, floatingRef: menuRef,
placement: 'bottom-start', offset: 1, viewportMargin: 6, clampHeight: true,
@@ -4,7 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createRef } from 'react'
import { WorkspaceBrowserAddressBar } from '@/components/workbench/WorkspaceBrowserAddressBar'
import { normalizeBrowserAddress } from '@/lib/workspace/browserAddress'
import { useOverlayStore } from '@/stores/overlayStore'
import { useSettingsStore } from '@/stores/settingsStore'
const visits = Array.from({ length: 8 }, (_, index) => ({
@@ -19,7 +18,6 @@ const props = {
beforeEach(() => {
vi.clearAllMocks()
useSettingsStore.setState({ locale: 'en' })
useOverlayStore.setState({ count: 0, snapshotCount: 0 })
})
afterEach(cleanup)
@@ -63,7 +61,6 @@ describe('WorkspaceBrowserAddressBar', () => {
fireEvent.change(address, { target: { value: 'in progress' } })
rerender(<WorkspaceBrowserAddressBar {...props} currentAddress="" blank active={false} />)
expect(address).toHaveValue('in progress')
expect(useOverlayStore.getState().count).toBe(0)
act(() => address.blur())
rerender(<WorkspaceBrowserAddressBar {...props} currentAddress="" blank />)
act(() => address.focus())
@@ -73,19 +70,16 @@ describe('WorkspaceBrowserAddressBar', () => {
expect(onNavigate).not.toHaveBeenCalled()
})
it('does not create actionable suggestions for an unsupported scheme and balances overlays on unmount', () => {
const { unmount } = render(<WorkspaceBrowserAddressBar {...props} />)
it('does not create actionable suggestions for an unsupported scheme', () => {
render(<WorkspaceBrowserAddressBar {...props} />)
const address = screen.getByRole('combobox')
act(() => address.focus())
expect(useOverlayStore.getState().count).toBe(1)
fireEvent.change(address, { target: { value: 'javascript:alert(1)' } })
expect(screen.queryByRole('listbox')).toBeNull()
fireEvent.keyDown(address, { key: 'Enter' })
expect(onNavigate).not.toHaveBeenCalled()
fireEvent.change(address, { target: { value: 'fixture' } })
expect(useOverlayStore.getState().snapshotCount).toBe(1)
unmount()
expect(useOverlayStore.getState().count).toBe(0)
expect(screen.getByRole('listbox')).toBeInTheDocument()
})
it('shows the new address only after navigation is accepted, allowing selection-discard confirmation first', () => {
@@ -4,7 +4,6 @@ import { IconButton } from '@/components/ui/IconButton'
import { useDismissable } from '@/hooks/useDismissable'
import { useTranslation } from '@/i18n'
import { normalizeBrowserAddress } from '@/lib/workspace/browserAddress'
import { useSuppressBrowserOverlay } from '@/stores/overlayStore'
import type { WorkspaceBrowserVisit } from '@/stores/workspaceBrowserStore'
type AddressSuggestion = { input: string; title: string; url?: string; action?: 'search' | 'visit' }
@@ -186,7 +185,6 @@ function AddressSuggestions({ id, items, query, selectedIndex, onHighlight, onSe
}) {
const t = useTranslation()
const listRef = useRef<HTMLDivElement>(null)
useSuppressBrowserOverlay({ preserveSnapshot: true })
useEffect(() => {
listRef.current?.children[selectedIndex]?.scrollIntoView?.({ block: 'nearest' })
}, [selectedIndex, items])
@@ -0,0 +1,24 @@
import { useCallback } from 'react'
import { setWorkspaceBrowserGuestLayer } from '@/lib/workspace/browserGuests'
/**
* Where every workspace browser page lives, for the life of the window.
*
* It must be mounted once and never re-parented: a `<webview>` reloads when
* moved and dies when removed. It sits after the session content in the same
* stacking context, so a page paints over the workspace like the placeholder it
* covers and under every dropdown, dialog and toast, and it fades with the
* session panel when another page takes the window.
*/
export function WorkspaceBrowserGuestLayer() {
const register = useCallback((element: HTMLDivElement | null) => {
setWorkspaceBrowserGuestLayer(element)
}, [])
return (
<div
ref={register}
data-testid="workspace-browser-guest-layer"
className="pointer-events-none absolute inset-0 z-0"
/>
)
}
@@ -19,13 +19,11 @@ const { host, isAvailable, releaseTab, openExternal, openPath } = vi.hoisted(()
goForward: resolved(),
reload: resolved(),
stop: resolved(),
setBounds: resolved(),
setVisible: resolved(),
setZoom: resolved(),
find: resolved(),
stopFind: resolved(),
capture: resolved(),
snapshot: vi.fn().mockResolvedValue('data:image/png;base64,BACKDROP'),
message: resolved(),
close: resolved(),
printToPdf: resolved(),
@@ -57,7 +55,7 @@ vi.mock('../../lib/desktopHost', async (importOriginal) => {
})
import { WorkspaceBrowserTab } from './WorkspaceBrowserTab'
import { useOverlayStore } from '../../stores/overlayStore'
import { installFakeBrowserGuests, isBrowserPageShown } from '../../test/fakeBrowserGuests'
import { useSettingsStore } from '../../stores/settingsStore'
import { useWorkspaceBrowserStore } from '../../stores/workspaceBrowserStore'
import { useWorkspaceStore } from '../../stores/workspaceStore'
@@ -125,23 +123,34 @@ async function openMenuItem(action: WorkspaceBrowserMenuAction) {
await act(async () => { fireEvent.click(screen.getByTestId('workspace-browser-menu-trigger')) })
}
function pageShown(tab: WorkspaceBrowserTabModel): boolean {
return isBrowserPageShown(tab.browserTabId)
}
/** Lets an element attach, register with the host and report back. */
async function flushGuests() {
for (let tick = 0; tick < 6; tick += 1) await act(async () => {})
}
let disposeGuests: () => void
beforeEach(() => {
useSettingsStore.setState({ locale: 'en', uiZoom: 1 })
useWorkspaceStore.setState({ bySession: {}, sideWidth: 860, bottomHeight: 420 })
useWorkspaceBrowserStore.setState({ pageByTabId: {}, historyByTabId: {}, downloads: [] })
useOverlayStore.setState({ count: 0, snapshotCount: 0 })
usePreviewSelectionStore.setState({ bySession: {} })
isAvailable.mockReturnValue(true)
for (const mock of Object.values(host)) mock.mockReset().mockResolvedValue({ ok: true })
host.snapshot.mockResolvedValue('data:image/png;base64,BACKDROP')
host.showMenu.mockResolvedValue(null)
releaseTab.mockClear()
openExternal.mockClear()
openPath.mockClear()
disposeGuests = installFakeBrowserGuests()
})
afterEach(() => {
cleanup()
disposeGuests()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
@@ -166,14 +175,16 @@ describe('lifecycle readiness', () => {
)
})
it('focuses a newly created empty address bar once its native page is ready', async () => {
it('focuses a newly created empty address bar once its page is ready', async () => {
const tab = openBrowserTab(null)
const create = deferredCreate()
host.create.mockReturnValueOnce(create.promise)
render(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
const address = screen.getByTestId('workspace-browser-address')
expect(address).toBeDisabled()
await flushGuests()
await act(async () => { create.resolve({ ok: true }) })
await flushGuests()
expect(address).toBeEnabled()
expect(address).toHaveFocus()
})
@@ -185,61 +196,62 @@ describe('lifecycle readiness', () => {
render(<><input aria-label="Chat composer" /><WorkspaceBrowserTab sessionId={SESSION} tab={tab} active /></>)
const composer = screen.getByRole('textbox', { name: 'Chat composer' })
act(() => { composer.focus() })
await flushGuests()
await act(async () => { create.resolve({ ok: true }) })
await flushGuests()
expect(composer).toHaveFocus()
})
it('does not focus an existing loaded page address bar when snapshots open and close', async () => {
it('does not focus an existing loaded page address bar when it is hidden and shown again', async () => {
const tab = openBrowserTab()
await renderReady(<><input aria-label="Chat composer" /><WorkspaceBrowserTab sessionId={SESSION} tab={tab} active /></>)
const view = await renderReady(<><input aria-label="Chat composer" /><WorkspaceBrowserTab sessionId={SESSION} tab={tab} active /></>)
const composer = screen.getByRole('textbox', { name: 'Chat composer' })
act(() => { composer.focus() })
await act(async () => { useOverlayStore.getState().push(true) })
act(() => { useOverlayStore.getState().pop(true) })
view.rerender(<><input aria-label="Chat composer" /><WorkspaceBrowserTab sessionId={SESSION} tab={tab} active={false} /></>)
view.rerender(<><input aria-label="Chat composer" /><WorkspaceBrowserTab sessionId={SESSION} tab={tab} active /></>)
expect(composer).toHaveFocus()
})
it('waits for registration before bounds, visibility or user commands', async () => {
it('registers the guest it created with the host, then shows it and takes commands', async () => {
const creation = deferredCreate()
host.create.mockReturnValueOnce(creation.promise)
const tab = openBrowserTab()
render(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
fireEvent.click(screen.getByRole('button', { name: 'Reload' }))
expect(host.setBounds).not.toHaveBeenCalled()
await flushGuests()
expect(host.create).toHaveBeenCalledWith(tab.browserTabId, {
storageId: tab.storageId,
webContentsId: expect.any(Number),
url: 'https://example.test/',
})
expect(pageShown(tab)).toBe(false)
expect(host.setVisible).not.toHaveBeenCalled()
expect(host.reload).not.toHaveBeenCalled()
await act(async () => creation.resolve({ ok: true }))
expect(host.setBounds).toHaveBeenCalledWith(tab.browserTabId, expect.any(Object))
await flushGuests()
expect(pageShown(tab)).toBe(true)
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, true)
})
it('uses registered state before a slow initial navigation finishes', async () => {
const creation = deferredCreate()
host.create.mockReturnValueOnce(creation.promise)
const tab = openBrowserTab()
render(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
expect(host.setVisible).not.toHaveBeenCalled()
emit(pageState(tab.browserTabId, { loading: true }))
fireEvent.click(screen.getByRole('button', { name: 'Stop loading' }))
expect(host.stop).toHaveBeenCalledWith(tab.browserTabId)
await act(async () => creation.resolve({ ok: true }))
})
it('surfaces failed creation and retries create before accepting navigation', async () => {
const creation = deferredCreate()
host.create.mockReturnValueOnce(creation.promise)
const tab = openBrowserTab()
const view = render(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
await flushGuests()
await act(async () => creation.reject(new Error('native creation denied')))
await flushGuests()
view.rerender(<WorkspaceBrowserTab sessionId={SESSION} tab={currentTab(tab.id)} active />)
expect(screen.getByRole('alert')).toHaveTextContent('native creation denied')
expect(host.setBounds).not.toHaveBeenCalled()
expect(pageShown(tab)).toBe(false)
expect(host.setVisible).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
await waitFor(() => expect(host.create).toHaveBeenCalledTimes(2))
await flushGuests()
expect(host.create).toHaveBeenCalledTimes(2)
// The refused guest was dropped; the retry registered a fresh one.
expect(host.create.mock.calls[1]![1].webContentsId).not.toBe(host.create.mock.calls[0]![1].webContentsId)
expect(host.reload).not.toHaveBeenCalled()
await act(async () => {})
const address = screen.getByTestId('workspace-browser-address')
fireEvent.change(address, { target: { value: 'https://retry.test/' } })
fireEvent.submit(address.closest('form')!)
@@ -251,14 +263,15 @@ describe('lifecycle readiness', () => {
host.create.mockReturnValueOnce(creation.promise)
const tab = openBrowserTab()
const view = render(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
host.setBounds.mockClear()
await flushGuests()
host.setVisible.mockClear()
act(() => useWorkspaceStore.getState().closeTab(SESSION, tab.id))
await act(async () => {
if (result === 'resolve') creation.resolve({ ok: true })
else creation.reject(new Error('closed while loading'))
})
expect(host.setBounds).not.toHaveBeenCalled()
await flushGuests()
expect(pageShown(tab)).toBe(false)
expect(host.setVisible).not.toHaveBeenCalled()
expect(useWorkspaceStore.getState().findBrowserTabOwner(tab.browserTabId)).toBeNull()
view.unmount()
@@ -271,16 +284,18 @@ describe('lifecycle readiness', () => {
const first = openBrowserTab()
const second = openBrowserTab('https://second.test/')
const view = render(<WorkspaceBrowserTab sessionId={SESSION} tab={first} active />)
await flushGuests()
view.rerender(<WorkspaceBrowserTab sessionId={SESSION} tab={second} active />)
await act(async () => {})
host.setBounds.mockClear()
await flushGuests()
host.setVisible.mockClear()
await act(async () => {
if (result === 'resolve') creation.resolve({ ok: true })
else creation.reject(new Error('old page failed'))
})
expect(host.setBounds).not.toHaveBeenCalled()
expect(host.setVisible).not.toHaveBeenCalled()
await flushGuests()
expect(pageShown(first)).toBe(false)
expect(pageShown(second)).toBe(true)
expect(host.setVisible).not.toHaveBeenCalledWith(first.browserTabId, expect.anything())
expect(currentTab(first.id).loadError).toBeNull()
expect(currentTab(second.id).loadError).toBeNull()
})
@@ -290,57 +305,43 @@ describe('lifecycle readiness', () => {
host.create.mockReturnValueOnce(creation.promise)
const tab = openBrowserTab()
const view = render(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
expect(host.create).toHaveBeenCalledWith(tab.browserTabId, expect.objectContaining({ visible: false }))
await flushGuests()
view.unmount()
await act(async () => creation.resolve({ ok: true }))
expect(host.setBounds).not.toHaveBeenCalled()
await flushGuests()
expect(pageShown(tab)).toBe(false)
expect(host.setVisible).not.toHaveBeenCalled()
expect(host.close).not.toHaveBeenCalled()
})
it('does not replay old resize callbacks into the next page identity', async () => {
const callbacks: ResizeObserverCallback[] = []
vi.stubGlobal('ResizeObserver', class {
constructor(callback: ResizeObserverCallback) { callbacks.push(callback) }
observe() {}
disconnect() {}
})
it('parks the previous page and draws the next one when the surface switches tabs', async () => {
const first = openBrowserTab()
const second = openBrowserTab('https://second.test/')
const view = render(<WorkspaceBrowserTab sessionId={SESSION} tab={first} active />)
await act(async () => {})
const oldCallback = callbacks[0]!
const view = await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={first} active />)
expect(pageShown(first)).toBe(true)
view.rerender(<WorkspaceBrowserTab sessionId={SESSION} tab={second} active />)
await act(async () => {})
host.setBounds.mockClear()
act(() => oldCallback([], {} as ResizeObserver))
expect(host.setBounds).not.toHaveBeenCalled()
await flushGuests()
expect(pageShown(first)).toBe(false)
expect(pageShown(second)).toBe(true)
// Parking is not closing: both pages are still alive.
expect(document.querySelectorAll('webview')).toHaveLength(2)
expect(host.close).not.toHaveBeenCalled()
})
it('keeps an initial navigation failure retryable after registration', async () => {
const creation = deferredCreate()
host.create.mockReturnValueOnce(creation.promise)
const tab = openBrowserTab()
const view = render(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
emit(pageState(tab.browserTabId))
await act(async () => creation.reject(new Error('ERR_CONNECTION_REFUSED')))
const view = await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
// The host reports a failed first load as a `failed` event, which the
// event bridge writes onto the tab.
act(() => useWorkspaceStore.getState().updateBrowserTab(SESSION, tab.browserTabId, { loadError: 'ERR_CONNECTION_REFUSED' }))
view.rerender(<WorkspaceBrowserTab sessionId={SESSION} tab={currentTab(tab.id)} active />)
expect(screen.getByRole('alert')).toHaveTextContent('ERR_CONNECTION_REFUSED')
expect(pageShown(tab)).toBe(false)
fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
expect(host.reload).toHaveBeenCalledWith(tab.browserTabId, { ignoreCache: true })
expect(host.create).toHaveBeenCalledTimes(1)
})
it('does not replace a completed initial navigation with its late promise rejection', async () => {
const creation = deferredCreate()
host.create.mockReturnValueOnce(creation.promise)
const tab = openBrowserTab()
render(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
emit({ ...pageState(tab.browserTabId), navigationId: 1, navigationOutcome: 'succeeded' } as WorkspaceBrowserEvent)
await act(async () => creation.reject(new Error('late initial load rejection')))
expect(currentTab(tab.id).loadError).toBeNull()
})
it('does not replace successful navigation B with late rejected navigation A', async () => {
const navigation = deferredCreate()
host.navigate.mockReturnValueOnce(navigation.promise)
@@ -357,7 +358,7 @@ describe('lifecycle readiness', () => {
expect(currentTab(tab.id).loadError).toBeNull()
})
it('reports real navigation and geometry errors for a live page', async () => {
it('reports real navigation errors for a live page', async () => {
const tab = openBrowserTab()
await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
host.navigate.mockRejectedValueOnce(new Error('navigation denied'))
@@ -365,15 +366,12 @@ describe('lifecycle readiness', () => {
fireEvent.change(address, { target: { value: 'https://denied.test/' } })
fireEvent.submit(address.closest('form')!)
await waitFor(() => expect(currentTab(tab.id).loadError).toBe('navigation denied'))
host.setBounds.mockRejectedValueOnce(new Error('native geometry failed'))
act(() => window.dispatchEvent(new Event('resize')))
await waitFor(() => expect(currentTab(tab.id).loadError).toBe('native geometry failed'))
})
})
async function renderReady(ui: Parameters<typeof render>[0]) {
const view = render(ui)
await act(async () => {})
await flushGuests()
return view
}
@@ -478,7 +476,7 @@ describe('native toolbar menu', () => {
expect(screen.getByTestId('workspace-browser-find')).toBeInTheDocument()
})
it.each(['unmount', 'inactive', 'reactivate', 'close', 'replace', 'overlay'] as const)(
it.each(['unmount', 'inactive', 'reactivate', 'close', 'replace'] as const)(
'ignores a late menu action after %s', async (transition) => {
const tab = openBrowserTab()
const view = await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
@@ -496,7 +494,6 @@ describe('native toolbar menu', () => {
const next = openBrowserTab('https://replacement.test/')
view.rerender(<WorkspaceBrowserTab sessionId={SESSION} tab={next} active />)
}
if (transition === 'overlay') act(() => { useOverlayStore.getState().push() })
await act(async () => { select('print') })
expect(host.printToPdf).not.toHaveBeenCalled()
if (transition === 'replace' || transition === 'reactivate') {
@@ -586,8 +583,7 @@ describe('page lifetime', () => {
// the entire page. Native menus can cover the live guest without hiding it.
expect(host.setVisible).not.toHaveBeenCalledWith(tab.browserTabId, false)
expect(host.showMenu).toHaveBeenCalledWith(tab.browserTabId, expect.any(Object))
expect(host.snapshot).not.toHaveBeenCalled()
expect(screen.queryByTestId('workspace-browser-backdrop')).toBeNull()
expect(pageShown(tab)).toBe(true)
expect(screen.getByTestId('workspace-browser-menu-trigger')).toHaveAttribute('aria-expanded', 'true')
await act(async () => { dismiss(null) })
expect(screen.getByTestId('workspace-browser-menu-trigger')).toHaveAttribute('aria-expanded', 'false')
@@ -664,148 +660,63 @@ describe('page lifetime', () => {
})
describe('visibility', () => {
it('captures a presentation backdrop before hiding the native page for a plus menu and restores the same page', async () => {
// The page is composited with the DOM, so nothing that draws over it has to
// hide it, snapshot it, or ask it to move — the bug class the native view had.
it('keeps the live page drawn under a dialog instead of hiding or snapshotting it', async () => {
const tab = openBrowserTab()
await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
let finish!: (url: string) => void
host.snapshot.mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
act(() => {
usePreviewSelectionStore.setState({
bySession: { [tab.browserTabId]: { items: [{ id: 'one' }], nextNumber: 2 } },
} as never)
})
host.setVisible.mockClear()
const createCount = host.create.mock.calls.length
act(() => { useOverlayStore.getState().push(true) })
expect(host.snapshot).toHaveBeenCalledWith(tab.browserTabId)
expect(host.setVisible).not.toHaveBeenCalledWith(tab.browserTabId, false)
await act(async () => { finish('data:image/png;base64,BACKDROP') })
expect(screen.getByTestId('workspace-browser-backdrop')).toHaveAttribute('src', 'data:image/png;base64,BACKDROP')
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, false)
expect(host.capture).not.toHaveBeenCalled()
act(() => { useOverlayStore.getState().pop(true) })
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, true)
expect(screen.queryByTestId('workspace-browser-backdrop')).toBeNull()
expect(host.create).toHaveBeenCalledTimes(createCount)
expect(host.close).not.toHaveBeenCalled()
const address = screen.getByTestId('workspace-browser-address')
fireEvent.change(address, { target: { value: 'https://elsewhere.test/' } })
fireEvent.submit(address.closest('form')!)
// Navigating away would discard annotations, so the page asks first.
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(pageShown(tab)).toBe(true)
expect(host.setVisible).not.toHaveBeenCalled()
expect(host.navigate).not.toHaveBeenCalled()
})
it('does not hide or paint a stale capture after the plus menu has already closed', async () => {
const tab = openBrowserTab()
await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
let finish!: (url: string) => void
host.snapshot.mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
act(() => { useOverlayStore.getState().push(true) })
act(() => { useOverlayStore.getState().pop(true) })
host.setVisible.mockClear()
await act(async () => { finish('data:image/png;base64,STALE') })
expect(screen.queryByTestId('workspace-browser-backdrop')).toBeNull()
expect(host.setVisible).not.toHaveBeenCalledWith(tab.browserTabId, false)
})
it('keeps menus usable after capture failure without turning it into a page load error', async () => {
const tab = openBrowserTab()
await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
host.snapshot.mockRejectedValueOnce(new Error('capture unavailable'))
await act(async () => { useOverlayStore.getState().push(true) })
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, false)
expect(currentTab(tab.id).loadError).toBeFalsy()
act(() => { useOverlayStore.getState().pop(true) })
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, true)
})
it('lets an ordinary modal hide immediately while a snapshot is pending', async () => {
const tab = openBrowserTab()
await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
let finish!: (url: string) => void
host.snapshot.mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
act(() => { useOverlayStore.getState().push(true) })
act(() => { useOverlayStore.getState().push() })
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, false)
await act(async () => { finish('data:image/png;base64,STALE') })
expect(screen.queryByTestId('workspace-browser-backdrop')).toBeNull()
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, false)
})
it('bounds capture waiting so a stalled native snapshot cannot trap the menu', async () => {
const tab = openBrowserTab()
await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
let finish!: (url: string) => void
host.snapshot.mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
vi.useFakeTimers()
try {
act(() => { useOverlayStore.getState().push(true) })
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, true)
await act(async () => { await vi.advanceTimersByTimeAsync(800) })
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, false)
await act(async () => { finish('data:image/png;base64,TOO_LATE') })
expect(screen.queryByTestId('workspace-browser-backdrop')).toBeNull()
act(() => { useOverlayStore.getState().pop(true) })
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, true)
} finally {
vi.useRealTimers()
}
})
it('discards a pending backdrop when the browser tab is deactivated', async () => {
const tab = openBrowserTab()
const { rerender } = await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
let finish!: (url: string) => void
host.snapshot.mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
act(() => { useOverlayStore.getState().push(true) })
rerender(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active={false} />)
await act(async () => { finish('data:image/png;base64,OTHER_TAB') })
expect(screen.queryByTestId('workspace-browser-backdrop')).toBeNull()
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, false)
expect(host.close).not.toHaveBeenCalled()
})
it('clears an already displayed backdrop when the underlying page navigates', async () => {
const tab = openBrowserTab()
await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
await act(async () => { useOverlayStore.getState().push(true) })
expect(screen.getByTestId('workspace-browser-backdrop')).toBeInTheDocument()
emit({ type: 'state', tabId: tab.browserTabId, navigationId: 2, url: 'https://next.example/', title: '', loading: true, canGoBack: false, canGoForward: false })
expect(screen.queryByTestId('workspace-browser-backdrop')).toBeNull()
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, false)
})
it('attaches the page only while its tab is the active one', async () => {
it('draws the page only while its tab is the active one', async () => {
const tab = openBrowserTab()
const { rerender } = await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
expect(pageShown(tab)).toBe(true)
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, true)
rerender(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active={false} />)
expect(pageShown(tab)).toBe(false)
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, false)
rerender(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
expect(pageShown(tab)).toBe(true)
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, true)
expect(host.create).toHaveBeenCalledTimes(1)
})
it('hides the page while a fullscreen DOM overlay is up', async () => {
// A native view always paints above the DOM, so an image modal opened over
// the workspace would otherwise be covered by the page.
const tab = openBrowserTab()
await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
act(() => { useOverlayStore.getState().push() })
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, false)
act(() => { useOverlayStore.getState().pop() })
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, true)
})
it('hides the page while its own downloads overlay is open', async () => {
// Same reason, for the overlays this component draws itself: the downloads
// and history sheets are DOM, and the page would paint straight over them.
it('replaces the page with its own downloads sheet while that is open', async () => {
const tab = openBrowserTab()
await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
await openMenuItem('downloads')
expect(screen.getByTestId('workspace-browser-panel-downloads')).toBeInTheDocument()
expect(pageShown(tab)).toBe(false)
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, false)
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(pageShown(tab)).toBe(true)
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, true)
})
it('shows the empty state, not a blank page, for a tab without an address', async () => {
const tab = openBrowserTab(null)
await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
expect(pageShown(tab)).toBe(false)
expect(document.querySelectorAll('webview')).toHaveLength(1)
})
})
describe('navigation controls', () => {
@@ -1123,7 +1034,6 @@ describe('browser address suggestions', () => {
expect(options[0]).toHaveTextContent('ChatCut latest')
expect(options[1]).toHaveTextContent('Chain Sheet')
expect(screen.getByTestId('workspace-browser-address')).toHaveAttribute('aria-expanded', 'true')
expect(useOverlayStore.getState().snapshotCount).toBe(1)
})
it('filters by title and URL, starts with search, and uses ArrowDown plus Enter to open a history item', async () => {
@@ -1154,7 +1064,6 @@ describe('browser address suggestions', () => {
fireEvent.pointerDown(option)
fireEvent.click(option)
expect(host.navigate).toHaveBeenCalledWith(tab.browserTabId, 'https://chain.test/')
expect(useOverlayStore.getState().count).toBe(0)
})
it('submits the search row but lets IME composition finish first', async () => {
@@ -1169,20 +1078,22 @@ describe('browser address suggestions', () => {
expect(host.navigate).toHaveBeenCalledWith(tab.browserTabId, 'https://www.google.com/search?q=%E7%95%8C%E9%9D%A2%E8%AE%BE%E8%AE%A1')
})
it('keeps the native page behind a presentation snapshot while suggestions are open and restores it on Escape', async () => {
it('drops its suggestions over the live page and closes them on Escape without touching the page', async () => {
seedVisits()
const tab = openBrowserTab()
await renderReady(<WorkspaceBrowserTab sessionId={SESSION} tab={tab} active />)
host.setVisible.mockClear()
const address = screen.getByTestId('workspace-browser-address')
act(() => address.focus())
await waitFor(() => expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, false))
expect(host.snapshot).toHaveBeenCalledWith(tab.browserTabId)
expect(screen.getByRole('listbox')).toBeInTheDocument()
// The list paints over the page; the page itself never had to move.
expect(pageShown(tab)).toBe(true)
fireEvent.change(address, { target: { value: 'unfinished' } })
fireEvent.keyDown(address, { key: 'Escape' })
expect(address).toHaveValue(tab.url)
expect(screen.queryByRole('listbox')).toBeNull()
expect(useOverlayStore.getState().count).toBe(0)
expect(host.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, true)
expect(pageShown(tab)).toBe(true)
expect(host.setVisible).not.toHaveBeenCalled()
})
})
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { flushSync } from 'react-dom'
import {
ArrowLeft,
ArrowRight,
@@ -18,7 +17,6 @@ import { WorkspaceBrowserAddressBar } from '@/components/workbench/WorkspaceBrow
import { WorkspaceBrowserSelectionBar } from '@/components/workbench/WorkspaceBrowserSelectionBar'
import { useDismissable } from '@/hooks/useDismissable'
import { useTranslation } from '../../i18n'
import { computeWebviewBounds } from '../browser/computeWebviewBounds'
import { getDesktopHost } from '../../lib/desktopHost'
import { getServerBaseUrl } from '../../lib/desktopRuntime'
import { formatBytes } from '../../lib/formatBytes'
@@ -28,7 +26,11 @@ import {
isWorkspaceBrowserAvailable,
workspaceBrowserHost,
} from '../../lib/workspace/browserHost'
import { useOverlayStore } from '../../stores/overlayStore'
import {
ensureWorkspaceBrowserGuest,
isWorkspaceBrowserGuestRegistered,
placeWorkspaceBrowserGuest,
} from '../../lib/workspace/browserGuests'
import { useSettingsStore } from '../../stores/settingsStore'
import { useUIStore } from '@/stores/uiStore'
import { useWorkspaceBrowserStore } from '../../stores/workspaceBrowserStore'
@@ -43,7 +45,6 @@ import type { WorkspaceBrowserTab as WorkspaceBrowserTabModel } from '../../lib/
const MIN_ZOOM = MIN_APP_ZOOM
const MAX_ZOOM = MAX_APP_ZOOM
const ZOOM_STEP = 0.1
const PRESENTATION_SNAPSHOT_TIMEOUT_MS = 800
type BrowserPanel = 'downloads' | 'history' | null
@@ -69,10 +70,11 @@ function resolveNavigationUrl(input: string, sessionId: string): string {
/**
* One page, addressed by its own `browserTabId`.
*
* React here only decides *where the page is drawn*. Creating it, navigating it
* and destroying it are the controller's and the host's business — which is why
* unmounting this component (hiding the panel, switching tab, switching task)
* hides the view and nothing more.
* React here only decides *where the page is drawn*: the stage below is the
* placeholder the page's `<webview>` is positioned over. Creating it,
* navigating it and destroying it are the controller's and the host's business
* — which is why unmounting this component (hiding the panel, switching tab,
* switching task) parks the page and nothing more.
*/
export function WorkspaceBrowserTab({ sessionId, tab, active }: WorkspaceBrowserTabProps) {
const t = useTranslation()
@@ -91,20 +93,19 @@ export function WorkspaceBrowserTab({ sessionId, tab, active }: WorkspaceBrowser
const panelRef = useRef<HTMLDivElement>(null)
const appZoom = useSettingsStore((state) => state.uiZoom)
const theme = useUIStore((state) => state.theme)
const overlayCount = useOverlayStore((state) => state.count)
const snapshotOverlayCount = useOverlayStore((state) => state.snapshotCount)
const nativePresentedRef = useRef(false)
const [presentationSnapshot, setPresentationSnapshot] = useState<string | null>(null)
const available = useMemo(() => isWorkspaceBrowserAvailable(), [])
const browserTabId = tab.browserTabId
const [registeredId, setRegisteredId] = useState<string | null>(null)
// Bumped when this page's registration settles; readiness itself is read
// from the guest registry, which also knows when a page was lost.
const [, setRegistrationSettled] = useState(0)
const [createAttempt, setCreateAttempt] = useState(0)
const lifetimeRef = useRef<{ id: string; ready: boolean; cancelled: boolean } | null>(null)
const navigationRequestRef = useRef(0)
const appZoomRef = useRef(appZoom)
appZoomRef.current = appZoom
const page = useWorkspaceBrowserStore((state) => state.pageByTabId[browserTabId])
const ready = page?.registered === true || registeredId === browserTabId
// Only a page the host adopted can take commands. A page registered by an
// earlier mount is ready at once, so re-activating a tab does not flash a
// disabled toolbar.
const ready = isWorkspaceBrowserGuestRegistered(browserTabId)
const initialAddressFocusRef = useRef({ browserTabId, pending: !tab.url && !ready, activated: false })
if (initialAddressFocusRef.current.browserTabId !== browserTabId) {
initialAddressFocusRef.current = { browserTabId, pending: !tab.url && !ready, activated: false }
@@ -116,7 +117,7 @@ export function WorkspaceBrowserTab({ sessionId, tab, active }: WorkspaceBrowser
const visits = useMemo(() => Object.values(historyByTabId).flatMap((entries) => entries ?? []), [historyByTabId])
const downloads = useWorkspaceBrowserStore((state) => state.downloads)
const loading = page?.loading ?? false
menuAllowedRef.current = active && overlayCount === 0 && !pendingNavigation
menuAllowedRef.current = active && !pendingNavigation
const currentAddress = page?.url || tab.url || ''
const annotationActive = page?.annotationActive ?? false
@@ -151,13 +152,13 @@ export function WorkspaceBrowserTab({ sessionId, tab, active }: WorkspaceBrowser
menuRequestRef.current = null
setMenuOpen(false)
}
}, [active, overlayCount, pendingNavigation])
}, [active, pendingNavigation])
const stillOwned = useCallback(() => useWorkspaceStore.getState().findBrowserTabOwner(browserTabId)?.sessionId === sessionId, [browserTabId, sessionId])
const canCommand = useCallback(() => {
const lifetime = lifetimeRef.current
return lifetime?.id === browserTabId && !lifetime.cancelled && stillOwned() &&
(lifetime.ready || useWorkspaceBrowserStore.getState().pageByTabId[browserTabId]?.registered === true)
(lifetime.ready || isWorkspaceBrowserGuestRegistered(browserTabId))
}, [browserTabId, stillOwned])
const reportHostError = useCallback((error: unknown) => {
const lifetime = lifetimeRef.current
@@ -180,18 +181,9 @@ export function WorkspaceBrowserTab({ sessionId, tab, active }: WorkspaceBrowser
reportHostError(error)
})
}
const reportBounds = useCallback(() => {
if (!canCommand()) return
const element = stageRef.current
if (!element) return
void workspaceBrowserHost.setBounds(
browserTabId,
computeWebviewBounds(element.getBoundingClientRect(), appZoomRef.current),
).catch(reportHostError)
}, [browserTabId, canCommand, reportHostError])
// Create once per page identity. `storageId` travels with it so a restored
// tab reopens the same page rather than a blank one.
// tab reopens the same page rather than a blank one. A page that already
// exists is only re-registered, never re-navigated.
useEffect(() => {
if (!available || !stillOwned()) return
menuRequestRef.current = null
@@ -200,32 +192,25 @@ export function WorkspaceBrowserTab({ sessionId, tab, active }: WorkspaceBrowser
const lifetime = { id: browserTabId, ready: false, cancelled: false }
lifetimeRef.current = lifetime
const request = ++navigationRequestRef.current
const navigationId = useWorkspaceBrowserStore.getState().pageByTabId[browserTabId]?.navigationId ?? 0
const element = stageRef.current
void workspaceBrowserHost.create(browserTabId, {
storageId: tab.storageId,
// A delayed completion must never attach an abandoned tab over its replacement.
visible: false,
...(tab.url ? { url: tab.url } : {}),
...(element
? { bounds: computeWebviewBounds(element.getBoundingClientRect(), appZoom) }
: {}),
}).then((result) => {
if (lifetime.cancelled || !stillOwned() || !result.ok) return
void ensureWorkspaceBrowserGuest(browserTabId, async (webContentsId) => {
const result = await workspaceBrowserHost.create(browserTabId, {
storageId: tab.storageId,
webContentsId,
...(tab.url ? { url: tab.url } : {}),
})
if (!result.ok) throw new Error(t('workspace.browser.unavailableTitle'))
}).then(() => {
if (lifetime.cancelled || !stillOwned()) return
lifetime.ready = true
setRegisteredId(browserTabId)
reportBounds()
setRegistrationSettled((count) => count + 1)
}).catch((error: unknown) => {
if (lifetime.cancelled || !stillOwned() || request !== navigationRequestRef.current) return
const current = useWorkspaceBrowserStore.getState().pageByTabId[browserTabId]
// The initial load may reject after the user has already navigated again.
if (current && (current.navigationId > navigationId + 1 || current.navigationOutcome === 'succeeded')) return
reportHostError(error)
})
// Deliberately NOT closing on unmount: the page belongs to the tab, and the
// tab outlives this component. `closeTab` is the only thing that ends it.
return () => {
const registered = lifetime.ready || useWorkspaceBrowserStore.getState().pageByTabId[browserTabId]?.registered === true
const registered = lifetime.ready || isWorkspaceBrowserGuestRegistered(browserTabId)
lifetime.cancelled = true
if (registered && stillOwned()) {
void workspaceBrowserHost.setVisible(browserTabId, false).catch((error: unknown) => {
@@ -235,98 +220,43 @@ export function WorkspaceBrowserTab({ sessionId, tab, active }: WorkspaceBrowser
})
}
}
// The URL/bounds are initial inputs, not a reason to recreate a live page.
// The URL is an initial input, not a reason to recreate a live page.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [browserTabId, createAttempt])
/*
A `WebContentsView` always paints above the DOM, so "is this page on screen"
has to account for everything the app might want to draw over it — and the
teardown is load-bearing.
The page is a `<webview>` composited with the DOM, so menus, dialogs and
other pages draw over it on their own. What remains is deciding when this
surface shows it at all — and the teardown is load-bearing.
Without the cleanup, unmounting leaves the page attached at its last bounds:
switching to a file tab, opening the `+` picker, hiding the workspace or
switching tasks would each leave a live page floating over whatever replaced
it. The surface renders only the active tab, so unmount is the *normal* way
a browser tab goes off screen, not an edge case.
The surface renders only the active tab, so unmount is the *normal* way a
browser tab goes off screen: switching to a file tab, hiding the workspace
or switching tasks. Releasing the placement parks the page (it keeps its
state); without that it would stay drawn over whatever replaced it.
Full-page panels and the retry overlay still replace the guest. The toolbar
menu uses the host's native popup layer, so opening it must not detach or
hide the live page.
Full-page panels and the retry overlay replace the page, and a tab with no
address shows its empty state instead.
*/
const pageCanBePresented = active &&
const pageVisible = active &&
panel === null &&
!pendingNavigation &&
!tab.loadError &&
Boolean(tab.url)
const pageVisible = pageCanBePresented && overlayCount === 0
const snapshotRequested = pageCanBePresented && overlayCount > 0 && overlayCount === snapshotOverlayCount
const navigationId = page?.navigationId ?? 0
useLayoutEffect(() => {
const stage = stageRef.current
if (!available || !stage) return
return placeWorkspaceBrowserGuest(browserTabId, stage, pageVisible && ready)
}, [available, browserTabId, pageVisible, ready])
// Only the shown page acts as browser chrome in the host (shortcuts, the zoom
// capsule), and a native menu of a page that goes away has to close with it.
useLayoutEffect(() => {
if (!canCommand()) return
let cancelled = false
let settled = false
let timeout: ReturnType<typeof setTimeout> | undefined
const present = (visible: boolean) => {
nativePresentedRef.current = visible
void workspaceBrowserHost.setVisible(browserTabId, visible).catch(reportHostError)
}
if (snapshotRequested && nativePresentedRef.current) {
// Capture while the native page is still attached. Its WebContentsView
// would otherwise cover a DOM menu, but detaching first can capture an
// empty frame. This image is presentation-only and never becomes a chat
// attachment. Ordinary modal overlays still hide immediately below.
const finish = (dataUrl: string | null) => {
if (cancelled || settled) return
settled = true
clearTimeout(timeout)
// Commit the already-decoded image before the IPC detaches the view.
flushSync(() => setPresentationSnapshot(dataUrl))
present(false)
}
timeout = setTimeout(() => finish(null), PRESENTATION_SNAPSHOT_TIMEOUT_MS)
void workspaceBrowserHost.snapshot(browserTabId).then(async (dataUrl) => {
if (cancelled || settled) return
if (!dataUrl?.startsWith('data:image/png;base64,')) { finish(null); return }
const image = new Image()
image.src = dataUrl
if (image.decode) await image.decode()
finish(dataUrl)
}).catch(() => finish(null))
} else {
setPresentationSnapshot(null)
present(pageVisible)
}
return () => {
cancelled = true
clearTimeout(timeout)
}
// Navigation invalidates an image even when the page identity is reused.
}, [browserTabId, canCommand, navigationId, pageVisible, ready, reportHostError, snapshotRequested])
void workspaceBrowserHost.setVisible(browserTabId, pageVisible).catch(reportHostError)
}, [browserTabId, canCommand, pageVisible, ready, reportHostError])
useEffect(() => {
if (!active || !ready) return
const element = stageRef.current
if (!element) return
let cancelled = false
const update = () => { if (!cancelled) reportBounds() }
const observer = new ResizeObserver(update)
observer.observe(element)
window.addEventListener('resize', update)
return () => {
cancelled = true
observer.disconnect()
window.removeEventListener('resize', update)
}
}, [active, ready, reportBounds])
useLayoutEffect(() => {
if (active) reportBounds()
}, [active, appZoom, ready, reportBounds])
// The capsule must be drawn inside the native page, not behind it in React.
// The host retains this configuration and replays it after each navigation.
// The capsule is drawn inside the page itself, so it scrolls and zooms with
// it. The host retains this configuration and replays it after each navigation.
useEffect(() => {
if (!ready || !available || !active || !canCommand()) return
const styles = getComputedStyle(document.documentElement)
@@ -635,17 +565,7 @@ export function WorkspaceBrowserTab({ sessionId, tab, active }: WorkspaceBrowser
) : null}
<div className="relative min-h-0 flex-1 overflow-hidden" data-testid="workspace-browser-stage">
<div ref={stageRef} className="absolute inset-0" />
{presentationSnapshot ? (
<img
data-testid="workspace-browser-backdrop"
src={presentationSnapshot}
alt=""
aria-hidden="true"
draggable={false}
className="pointer-events-none absolute inset-0 h-full w-full object-fill"
/>
) : null}
<div ref={stageRef} data-testid="workspace-browser-placeholder" className="absolute inset-0" />
{!tab.url && !tab.loadError ? (
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 text-center">
<Globe size={32} strokeWidth={1.75} aria-hidden="true" className="mb-2 text-[var(--color-text-tertiary)]" />
@@ -12,10 +12,8 @@ const mocks = vi.hoisted(() => ({
workspaceBrowserHost: {
create: vi.fn(async () => ({ ok: true })),
setVisible: vi.fn(async () => ({ ok: true })),
setBounds: vi.fn(async () => ({ ok: true })),
close: vi.fn(async () => ({ ok: true })),
message: vi.fn(async () => ({ ok: true })),
snapshot: vi.fn(async (): Promise<string | null> => null),
},
}))
@@ -67,6 +65,7 @@ import { WorkspaceSurface, useWorkspaceBrowserEventBridge } from './WorkspaceSur
import { WorkspaceHeaderProvider } from '../layout/WorkspaceHeaderContext'
import { TabBar } from '../layout/TabBar'
import type { WorkspaceBrowserTab } from '../../lib/workspace/types'
import { installFakeBrowserGuests } from '../../test/fakeBrowserGuests'
const SESSION = 'session-a'
@@ -84,10 +83,14 @@ beforeEach(() => {
mocks.releaseWorkspaceBrowserTab.mockClear()
mocks.isWorkspaceBrowserAvailable.mockReturnValue(false)
Object.values(mocks.workspaceBrowserHost).forEach(mock => mock.mockClear())
disposeGuests = installFakeBrowserGuests()
})
let disposeGuests: () => void
afterEach(() => {
cleanup()
disposeGuests()
vi.unstubAllGlobals()
})
@@ -116,6 +119,32 @@ it('reveals successful navigation and ignores old failures and stopped error doc
expect(useWorkspaceStore.getState().getTab(SESSION, tabId)).toMatchObject({ loadError: null, url: 'https://good.test/' })
})
it('reloads a crashed page in place but rebuilds a page whose guest was lost', async () => {
mocks.isWorkspaceBrowserAvailable.mockReturnValue(true)
const tabId = useWorkspaceStore.getState().openTarget(SESSION, { kind: 'browser', url: 'https://lost.test/' })!
const browserTabId = (useWorkspaceStore.getState().getTab(SESSION, tabId) as WorkspaceBrowserTab).browserTabId
renderSurface()
await waitFor(() => expect(screen.getByTestId('workspace-browser-address')).toBeEnabled())
renderHook(() => useWorkspaceBrowserEventBridge(true))
await act(async () => { await Promise.resolve() })
const emitEvent = (event: unknown) => act(() => { mocks.subscribeWorkspaceBrowserEvents.mock.calls[0]![0](event) })
const [firstGuest] = Array.from(document.querySelectorAll('webview'))
// A crashed guest still exists: retrying reloads it.
emitEvent({ type: 'destroyed', tabId: browserTabId, reason: 'crashed' })
expect(screen.getByRole('alert')).toBeInTheDocument()
expect(firstGuest!.isConnected).toBe(true)
// A lost guest is gone: retrying must build and register a new page.
emitEvent({ type: 'destroyed', tabId: browserTabId, reason: 'closed' })
expect(firstGuest!.isConnected).toBe(false)
fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
await waitFor(() => expect(mocks.workspaceBrowserHost.create).toHaveBeenCalledTimes(2))
const [first, second] = mocks.workspaceBrowserHost.create.mock.calls as unknown as Array<[string, { webContentsId: number, url?: string }]>
expect(second![1].webContentsId).not.toBe(first![1].webContentsId)
expect(second![1].url).toBe('https://lost.test/')
})
it('updates downloads after close without resurrecting page state', async () => {
const tabId = useWorkspaceStore.getState().openTarget(SESSION, { kind: 'browser' })!
const browserTabId = (useWorkspaceStore.getState().getTab(SESSION, tabId) as WorkspaceBrowserTab).browserTabId
@@ -13,6 +13,7 @@ import { useWorkspaceBrowserStore } from '../../stores/workspaceBrowserStore'
import { useWorkspaceContentStore } from '../../stores/workspaceContentStore'
import { openWorkspaceTarget, workspaceOpen } from '../../lib/workspace/openTarget'
import { subscribeWorkspaceBrowserEvents } from '../../lib/workspace/browserHost'
import { forgetLostWorkspaceBrowserGuest } from '../../lib/workspace/browserGuests'
import type { WorkspaceCloseScope, WorkspaceDock, WorkspaceTabKind } from '../../lib/workspace/types'
import { WorkspaceTabStrip } from './WorkspaceTabStrip'
import { WorkspaceLauncher } from './WorkspaceLauncher'
@@ -335,6 +336,9 @@ export function useWorkspaceBrowserEventBridge(enabled: boolean) {
})
break
case 'destroyed':
// A crashed guest still exists and reloads in place. A closed one is
// gone, so its retry has to build a new page.
if (event.reason === 'closed') forgetLostWorkspaceBrowserGuest(event.tabId)
store.updateBrowserTab(owner.sessionId, event.tabId, {
// A reason code is not a message. It reaches the error overlay, so
// it has to be a translated sentence in all five languages.
+33
View File
@@ -214,3 +214,36 @@ describe('useDismissable', () => {
expect(onDismiss).not.toHaveBeenCalled()
})
})
describe('useDismissable with embedded pages', () => {
// A page in a `<webview>` never reports its pointer events to the app's
// document; clicking into it only blurs the window and moves focus onto the
// element. That is the outside click a menu over the workspace browser sees.
function focusEmbedded(element: HTMLElement) {
element.tabIndex = -1
element.focus()
window.dispatchEvent(new Event('blur'))
}
it('dismisses when focus moves into an embedded page outside the overlay', async () => {
const onDismiss = vi.fn()
render(<Harness onDismiss={onDismiss} />)
const page = document.createElement('webview')
document.body.appendChild(page)
focusEmbedded(page)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(onDismiss).toHaveBeenCalledWith('outside')
page.remove()
})
it('stays open when the window blurs for another app or for a frame inside the overlay', async () => {
const onDismiss = vi.fn()
render(<Harness onDismiss={onDismiss} />)
window.dispatchEvent(new Event('blur'))
const frame = document.createElement('iframe')
screen.getByTestId('panel').appendChild(frame)
focusEmbedded(frame)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(onDismiss).not.toHaveBeenCalled()
})
})
+20
View File
@@ -97,7 +97,25 @@ export function useDismissable({
}
const handleResize = () => onDismiss('resize')
// A page inside a `<webview>` or `<iframe>` never reports its pointer
// events to this document: clicking into it only takes focus away from
// the window. Focus landing in embedded content outside the overlay is
// that same outside click. Read after the blur settles — Chromium blurs
// the window before it moves `activeElement` onto the frame.
let blurTimer: ReturnType<typeof setTimeout> | undefined
const handleWindowBlur = () => {
clearTimeout(blurTimer)
blurTimer = setTimeout(() => {
const active = document.activeElement
if (!active || (active.tagName !== 'WEBVIEW' && active.tagName !== 'IFRAME')) return
if (refs.some((ref) => ref.current?.contains(active))) return
if (triggerRef?.current?.contains(active)) return
onDismiss('outside')
}, 0)
}
document.addEventListener(event, handlePointer, capture)
window.addEventListener('blur', handleWindowBlur)
if (closeOnEscape) document.addEventListener('keydown', handleKey, stopEscapePropagation)
if (closeOnViewportChange) {
// Capture on scroll: scroll events from nested containers do not bubble.
@@ -106,6 +124,8 @@ export function useDismissable({
}
return () => {
clearTimeout(blurTimer)
window.removeEventListener('blur', handleWindowBlur)
document.removeEventListener(event, handlePointer, capture)
document.removeEventListener('keydown', handleKey, stopEscapePropagation)
window.removeEventListener('scroll', handleScroll, true)
@@ -327,13 +327,11 @@ export const browserHost: DesktopHost = {
async goForward() {},
async reload() {},
async stop() {},
async setBounds() {},
async setVisible() {},
async setZoom() {},
async find() {},
async stopFind() {},
async capture() {},
async snapshot() { return null },
async message() {},
async printToPdf() {},
async showMenu() { return null },
+1 -3
View File
@@ -72,19 +72,17 @@ describe('desktop host contract', () => {
// externally" fallback, so a rejection here would only ever surface as an
// unhandled error behind that fallback.
expect(browserHost.capabilities.workspaceBrowser).toBe(false)
await expect(browserHost.browser.create('wb-1', { storageId: 'wsb-1' })).resolves.toBeUndefined()
await expect(browserHost.browser.create('wb-1', { storageId: 'wsb-1', webContentsId: 7 })).resolves.toBeUndefined()
await expect(browserHost.browser.navigate('wb-1', 'https://example.com')).resolves.toBeUndefined()
await expect(browserHost.browser.goBack('wb-1')).resolves.toBeUndefined()
await expect(browserHost.browser.goForward('wb-1')).resolves.toBeUndefined()
await expect(browserHost.browser.reload('wb-1', { ignoreCache: true })).resolves.toBeUndefined()
await expect(browserHost.browser.stop('wb-1')).resolves.toBeUndefined()
await expect(browserHost.browser.setBounds('wb-1', { x: 0, y: 0, width: 10, height: 10 })).resolves.toBeUndefined()
await expect(browserHost.browser.setVisible('wb-1', false)).resolves.toBeUndefined()
await expect(browserHost.browser.setZoom('wb-1', 1.25)).resolves.toBeUndefined()
await expect(browserHost.browser.find('wb-1', 'invoice')).resolves.toBeUndefined()
await expect(browserHost.browser.stopFind('wb-1')).resolves.toBeUndefined()
await expect(browserHost.browser.capture('wb-1', 'viewport')).resolves.toBeUndefined()
await expect(browserHost.browser.snapshot('wb-1')).resolves.toBeNull()
await expect(browserHost.browser.message('wb-1', { v: 1, type: 'exit-picker' })).resolves.toBeUndefined()
await expect(browserHost.browser.printToPdf('wb-1')).resolves.toBeUndefined()
await expect(browserHost.browser.showMenu('wb-1', {
@@ -231,15 +231,13 @@ describe('electron desktop host', () => {
await host.browser.create('wb-1', {
storageId: 'wsb-1',
url: 'https://example.com',
bounds: { x: 0, y: 40, width: 800, height: 600 },
visible: false,
webContentsId: 7,
})
await host.browser.navigate('wb-1', 'https://example.com/next')
await host.browser.goBack('wb-1')
await host.browser.goForward('wb-1')
await host.browser.reload('wb-1', { ignoreCache: true })
await host.browser.stop('wb-1')
await host.browser.setBounds('wb-1', { x: 1, y: 2, width: 3, height: 4 })
await host.browser.setVisible('wb-1', false)
await host.browser.setZoom('wb-1', 1.25)
await host.browser.find('wb-1', 'invoice', { matchCase: true })
@@ -254,16 +252,14 @@ describe('electron desktop host', () => {
[ELECTRON_IPC_CHANNELS.workspaceBrowserCreate, {
tabId: 'wb-1',
storageId: 'wsb-1',
webContentsId: 7,
url: 'https://example.com',
bounds: { x: 0, y: 40, width: 800, height: 600 },
visible: false,
}],
[ELECTRON_IPC_CHANNELS.workspaceBrowserNavigate, { tabId: 'wb-1', url: 'https://example.com/next' }],
[ELECTRON_IPC_CHANNELS.workspaceBrowserGoBack, { tabId: 'wb-1' }],
[ELECTRON_IPC_CHANNELS.workspaceBrowserGoForward, { tabId: 'wb-1' }],
[ELECTRON_IPC_CHANNELS.workspaceBrowserReload, { tabId: 'wb-1', ignoreCache: true }],
[ELECTRON_IPC_CHANNELS.workspaceBrowserStop, { tabId: 'wb-1' }],
[ELECTRON_IPC_CHANNELS.workspaceBrowserSetBounds, { tabId: 'wb-1', bounds: { x: 1, y: 2, width: 3, height: 4 } }],
[ELECTRON_IPC_CHANNELS.workspaceBrowserSetVisible, { tabId: 'wb-1', visible: false }],
[ELECTRON_IPC_CHANNELS.workspaceBrowserSetZoom, { tabId: 'wb-1', factor: 1.25 }],
[ELECTRON_IPC_CHANNELS.workspaceBrowserFind, { tabId: 'wb-1', text: 'invoice', options: { matchCase: true } }],
@@ -282,13 +278,12 @@ describe('electron desktop host', () => {
expect(host.capabilities.workspaceBrowser).toBe(true)
})
it('returns a presentation snapshot through its own addressed IPC without requesting a chat capture', async () => {
const invoke = vi.fn().mockResolvedValue('data:image/png;base64,BACKDROP')
it('refuses to register a page without the guest it names', async () => {
const invoke = vi.fn().mockResolvedValue(undefined)
const host = createElectronHost({ invoke, subscribe: vi.fn() })
await expect(host.browser.snapshot('wb-1')).resolves.toBe('data:image/png;base64,BACKDROP')
expect(invoke.mock.calls).toEqual([[ELECTRON_IPC_CHANNELS.workspaceBrowserSnapshot, { tabId: 'wb-1' }]])
await expect(host.browser.snapshot('')).rejects.toThrow('Invalid Electron IPC payload')
expect(invoke).toHaveBeenCalledTimes(1)
await expect(host.browser.create('wb-1', { storageId: 'wsb-1', webContentsId: 0 }))
.rejects.toThrow('Invalid Electron IPC payload')
expect(invoke).not.toHaveBeenCalled()
})
it('rejects an unaddressed browser call before it reaches Electron IPC', async () => {
+1 -4
View File
@@ -202,9 +202,8 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost {
create: (tabId, options) => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserCreate, {
tabId,
storageId: options.storageId,
webContentsId: options.webContentsId,
...(options.url === undefined ? {} : { url: options.url }),
...(options.bounds === undefined ? {} : { bounds: options.bounds }),
...(options.visible === undefined ? {} : { visible: options.visible }),
}),
navigate: (tabId, url) => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserNavigate, { tabId, url }),
goBack: tabId => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserGoBack, { tabId }),
@@ -214,7 +213,6 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost {
...(options?.ignoreCache === undefined ? {} : { ignoreCache: options.ignoreCache }),
}),
stop: tabId => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserStop, { tabId }),
setBounds: (tabId, bounds) => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserSetBounds, { tabId, bounds }),
setVisible: (tabId, visible) => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserSetVisible, { tabId, visible }),
setZoom: (tabId, factor) => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserSetZoom, { tabId, factor }),
find: (tabId, text, options) => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserFind, {
@@ -224,7 +222,6 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost {
}),
stopFind: tabId => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserStopFind, { tabId }),
capture: (tabId, kind) => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserCapture, { tabId, kind }),
snapshot: (tabId) => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserSnapshot, { tabId }),
message: (tabId, payload) => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserMessage, { tabId, payload }),
printToPdf: tabId => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserPrintToPdf, { tabId }),
close: tabId => invoke(ELECTRON_IPC_CHANNELS.workspaceBrowserClose, { tabId }),
+2 -6
View File
@@ -194,8 +194,6 @@ export type PreviewHostMessage = PreviewCaptureMessage | PreviewPickerMessage |
* Multi-page browser host. Every method names its page, so a second page never
* navigates the first and unmounting a React surface never destroys a page.
*/
export type WorkspaceBrowserBounds = PreviewBounds
export type WorkspaceBrowserCaptureKind = 'full' | 'viewport'
export type WorkspaceBrowserMenuAction =
@@ -524,23 +522,21 @@ export type DesktopHost = {
}
browser: {
showMenu(tabId: string, options: WorkspaceBrowserMenuOptions): Promise<WorkspaceBrowserMenuAction | null>
/** Registers the page's `<webview>` guest with the host and starts `url`. */
create(
tabId: string,
options: { storageId: string; url?: string; bounds?: WorkspaceBrowserBounds; visible?: boolean },
options: { storageId: string; url?: string; webContentsId: number },
): Promise<void>
navigate(tabId: string, url: string): Promise<void>
goBack(tabId: string): Promise<void>
goForward(tabId: string): Promise<void>
reload(tabId: string, options?: { ignoreCache?: boolean }): Promise<void>
stop(tabId: string): Promise<void>
setBounds(tabId: string, bounds: WorkspaceBrowserBounds): Promise<void>
setVisible(tabId: string, visible: boolean): Promise<void>
setZoom(tabId: string, factor: number): Promise<void>
find(tabId: string, text: string, options?: WorkspaceBrowserFindOptions): Promise<void>
stopFind(tabId: string): Promise<void>
capture(tabId: string, kind: WorkspaceBrowserCaptureKind): Promise<void>
/** Read-only presentation backdrop, with no screenshot or composer event. */
snapshot(tabId: string): Promise<string | null>
message(tabId: string, payload: PreviewHostMessage): Promise<void>
printToPdf(tabId: string): Promise<void>
close(tabId: string): Promise<void>
@@ -0,0 +1,21 @@
/**
* Shared between the renderer, which creates each page's `<webview>`, and the
* Electron host, which only adopts guests that match this contract.
*/
/**
* One persistent partition for every workspace browser page of this user.
*
* Codex does the same with `persist:codex-browser-app`: a login performed in
* one tab has to be there in the next one. The per-tab `storageId` is a *page
* restore identity* — which page to reopen after a restart — and must never be
* turned into a partition name, or every tab would get its own cookie jar.
*/
export const WORKSPACE_BROWSER_PARTITION = 'persist:cc-haha-browser-app'
/**
* A `<webview>` creates no guest until it has a `src`. The renderer always
* starts from this document and the host performs every real navigation, so a
* renderer never chooses what a page loads by writing an attribute.
*/
export const WORKSPACE_BROWSER_INITIAL_SRC = 'about:blank'
@@ -0,0 +1,282 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
type Registry = typeof import('./browserGuests')
// The registry is module state on purpose (pages outlive every component), so
// each test gets a fresh copy.
let registry: Registry
let layer: HTMLDivElement
let nextId = 40
beforeEach(async () => {
vi.resetModules()
registry = await import('./browserGuests')
layer = document.createElement('div')
document.body.appendChild(layer)
rect(layer, { left: 100, top: 50, width: 1000, height: 800 })
})
afterEach(() => {
registry.setWorkspaceBrowserGuestLayer(null)
document.body.innerHTML = ''
vi.useRealTimers()
vi.unstubAllGlobals()
})
function rect(element: HTMLElement, box: { left: number, top: number, width: number, height: number }) {
element.getBoundingClientRect = () => ({
...box, x: box.left, y: box.top, right: box.left + box.width, bottom: box.top + box.height, toJSON: () => box,
}) as DOMRect
}
function webviews(): HTMLElement[] {
return Array.from(document.querySelectorAll<HTMLElement>('webview'))
}
/** What Electron does once a guest exists: it becomes addressable, then ready. */
function attach(webview: HTMLElement, id = nextId++): number {
Object.assign(webview, { getWebContentsId: () => id })
webview.dispatchEvent(new Event('dom-ready'))
return id
}
async function settle() {
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
}
describe('workspace browser guests', () => {
it('creates one blank, partitioned webview per page and registers the guest it got', async () => {
registry.setWorkspaceBrowserGuestLayer(layer)
const register = vi.fn(async (_id: number) => {})
const ready = registry.ensureWorkspaceBrowserGuest('tab-a', register)
await settle()
const [webview] = webviews()
expect(webview).toBeDefined()
// Exactly what the host's attach policy admits; anything else is denied.
expect(webview!.getAttribute('partition')).toBe('persist:cc-haha-browser-app')
expect(webview!.getAttribute('src')).toBe('about:blank')
// Without it Chromium drops popups before the host can turn them into tabs.
expect(webview!.hasAttribute('allowpopups')).toBe(true)
expect(register).not.toHaveBeenCalled()
const id = attach(webview!)
await ready
expect(register).toHaveBeenCalledWith(id)
expect(registry.isWorkspaceBrowserGuestRegistered('tab-a')).toBe(true)
// A remounted tab asks again: same page, no second element, no second registration.
await registry.ensureWorkspaceBrowserGuest('tab-a', register)
expect(webviews()).toEqual([webview])
expect(register).toHaveBeenCalledTimes(1)
})
it('reads the guest id only once the page is ready, and only the first time', async () => {
registry.setWorkspaceBrowserGuestLayer(layer)
const register = vi.fn(async (_id: number) => {})
const ready = registry.ensureWorkspaceBrowserGuest('tab-a', register)
await settle()
const [webview] = webviews()
attach(webview!, 7)
await ready
// `dom-ready` repeats on every navigation of the page.
attach(webview!, 8)
await settle()
expect(register.mock.calls).toEqual([[7]])
})
it('creates nothing until the layer exists', async () => {
const ready = registry.ensureWorkspaceBrowserGuest('tab-a', async () => {})
await settle()
expect(webviews()).toHaveLength(0)
registry.setWorkspaceBrowserGuestLayer(layer)
await settle()
expect(webviews()).toHaveLength(1)
expect(layer.contains(webviews()[0]!)).toBe(true)
attach(webviews()[0]!)
await ready
})
it('drops a page the host refused, so a retry starts from a fresh element', async () => {
registry.setWorkspaceBrowserGuestLayer(layer)
const first = registry.ensureWorkspaceBrowserGuest('tab-a', async () => { throw new Error('not adopted') })
await settle()
const refused = webviews()[0]!
attach(refused)
await expect(first).rejects.toThrow('not adopted')
expect(refused.isConnected).toBe(false)
expect(registry.isWorkspaceBrowserGuestRegistered('tab-a')).toBe(false)
const register = vi.fn(async (_id: number) => {})
const retry = registry.ensureWorkspaceBrowserGuest('tab-a', register)
await settle()
const fresh = webviews()[0]!
expect(fresh).not.toBe(refused)
attach(fresh)
await retry
expect(registry.isWorkspaceBrowserGuestRegistered('tab-a')).toBe(true)
})
it('gives up on a guest that never attaches', async () => {
vi.useFakeTimers()
registry.setWorkspaceBrowserGuestLayer(layer)
const ready = registry.ensureWorkspaceBrowserGuest('tab-a', async () => {})
const outcome = expect(ready).rejects.toThrow('failed to attach')
await vi.advanceTimersByTimeAsync(10_000)
await outcome
expect(webviews()).toHaveLength(0)
})
it('draws the page over its placeholder on whole device pixels, and parks it at the same size', async () => {
vi.stubGlobal('devicePixelRatio', 2)
registry.setWorkspaceBrowserGuestLayer(layer)
const stage = document.createElement('div')
document.body.appendChild(stage)
rect(stage, { left: 600.3, top: 152.2, width: 480.26, height: 640.74 })
const ready = registry.ensureWorkspaceBrowserGuest('tab-a', async () => {})
await settle()
attach(webviews()[0]!)
await ready
const release = registry.placeWorkspaceBrowserGuest('tab-a', stage, true)
const slot = webviews()[0]!.parentElement!
expect(slot.style).toMatchObject({ left: '500.5px', top: '102px', width: '480.5px', height: '640.5px' })
expect(slot.style.visibility).toBe('visible')
expect(slot.style.pointerEvents).toBe('auto')
release()
expect(slot.style.visibility).toBe('hidden')
expect(slot.style.pointerEvents).toBe('none')
// Parking never resizes the page, so hiding it does not reflow it.
expect(slot.style.width).toBe('480.5px')
expect(webviews()[0]!.isConnected).toBe(true)
})
it('applies a placement made before the page existed, and never moves the element', async () => {
registry.setWorkspaceBrowserGuestLayer(layer)
const first = document.createElement('div')
const second = document.createElement('div')
document.body.append(first, second)
rect(first, { left: 100, top: 50, width: 300, height: 200 })
rect(second, { left: 400, top: 60, width: 500, height: 400 })
registry.placeWorkspaceBrowserGuest('tab-a', first, true)
const ready = registry.ensureWorkspaceBrowserGuest('tab-a', async () => {})
await settle()
const webview = webviews()[0]!
const slot = webview.parentElement!
expect(slot.style).toMatchObject({ left: '0px', top: '0px', width: '300px', height: '200px', visibility: 'visible' })
attach(webview)
await ready
// A webview that changes parents reloads its page; only the slot moves.
registry.placeWorkspaceBrowserGuest('tab-a', second, true)
expect(webview.parentElement).toBe(slot)
expect(slot.parentElement).toBe(layer)
expect(slot.style).toMatchObject({ left: '300px', top: '10px', width: '500px', height: '400px' })
})
it('lets a stale release leave a newer placement alone', async () => {
registry.setWorkspaceBrowserGuestLayer(layer)
const stage = document.createElement('div')
document.body.appendChild(stage)
rect(stage, { left: 100, top: 50, width: 300, height: 200 })
const ready = registry.ensureWorkspaceBrowserGuest('tab-a', async () => {})
await settle()
attach(webviews()[0]!)
await ready
const releaseOld = registry.placeWorkspaceBrowserGuest('tab-a', stage, true)
registry.placeWorkspaceBrowserGuest('tab-a', stage, true)
releaseOld()
expect(webviews()[0]!.parentElement!.style.visibility).toBe('visible')
})
it('follows its placeholder when the placeholder resizes', async () => {
const observers: Array<{ callback: () => void, targets: Element[] }> = []
vi.stubGlobal('ResizeObserver', class {
targets: Element[] = []
constructor(public callback: () => void) { observers.push(this) }
observe(target: Element) { this.targets.push(target) }
disconnect() {}
unobserve() {}
})
registry.setWorkspaceBrowserGuestLayer(layer)
const stage = document.createElement('div')
document.body.appendChild(stage)
rect(stage, { left: 100, top: 50, width: 300, height: 200 })
const ready = registry.ensureWorkspaceBrowserGuest('tab-a', async () => {})
await settle()
attach(webviews()[0]!)
await ready
registry.placeWorkspaceBrowserGuest('tab-a', stage, true)
rect(stage, { left: 100, top: 86, width: 300, height: 164 })
observers.find(observer => observer.targets.includes(stage))!.callback()
expect(webviews()[0]!.parentElement!.style).toMatchObject({ top: '36px', height: '164px' })
})
it('ends a page by removing its element, and forgets a page the host lost', async () => {
registry.setWorkspaceBrowserGuestLayer(layer)
for (const tabId of ['closed', 'lost']) {
const ready = registry.ensureWorkspaceBrowserGuest(tabId, async () => {})
await settle()
attach(webviews().at(-1)!)
await ready
}
const [closed, lost] = webviews()
registry.disposeWorkspaceBrowserGuest('closed')
registry.forgetLostWorkspaceBrowserGuest('lost')
expect(closed!.isConnected).toBe(false)
expect(lost!.isConnected).toBe(false)
expect(registry.isWorkspaceBrowserGuestRegistered('closed')).toBe(false)
expect(registry.isWorkspaceBrowserGuestRegistered('lost')).toBe(false)
})
it('rebuilds a page whose element went away with an old layer instead of moving it', async () => {
registry.setWorkspaceBrowserGuestLayer(layer)
const ready = registry.ensureWorkspaceBrowserGuest('tab-a', async () => {})
await settle()
const old = webviews()[0]!
attach(old)
await ready
layer.remove()
const next = document.createElement('div')
document.body.appendChild(next)
registry.setWorkspaceBrowserGuestLayer(next)
expect(registry.isWorkspaceBrowserGuestRegistered('tab-a')).toBe(false)
const again = registry.ensureWorkspaceBrowserGuest('tab-a', async () => {})
await settle()
const rebuilt = Array.from(next.querySelectorAll('webview'))[0]!
expect(rebuilt).not.toBe(old)
attach(rebuilt as HTMLElement)
await again
expect(registry.isWorkspaceBrowserGuestRegistered('tab-a')).toBe(true)
})
it('keeps an app pointer gesture from being taken by a page it crosses', async () => {
registry.setWorkspaceBrowserGuestLayer(layer)
const stage = document.createElement('div')
document.body.appendChild(stage)
rect(stage, { left: 100, top: 50, width: 300, height: 200 })
const ready = registry.ensureWorkspaceBrowserGuest('tab-a', async () => {})
await settle()
attach(webviews()[0]!)
await ready
registry.placeWorkspaceBrowserGuest('tab-a', stage, true)
const slot = webviews()[0]!.parentElement!
// A drag that starts in the app (e.g. the panel resize handle).
document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }))
expect(slot.style.pointerEvents).toBe('none')
expect(slot.style.visibility).toBe('visible')
window.dispatchEvent(new Event('pointerup'))
expect(slot.style.pointerEvents).toBe('auto')
// A release outside the window never arrives; a plain move ends it.
document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }))
const move = new Event('pointermove', { bubbles: true })
Object.defineProperty(move, 'buttons', { value: 0 })
document.body.dispatchEvent(move)
expect(slot.style.pointerEvents).toBe('auto')
})
})
+293
View File
@@ -0,0 +1,293 @@
import {
WORKSPACE_BROWSER_INITIAL_SRC,
WORKSPACE_BROWSER_PARTITION,
} from './browserGuestContract'
/**
* Renderer side of the workspace browser: one `<webview>` per page.
*
* Why a `<webview>` and not a native view: a native view always paints above
* the DOM, so every menu, dialog and page that should cover it had to hide it
* or swap in a screenshot — which flickered, and was missed by every overlay
* that did not opt in. A webview is composited with the page like any element.
*
* Why this module and not React: a webview that leaves the DOM destroys its
* page, and one that is *moved* reloads it. React unmounts a tab's surface
* whenever the tab, the task or the panel changes, so the element can never
* live inside that surface. It lives in a layer that is mounted once, is never
* re-parented, and is only positioned over whatever placeholder currently
* shows the page. The page's lifetime is `dispose`, nothing else.
*/
const ATTACH_TIMEOUT_MS = 10_000
type WebviewElement = HTMLElement & { getWebContentsId(): number }
type GuestRecord = {
tabId: string
slot: HTMLDivElement
webview: WebviewElement
webContentsId: number | null
attach: Promise<number> | null
registration: Promise<void> | null
registered: boolean
}
type Placement = {
token: object
stage: HTMLElement
visible: boolean
observer: ResizeObserver | null
}
let layer: HTMLElement | null = null
let layerObserver: ResizeObserver | null = null
let layerWaiters: Array<(element: HTMLElement) => void> = []
const guests = new Map<string, GuestRecord>()
const placements = new Map<string, Placement>()
let gesture = false
let gestureListenersInstalled = false
/**
* The layer is rendered once, inside the session panel's stacking context, so
* a page paints above the workspace like the placeholder it covers and below
* every dropdown, dialog and toast — and fades with the panel when another page
* replaces it.
*/
export function setWorkspaceBrowserGuestLayer(element: HTMLElement | null): void {
if (element === layer) return
layerObserver?.disconnect()
layerObserver = null
layer = element
// Guests are never moved here: a webview that changes parents reloads.
// Pages whose element went away with an old layer are disconnected, which
// is how `ensureWorkspaceBrowserGuest` knows to rebuild them; pages still
// waiting for a layer attach to this one.
if (!element) return
installGestureListeners()
if (typeof ResizeObserver !== 'undefined') {
layerObserver = new ResizeObserver(() => applyAllPlacements())
layerObserver.observe(element)
}
const waiters = layerWaiters
layerWaiters = []
for (const resolve of waiters) resolve(element)
}
/**
* Creates the page's webview if it has none and registers it with the host.
* Repeated calls for a live page resolve without touching it.
*/
export function ensureWorkspaceBrowserGuest(
tabId: string,
register: (webContentsId: number) => Promise<void>,
): Promise<void> {
let record = guests.get(tabId)
if (record && !record.slot.isConnected) {
forget(record)
record = undefined
}
if (record?.registration) return record.registration
const current = record ?? createGuest(tabId)
const registration = attachGuest(current).then(webContentsId => register(webContentsId)).then(
() => {
if (guests.get(tabId) === current) current.registered = true
},
(error: unknown) => {
// The host adopted nothing, so nothing addresses this guest. Drop it and
// let the next attempt start from a fresh element.
if (guests.get(tabId) === current) dispose(current)
throw error
},
)
current.registration = registration
return registration
}
export function isWorkspaceBrowserGuestRegistered(tabId: string): boolean {
const record = guests.get(tabId)
return Boolean(record?.registered && record.slot.isConnected)
}
/**
* Draws the page over `stage` while `visible`, otherwise parks it. Returns a
* release that parks the page only if no later placement replaced this one.
* A placement may precede the page's creation; it applies once the page exists.
*/
export function placeWorkspaceBrowserGuest(
tabId: string,
stage: HTMLElement,
visible: boolean,
): () => void {
placements.get(tabId)?.observer?.disconnect()
const token = {}
const observer = typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(() => applyPlacement(tabId))
observer?.observe(stage)
placements.set(tabId, { token, stage, visible, observer })
applyPlacement(tabId)
return () => {
const placement = placements.get(tabId)
if (placement?.token !== token) return
placement.observer?.disconnect()
placements.delete(tabId)
applyPlacement(tabId)
}
}
/** Ends the page: removing its element destroys the guest. */
export function disposeWorkspaceBrowserGuest(tabId: string): void {
const record = guests.get(tabId)
if (record) dispose(record)
placements.get(tabId)?.observer?.disconnect()
placements.delete(tabId)
}
/** The host reported the guest gone; forget it so a retry builds a new one. */
export function forgetLostWorkspaceBrowserGuest(tabId: string): void {
const record = guests.get(tabId)
if (record) dispose(record)
}
function createGuest(tabId: string): GuestRecord {
const slot = document.createElement('div')
slot.dataset.workspaceBrowserSlot = tabId
Object.assign(slot.style, {
position: 'absolute',
left: '0px',
top: '0px',
width: '0px',
height: '0px',
overflow: 'hidden',
visibility: 'hidden',
pointerEvents: 'none',
})
const webview = document.createElement('webview') as WebviewElement
// Attributes the host's attach policy requires; it denies anything else.
webview.setAttribute('partition', WORKSPACE_BROWSER_PARTITION)
// Without it Chromium blocks `window.open` and `target=_blank` before the
// host's popup handler can turn them into tabs. The host still denies them.
webview.setAttribute('allowpopups', '')
webview.setAttribute('src', WORKSPACE_BROWSER_INITIAL_SRC)
Object.assign(webview.style, { display: 'flex', width: '100%', height: '100%', border: '0' })
slot.appendChild(webview)
const record: GuestRecord = {
tabId,
slot,
webview,
webContentsId: null,
attach: null,
registration: null,
registered: false,
}
guests.set(tabId, record)
return record
}
function attachGuest(record: GuestRecord): Promise<number> {
if (record.webContentsId !== null) return Promise.resolve(record.webContentsId)
if (record.attach) return record.attach
record.attach = waitForLayer().then(target => new Promise<number>((resolve, reject) => {
if (guests.get(record.tabId) !== record) {
reject(new Error('Browser page was closed before it attached'))
return
}
const timer = setTimeout(() => {
record.webview.removeEventListener('dom-ready', onReady)
reject(new Error('Browser page failed to attach'))
}, ATTACH_TIMEOUT_MS)
// Only now is a guest id readable; `did-attach` fires too early for it.
// `dom-ready` repeats on every navigation, so take the first one only.
function onReady() {
clearTimeout(timer)
record.webview.removeEventListener('dom-ready', onReady)
try {
const id = record.webview.getWebContentsId()
record.webContentsId = id
resolve(id)
} catch (error) {
reject(error instanceof Error ? error : new Error(String(error)))
}
}
record.webview.addEventListener('dom-ready', onReady)
target.appendChild(record.slot)
applyPlacement(record.tabId)
}))
return record.attach
}
function waitForLayer(): Promise<HTMLElement> {
if (layer) return Promise.resolve(layer)
return new Promise(resolve => { layerWaiters.push(resolve) })
}
function dispose(record: GuestRecord): void {
record.slot.remove()
forget(record)
}
function forget(record: GuestRecord): void {
if (guests.get(record.tabId) === record) guests.delete(record.tabId)
}
function applyAllPlacements(): void {
for (const tabId of guests.keys()) applyPlacement(tabId)
}
function applyPlacement(tabId: string): void {
const record = guests.get(tabId)
if (!record) return
const placement = placements.get(tabId)
if (!placement || !layer || !placement.stage.isConnected) {
// Parked pages keep their last size, so hiding never reflows the page.
setShown(record, false)
return
}
const stage = placement.stage.getBoundingClientRect()
const origin = layer.getBoundingClientRect()
const ratio = window.devicePixelRatio || 1
// Whole device pixels keep text crisp, as the native view's bounds did.
const snap = (value: number) => Math.round(value * ratio) / ratio
Object.assign(record.slot.style, {
left: `${snap(stage.left - origin.left)}px`,
top: `${snap(stage.top - origin.top)}px`,
width: `${snap(stage.width)}px`,
height: `${snap(stage.height)}px`,
})
setShown(record, placement.visible && stage.width > 0 && stage.height > 0)
}
function setShown(record: GuestRecord, shown: boolean): void {
record.slot.style.visibility = shown ? 'visible' : 'hidden'
record.slot.dataset.shown = shown ? 'true' : 'false'
record.slot.style.pointerEvents = shown && !gesture ? 'auto' : 'none'
}
/**
* A pointer gesture that starts in the app (dragging a resize handle, a tab,
* a text selection) must keep receiving its moves and its release when the
* pointer crosses a page: a guest otherwise takes them, and the drag stalls
* with its button still "held". Pages ignore the pointer until it ends.
* A gesture that starts inside a page is never seen here, so pages keep it.
*/
function installGestureListeners(): void {
if (gestureListenersInstalled) return
gestureListenersInstalled = true
document.addEventListener('pointerdown', () => setGesture(true), true)
window.addEventListener('pointerup', () => setGesture(false), true)
window.addEventListener('pointercancel', () => setGesture(false), true)
window.addEventListener('blur', () => setGesture(false))
// A release outside the window never arrives; the next plain move ends it.
document.addEventListener('pointermove', (event) => {
if (gesture && event.buttons === 0) setGesture(false)
}, true)
}
function setGesture(active: boolean): void {
if (gesture === active) return
gesture = active
for (const record of guests.values()) {
setShown(record, record.slot.dataset.shown === 'true')
}
}
+28 -4
View File
@@ -1,21 +1,45 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { WorkspaceBrowserMenuOptions } from '../desktopHost/types'
const mock = vi.hoisted(() => ({ available: true, showMenu: vi.fn() }))
const mock = vi.hoisted(() => ({ available: true, showMenu: vi.fn(), close: vi.fn(async () => {}) }))
vi.mock('../desktopHost', () => ({
getDesktopHost: () => ({ capabilities: { workspaceBrowser: mock.available }, browser: { showMenu: mock.showMenu } }),
getDesktopHost: () => ({
capabilities: { workspaceBrowser: mock.available },
browser: { showMenu: mock.showMenu, close: mock.close },
}),
}))
import { workspaceBrowserHost } from './browserHost'
import { releaseWorkspaceBrowserTab, workspaceBrowserHost } from './browserHost'
import { ensureWorkspaceBrowserGuest, isWorkspaceBrowserGuestRegistered } from './browserGuests'
import { installFakeBrowserGuests } from '../../test/fakeBrowserGuests'
const options: WorkspaceBrowserMenuOptions = {
x: 20, y: 44, zoomFactor: 1, hasPage: true, canOpenExternal: true,
labels: { find: 'Find', print: 'Print', zoom: 'Zoom', zoomIn: 'Larger', zoomOut: 'Smaller', zoomReset: 'Reset', capture: 'Capture', pickElement: 'Pick', downloads: 'Downloads', history: 'History', openExternal: 'External' },
}
let disposeGuests: () => void
beforeEach(() => {
mock.available = true
mock.showMenu.mockReset()
mock.close.mockClear()
disposeGuests = installFakeBrowserGuests()
})
afterEach(() => disposeGuests())
it('ends a closed tab\'s page by removing its element and releasing it in the host', async () => {
await ensureWorkspaceBrowserGuest('wb-a', async () => {})
const [webview] = Array.from(document.querySelectorAll('webview'))
expect(isWorkspaceBrowserGuestRegistered('wb-a')).toBe(true)
releaseWorkspaceBrowserTab('wb-a')
// Removing the element is what destroys a `<webview>` guest.
expect(webview!.isConnected).toBe(false)
expect(isWorkspaceBrowserGuestRegistered('wb-a')).toBe(false)
expect(mock.close).toHaveBeenCalledWith('wb-a')
})
it('preserves selected actions and cancellation instead of discarding the host result', async () => {
+5 -9
View File
@@ -1,7 +1,7 @@
import { getDesktopHost } from '../desktopHost'
import { usePreviewSelectionStore } from '../../stores/previewSelectionStore'
import { disposeWorkspaceBrowserGuest } from './browserGuests'
import type {
WorkspaceBrowserBounds,
WorkspaceBrowserCaptureKind,
WorkspaceBrowserEvent,
WorkspaceBrowserFindOptions,
@@ -17,7 +17,8 @@ import type { PreviewHostMessage } from '../desktopHost'
* old singleton `previewBridge`: with one implicit page, showing a second link
* silently navigated the first, and unmounting the React surface closed it.
* Here React unmounting only stops *drawing* a page — the page dies when its
* tab is closed, and nothing else.
* tab is closed, and nothing else. Drawing itself is `browserGuests`' job; the
* host only adopts the page's `<webview>` and runs what happens inside it.
*
* On hosts without a native browser (plain desktop browser, H5) every method
* resolves to a typed failure instead of a silent success, so callers can show
@@ -54,7 +55,7 @@ export const workspaceBrowserHost = {
},
create: (
tabId: string,
options: { storageId: string; url?: string; bounds?: WorkspaceBrowserBounds; visible?: boolean },
options: { storageId: string; url?: string; webContentsId: number },
) => call((api) => api.create(tabId, options)),
navigate: (tabId: string, url: string) => call((api) => api.navigate(tabId, url)),
goBack: (tabId: string) => call((api) => api.goBack(tabId)),
@@ -62,8 +63,6 @@ export const workspaceBrowserHost = {
reload: (tabId: string, options?: { ignoreCache?: boolean }) =>
call((api) => api.reload(tabId, options)),
stop: (tabId: string) => call((api) => api.stop(tabId)),
setBounds: (tabId: string, bounds: WorkspaceBrowserBounds) =>
call((api) => api.setBounds(tabId, bounds)),
setVisible: (tabId: string, visible: boolean) => call((api) => api.setVisible(tabId, visible)),
setZoom: (tabId: string, factor: number) => call((api) => api.setZoom(tabId, factor)),
find: (tabId: string, text: string, options?: WorkspaceBrowserFindOptions) =>
@@ -71,10 +70,6 @@ export const workspaceBrowserHost = {
stopFind: (tabId: string) => call((api) => api.stopFind(tabId)),
capture: (tabId: string, kind: WorkspaceBrowserCaptureKind) =>
call((api) => api.capture(tabId, kind)),
snapshot: async (tabId: string): Promise<string | null> => {
const api = host()
return api ? api.snapshot(tabId) : null
},
message: (tabId: string, payload: PreviewHostMessage) =>
call((api) => api.message(tabId, payload)),
close: (tabId: string) => call((api) => api.close(tabId)),
@@ -88,6 +83,7 @@ export const workspaceBrowserHost = {
*/
export function releaseWorkspaceBrowserTab(browserTabId: string): void {
usePreviewSelectionStore.getState().clear(browserTabId)
disposeWorkspaceBrowserGuest(browserTabId)
void workspaceBrowserHost.close(browserTabId).catch(() => {})
}
+72
View File
@@ -28,6 +28,27 @@ const teamApiMocks = vi.hoisted(() => ({
sendMemberMessage: vi.fn(),
}))
const browserHostMocks = vi.hoisted(() => ({
available: false,
create: vi.fn(async () => ({ ok: true as const })),
setVisible: vi.fn(async () => ({ ok: true as const })),
message: vi.fn(async () => ({ ok: true as const })),
}))
vi.mock('../lib/workspace/browserHost', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/workspace/browserHost')>()
return {
...actual,
isWorkspaceBrowserAvailable: () => browserHostMocks.available,
workspaceBrowserHost: {
...actual.workspaceBrowserHost,
create: browserHostMocks.create,
setVisible: browserHostMocks.setVisible,
message: browserHostMocks.message,
},
}
})
vi.mock('../api/sessions', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/sessions')>()
return {
@@ -98,6 +119,7 @@ vi.mock('./TerminalSettings', () => ({
}))
import { ActiveSession } from './ActiveSession'
import { installFakeBrowserGuests, isBrowserPageShown } from '../test/fakeBrowserGuests'
import { createDefaultSessionState, useChatStore } from '../stores/chatStore'
import { useCLITaskStore } from '../stores/cliTaskStore'
import { useSessionStore } from '../stores/sessionStore'
@@ -133,9 +155,18 @@ beforeEach(() => {
teamApiMocks.sendMemberMessage.mockReset()
})
let disposeGuests: (() => void) | null = null
afterEach(() => {
cleanup()
disposeGuests?.()
disposeGuests = null
vi.useRealTimers()
vi.unstubAllGlobals()
browserHostMocks.available = false
for (const mock of [browserHostMocks.create, browserHostMocks.setVisible, browserHostMocks.message]) {
mock.mockClear()
}
viewportMocks.isMobile = false
useTabStore.setState({ tabs: [], activeTabId: null })
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
@@ -230,6 +261,47 @@ describe('ActiveSession task polling', () => {
expect(useChatStore.getState().sessions['__settings__']).toBeUndefined()
})
it('parks the workspace browser page while another page covers the retained session', async () => {
// ContentRouter hides a retained session with opacity only. The old native
// page ignored CSS and kept painting over the market page; the page must
// also stop acting as browser chrome (shortcuts) while it is off screen.
disposeGuests = installFakeBrowserGuests()
browserHostMocks.available = true
const sessionId = 'browser-retained-session'
useSessionStore.setState({
sessions: [{ id: sessionId, title: 'Browser', createdAt: '', modifiedAt: '', messageCount: 1, projectPath: '/repo', workDir: '/repo', workDirExists: true }],
activeSessionId: sessionId,
})
useTabStore.setState({
tabs: [
{ sessionId, title: 'Browser', type: 'session', status: 'idle' },
{ sessionId: '__market__', title: 'Market', type: 'market', status: 'idle' },
],
activeTabId: sessionId,
})
useChatStore.setState({ sessions: { [sessionId]: { ...createDefaultSessionState(), connectionState: 'connected', historyStatus: 'ready', historyHydrated: true } } })
const tabId = useWorkspaceStore.getState().openTarget(sessionId, { kind: 'browser', url: 'https://example.test/' })!
const tab = useWorkspaceStore.getState().getTab(sessionId, tabId)
if (tab?.kind !== 'browser') throw new Error('expected a browser tab')
useWorkspaceStore.getState().setLayout(sessionId, 'split')
const { rerender } = render(<ActiveSession sessionId={sessionId} active />)
await waitFor(() => expect(browserHostMocks.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, true))
expect(isBrowserPageShown(tab.browserTabId)).toBe(true)
act(() => useTabStore.getState().setActiveTab('__market__'))
rerender(<ActiveSession sessionId={sessionId} active={false} />)
expect(browserHostMocks.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, false)
expect(isBrowserPageShown(tab.browserTabId)).toBe(false)
act(() => useTabStore.getState().setActiveTab(sessionId))
rerender(<ActiveSession sessionId={sessionId} active />)
expect(browserHostMocks.setVisible).toHaveBeenLastCalledWith(tab.browserTabId, true)
expect(isBrowserPageShown(tab.browserTabId)).toBe(true)
// Hiding is presentation only; the page itself must survive the round trip.
expect(browserHostMocks.create).toHaveBeenCalledTimes(1)
})
it('shows cleaned worktrees as retained history and uses the source project for tools', () => {
const sessionId = 'cleaned-worktree-session'
useSettingsStore.setState({ locale: 'en' })
+6
View File
@@ -810,11 +810,17 @@ export function ActiveSession({ sessionId, active = true }: { sessionId?: string
onClick={() => useWorkspaceStore.getState().setLayout(activeTabId, 'hidden')} />
<span className="text-sm text-[var(--color-text-secondary)]">{t('sideChat.title')}</span>
</div>}
{/*
ContentRouter keeps this page mounted under settings/market with
only opacity-0. A native browser page ignores CSS, so the dock
must be told it is off screen or it stays attached over them.
*/}
<WorkspaceSurface
sessionId={activeTabId}
dock="side"
cwd={getSessionTerminalCwd(session) ?? ''}
reviewUnavailableReason={workspaceIsGitRepo === false ? t('workspace.launcher.reviewNeedsGit') : null}
visible={active}
/>
</aside>
</>
+1 -1
View File
@@ -3,7 +3,7 @@ import type { PreviewBrowserControlsMessage } from '../lib/desktopHost/types'
type ZoomControlsConfig = Omit<PreviewBrowserControlsMessage, 'type' | 'v'>
export type BrowserZoomAction = 'out' | 'in' | 'reset'
/** Lives inside the native page, above its contents; renderer z-index cannot cover a WebContentsView. */
/** Lives inside the page, above its contents, so it scrolls, zooms and captures with the page itself. */
export function createZoomControls(onAction: (action: BrowserZoomAction) => void) {
const host = document.createElement('div')
host.dataset.workspaceBrowserZoom = 'true'
-58
View File
@@ -1,58 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { useOverlayStore } from './overlayStore'
const reset = () => {
useOverlayStore.setState(useOverlayStore.getInitialState(), true)
}
describe('overlayStore', () => {
beforeEach(reset)
it('starts at count 0', () => {
expect(useOverlayStore.getState().count).toBe(0)
})
it('tracks snapshot overlays separately so ordinary modals keep immediate hiding', () => {
const { push, pop } = useOverlayStore.getState()
push(true)
push()
expect(useOverlayStore.getState()).toMatchObject({ count: 2, snapshotCount: 1 })
pop()
expect(useOverlayStore.getState()).toMatchObject({ count: 1, snapshotCount: 1 })
pop(true)
expect(useOverlayStore.getState()).toMatchObject({ count: 0, snapshotCount: 0 })
})
it('push increments by 1', () => {
useOverlayStore.getState().push()
expect(useOverlayStore.getState().count).toBe(1)
useOverlayStore.getState().push()
expect(useOverlayStore.getState().count).toBe(2)
})
it('pop decrements by 1', () => {
const { push, pop } = useOverlayStore.getState()
push()
push()
pop()
expect(useOverlayStore.getState().count).toBe(1)
})
it('pop at 0 stays clamped at 0', () => {
useOverlayStore.getState().pop()
expect(useOverlayStore.getState().count).toBe(0)
useOverlayStore.getState().pop()
expect(useOverlayStore.getState().count).toBe(0)
})
it('balances pushes and pops back to 0', () => {
const { push, pop } = useOverlayStore.getState()
push(); push(); push()
expect(useOverlayStore.getState().count).toBe(3)
pop(); pop(); pop()
expect(useOverlayStore.getState().count).toBe(0)
// extra pop is still clamped
pop()
expect(useOverlayStore.getState().count).toBe(0)
})
})
-44
View File
@@ -1,44 +0,0 @@
import { useEffect } from 'react'
import { create } from 'zustand'
/**
* Tracks how many fullscreen DOM overlays (image preview modals, etc.) are
* currently mounted. A native child webview (e.g. the in-app browser preview)
* always renders ABOVE the DOM, so it covers any fullscreen overlay; surfaces
* driving such webviews read this count and hide the webview while count > 0.
*
* Reusable: any fullscreen overlay can opt in via `push()` / `pop()` (or the
* helper hook `useSuppressBrowserOverlay()` below).
*/
type OverlayStore = {
count: number
snapshotCount: number
push: (preserveSnapshot?: boolean) => void
pop: (preserveSnapshot?: boolean) => void
}
export const useOverlayStore = create<OverlayStore>((set) => ({
count: 0,
snapshotCount: 0,
push: (preserveSnapshot = false) => set((state) => ({
count: state.count + 1,
snapshotCount: state.snapshotCount + (preserveSnapshot ? 1 : 0),
})),
pop: (preserveSnapshot = false) => set((state) => ({
count: Math.max(0, state.count - 1),
snapshotCount: Math.max(0, state.snapshotCount - (preserveSnapshot ? 1 : 0)),
})),
}))
/**
* Mount-scoped helper: increments the overlay count on mount, decrements on
* unmount. Pairs cleanly with strict-mode double-invoke because each effect
* run does exactly one inc + one dec.
*/
export function useSuppressBrowserOverlay({ preserveSnapshot = false }: { preserveSnapshot?: boolean } = {}) {
useEffect(() => {
const { push, pop } = useOverlayStore.getState()
push(preserveSnapshot)
return () => pop(preserveSnapshot)
}, [preserveSnapshot])
}
+49
View File
@@ -0,0 +1,49 @@
import { vi } from 'vitest'
import { setWorkspaceBrowserGuestLayer } from '../lib/workspace/browserGuests'
/**
* The real workspace browser page registry and layer, with only Electron's part
* simulated: every `<webview>` the registry inserts becomes a guest with an id
* that reports ready, and the page placeholder gets a size (jsdom lays nothing
* out, and a page is only drawn over a placeholder that has one).
*
* Call in `beforeEach`; call the returned function in `afterEach`.
*/
export function installFakeBrowserGuests(): () => void {
const layer = document.createElement('div')
layer.dataset.testid = 'fake-browser-guest-layer'
document.body.appendChild(layer)
setWorkspaceBrowserGuestLayer(layer)
const observer = new MutationObserver((records) => {
for (const record of records) {
for (const node of record.addedNodes) {
const webview = node instanceof HTMLElement ? node.querySelector('webview') : null
if (!webview) continue
const id = nextGuestId++
Object.assign(webview, { getWebContentsId: () => id })
webview.dispatchEvent(new Event('dom-ready'))
}
}
})
observer.observe(layer, { childList: true })
const original = HTMLElement.prototype.getBoundingClientRect
const measure = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.dataset.testid !== 'workspace-browser-placeholder') return original.call(this)
const box = { left: 0, top: 52, width: 800, height: 600 }
return { ...box, x: box.left, y: box.top, right: box.left + box.width, bottom: box.top + box.height, toJSON: () => box } as DOMRect
})
return () => {
observer.disconnect()
measure.mockRestore()
setWorkspaceBrowserGuestLayer(null)
layer.remove()
}
}
let nextGuestId = 1
/** Whether the page's `<webview>` is drawn over its placeholder right now. */
export function isBrowserPageShown(browserTabId: string): boolean {
const slot = document.querySelector<HTMLElement>(`[data-workspace-browser-slot="${browserTabId}"]`)
return slot?.dataset.shown === 'true'
}