diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index b79e64dc33..acad200923 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -18,6 +18,12 @@ export function Example() { The package owns component anatomy, behavior, accessibility, and stable variants. It does not own theme selection persistence, product state, routes, locale resources, or platform APIs. +Floating dialog/sheet overlays, menu popovers, select/combobox popups and tooltips declare +`data-openbitfun-native-webview-occlusion` on their rendered floating surface. +Native hosts use its visible bounds to temporarily hide overlapping child views; +the components do not call platform APIs. Custom product popovers should declare +the same marker on the floating element, including while its exit animation runs. + ## Voice calls `VoiceCallPanel` owns the complete compact call surface: navigation, particle diff --git a/design-system/packages/ui/src/components/Combobox/Combobox.tsx b/design-system/packages/ui/src/components/Combobox/Combobox.tsx index 576ddb8c95..1b11cda896 100644 --- a/design-system/packages/ui/src/components/Combobox/Combobox.tsx +++ b/design-system/packages/ui/src/components/Combobox/Combobox.tsx @@ -511,6 +511,7 @@ const CollectionPicker = forwardRef(function Collec className={styles.popover} data-openbitfun-component={multiple ? "multi-select-popup" : "combobox-popup"} data-openbitfun-part="popover" + data-openbitfun-native-webview-occlusion data-keyboard-open={keyboardOpen ? "true" : "false"} data-invalid={invalid ? "true" : "false"} data-placement={layout?.placement ?? placement} diff --git a/design-system/packages/ui/src/components/Dialog/Dialog.tsx b/design-system/packages/ui/src/components/Dialog/Dialog.tsx index 9cd8cf21a8..de889e6763 100644 --- a/design-system/packages/ui/src/components/Dialog/Dialog.tsx +++ b/design-system/packages/ui/src/components/Dialog/Dialog.tsx @@ -165,6 +165,7 @@ const OverlaySurface = forwardRef(function className={styles.overlay} data-openbitfun-component={kind} data-openbitfun-part="overlay" + data-openbitfun-native-webview-occlusion data-placement={placement} data-state={exiting ? "exiting" : "open"} > diff --git a/design-system/packages/ui/src/components/Menu/MenuPopover.tsx b/design-system/packages/ui/src/components/Menu/MenuPopover.tsx index 0efebd8468..f7e48a00b6 100644 --- a/design-system/packages/ui/src/components/Menu/MenuPopover.tsx +++ b/design-system/packages/ui/src/components/Menu/MenuPopover.tsx @@ -201,7 +201,7 @@ function MenuLevel({ items, open, phase, treeId, onClose, onBack, anchorRef, pos return <> { (menuRef as { current: HTMLDivElement | null }).current = node; }} className={classNames(styles.popup, className)} autoFocusFirstItem={open && autoFocusFirstItem && Boolean(layout)} tabIndex={-1} - style={{ ...layout?.style, ...style, visibility: layout ? undefined : "hidden" }} data-openbitfun-menu-tree={treeId} data-placement={layout?.placement ?? placement} data-state={phase} + style={{ ...layout?.style, ...style, visibility: layout ? undefined : "hidden" }} data-openbitfun-native-webview-occlusion data-openbitfun-menu-tree={treeId} data-placement={layout?.placement ?? placement} data-state={phase} aria-hidden={!open || undefined} {...(!open ? { inert: "" } : {})} onContextMenu={event => event.preventDefault()}> {items.map(item => item.separator ? : {item.icon} : undefined} shortcut={item.shortcut ? {item.shortcut} : undefined} tone={item.tone} role={item.role} checked={item.checked} disabled={item.disabled} aria-disabled={item.disabled || undefined} aria-haspopup={item.submenu?.length ? "menu" : undefined} aria-expanded={item.submenu?.length ? activeEntry?.id === item.id : undefined} diff --git a/design-system/packages/ui/src/components/Select/Select.tsx b/design-system/packages/ui/src/components/Select/Select.tsx index 5cbdc4f590..bb95c8a437 100644 --- a/design-system/packages/ui/src/components/Select/Select.tsx +++ b/design-system/packages/ui/src/components/Select/Select.tsx @@ -364,6 +364,7 @@ export const Select = forwardRef(function Select className={styles.popover} data-openbitfun-component="select-popup" data-openbitfun-part="popover" + data-openbitfun-native-webview-occlusion data-invalid={resolvedInvalid ? "true" : "false"} data-placement={layout?.placement ?? placement} data-size={size} diff --git a/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx b/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx index e251267935..1ab561e523 100644 --- a/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx +++ b/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx @@ -502,6 +502,7 @@ export function Tooltip({ ref={tooltipRef} id={tooltipId} role="tooltip" + data-openbitfun-native-webview-occlusion className={classNames(styles.root, className)} data-openbitfun-component="tooltip" data-openbitfun-placement={layout.placement} diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index 41201e92aa..697bb000d5 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -27,9 +27,9 @@ OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, a - Generated per-item interaction audit: `docs/interactive-capabilities/technical/product-control-open-audit.json` - Generated low-level audit map: `docs/interactive-capabilities/technical/tauri-command-map.json` -说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **643** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 +说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **644** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 -Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **643** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. +Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **644** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. ## 控制边界 / Control boundary diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index a797664bdc..59c27424b8 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -2,10 +2,10 @@ "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", "catalogDigest": "896281a3cd5cac2b50ec607988e04624224444911ddb2d58ba59a5d7d06493c5", - "commandCount": 643, + "commandCount": 644, "coverage": { - "commandCount": 643, - "documentedCommandCount": 607, + "commandCount": 644, + "documentedCommandCount": 608, "implementationCommandCount": 36, "implementationDigest": "c9bbc0f2ca3695d8c18cff285386edeba7bcf3b11120adb57509a80b09ee3297" }, @@ -712,6 +712,22 @@ "signature": "fn browser_get_url( app: tauri::AppHandle, request: WebviewLabelRequest, ) -> Result", "remoteWorkspacePolicy": "LocalOnly" }, + { + "id": "browser_webview_capture_preview", + "moduleId": "browser", + "capabilityId": "feature.browser", + "capabilityIds": [ + "feature.browser" + ], + "documentedItemIds": [ + "feature.browser:embedded-webview" + ], + "visibility": "documented", + "rustPath": "api::browser_api::browser_webview_capture_preview", + "sourceFile": "src/apps/desktop/src/api/browser_api.rs", + "signature": "fn browser_webview_capture_preview( app: tauri::AppHandle, request: WebviewLabelRequest, ) -> Result", + "remoteWorkspacePolicy": "LocalOnly" + }, { "id": "browser_webview_create", "moduleId": "browser", diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index 24e33576dc..43eec18c65 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -112,6 +112,10 @@ For window geometry recovery, legacy state compatibility, and snapshot persisten run `cargo test -p openbitfun-desktop --lib window_state_support::tests`. For the matching startup wiring contract, run `pnpm --dir src/web-ui run test:run src/app/startup/startupPerformanceContract.test.ts`. +For embedded browser preview encoding and target correlation, run +`cargo test -p openbitfun-desktop --lib api::browser_api::tests`. +After browser command registration changes, also run +`cargo test -p openbitfun-desktop --lib remote_workspace_policy`. After changing updater command registration, also run `cargo test -p openbitfun-desktop --lib remote_workspace_policy`. diff --git a/src/apps/desktop/src/api/browser_api.rs b/src/apps/desktop/src/api/browser_api.rs index f9b393cba2..a0050a650e 100644 --- a/src/apps/desktop/src/api/browser_api.rs +++ b/src/apps/desktop/src/api/browser_api.rs @@ -4,7 +4,7 @@ //! adapter so stream-specific initialization can run before page scripts. use openbitfun_core::agentic::tools::browser_control::BuiltInBrowserTarget; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; use tauri::Manager; @@ -384,6 +384,64 @@ pub struct WebviewLabelRequest { pub label: String, } +#[derive(Debug, Serialize)] +#[serde(tag = "status", rename_all = "camelCase")] +pub enum WebviewPreviewResponse { + Ready { + #[serde(rename = "dataUrl")] + data_url: String, + }, + Unsupported { + reason: String, + }, +} + +// Preview frames are ephemeral UI assets, never files or full-page captures. +fn encode_browser_preview(png_base64: &str) -> Result { + use base64::Engine as _; + let bytes = base64::engine::general_purpose::STANDARD + .decode(png_base64) + .map_err(|e| format!("decode browser preview failed: {e}"))?; + let image = image::load_from_memory_with_format(&bytes, image::ImageFormat::Png) + .map_err(|e| format!("decode browser preview PNG failed: {e}"))?; + let image = if image.width() > 1600 || image.height() > 1600 { + image.thumbnail(1600, 1600) + } else { + image + }; + let mut jpeg = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 80) + .encode_image(&image.to_rgb8()) + .map_err(|e| format!("encode browser preview JPEG failed: {e}"))?; + Ok(format!( + "data:image/jpeg;base64,{}", + base64::engine::general_purpose::STANDARD.encode(jpeg) + )) +} + +/// Capture only the controller's embedded page for DOM occlusion placeholders. +#[tauri::command] +pub async fn browser_webview_capture_preview( + app: tauri::AppHandle, + request: WebviewLabelRequest, +) -> Result { + validate_browser_label(&request.label)?; + let webview = find_browser_webview(&app, &request.label)?; + let png = match openbitfun_webdriver::platform::take_screenshot(webview, 1000).await { + Ok(png) => png, + Err(error) if error.error == "unsupported operation" => { + return Ok(WebviewPreviewResponse::Unsupported { + reason: error.message, + }); + } + Err(error) => return Err(error.message), + }; + let data_url = tokio::task::spawn_blocking(move || encode_browser_preview(&png)) + .await + .map_err(|e| format!("browser preview encoding task failed: {e}"))??; + Ok(WebviewPreviewResponse::Ready { data_url }) +} + #[tauri::command] pub async fn browser_webview_reload( app: tauri::AppHandle, @@ -437,6 +495,23 @@ pub async fn browser_get_url( mod tests { use super::*; + #[test] + fn preview_is_a_bounded_jpeg_and_rejects_invalid_data() { + use base64::Engine as _; + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgb8(2000, 1000) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let encoded = base64::engine::general_purpose::STANDARD.encode(png.into_inner()); + let preview = encode_browser_preview(&encoded).unwrap(); + let bytes = base64::engine::general_purpose::STANDARD + .decode(preview.strip_prefix("data:image/jpeg;base64,").unwrap()) + .unwrap(); + let image = image::load_from_memory(&bytes).unwrap(); + assert_eq!((image.width(), image.height()), (1600, 800)); + assert!(encode_browser_preview("invalid").is_err()); + } + #[test] fn open_request_correlation_requires_the_exact_active_target() { let mut registry = BrowserTargetRegistry::default(); diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 7f4202200c..70b73ea722 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1821,6 +1821,7 @@ pub async fn run() { api::browser_api::browser_webview_navigate, api::browser_api::browser_webview_reload, api::browser_api::browser_webview_set_bounds, + api::browser_api::browser_webview_capture_preview, api::browser_api::browser_webview_set_agent_target_state, api::browser_api::browser_get_url, api::html_preview_api::html_preview_create, diff --git a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json index c69940ecca..6e8f9e3f5c 100644 --- a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json +++ b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "digest": "fnv1a64:57f26e05119c5457", + "digest": "fnv1a64:6c2c8c606588ee3f", "retiredCommandPrefixes": [ { "prefix": "lsp_", @@ -562,6 +562,18 @@ "reason": "the CLI peer host has no handler for this command" } }, + { + "id": "browser_webview_capture_preview", + "surface": "tauri_command", + "remoteWorkspace": "LocalOnly", + "peer": { + "kind": "controller_local" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the controller keeps this command; peer hosts refuse it before dispatch" + } + }, { "id": "browser_webview_create", "surface": "tauri_command", diff --git a/src/crates/contracts/product-domains/src/remote_surface/table.rs b/src/crates/contracts/product-domains/src/remote_surface/table.rs index 571114d43c..22015fa4b8 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/table.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/table.rs @@ -127,6 +127,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("browser_control_list_browsers", LocalOnly, Proxied, CLI_NO_BROWSER_OR_OS_AUTOMATION), op("browser_control_restart_with_cdp", LocalOnly, Proxied, CLI_NO_BROWSER_OR_OS_AUTOMATION), op("browser_get_url", LocalOnly, Proxied, CLI_NOT_IMPLEMENTED), + op("browser_webview_capture_preview", LocalOnly, ControllerLocal, REFUSED), op("browser_webview_create", LocalOnly, ControllerLocal, REFUSED), op("browser_webview_eval", LocalOnly, ControllerLocal, REFUSED), op("browser_webview_navigate", LocalOnly, ControllerLocal, REFUSED), diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 141f9c166f..54d1efd09d 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -2474,6 +2474,8 @@ }, "evidence": [ "command:browser_webview_set_bounds", + "command:browser_webview_capture_preview", + "source:src/web-ui/src/app/scenes/browser/browserPreviewCache.ts#export class BrowserPreviewCache", "source:src/web-ui/src/app/scenes/browser/BrowserPanel.tsx#sandbox=", "source:src/web-ui/src/app/scenes/browser/browserInspectorScript.ts#BLANK_TARGET_INTERCEPT_SCRIPT" ] diff --git a/src/web-ui/src/app/scenes/browser/BrowserPanel.tsx b/src/web-ui/src/app/scenes/browser/BrowserPanel.tsx index e8935a6bcd..3884e852f2 100644 --- a/src/web-ui/src/app/scenes/browser/BrowserPanel.tsx +++ b/src/web-ui/src/app/scenes/browser/BrowserPanel.tsx @@ -15,6 +15,7 @@ import { useContextStore } from '@/shared/context-system'; import type { WebElementContext } from '@/shared/types/context'; import { createInspectorScript, CANCEL_INSPECTOR_SCRIPT } from './browserInspectorScript'; import { useEmbeddedBrowserWebview } from './useEmbeddedBrowserWebview'; +import { BrowserPreview } from './BrowserPreview'; import './BrowserPanel.scss'; const log = createLogger('BrowserPanel'); @@ -237,10 +238,13 @@ const BrowserPanel: React.FC = ({ isActive, initialUrl, openR className="browser-panel__webview-host" data-webview-label={webviewLabel} > -
- - {currentUrl} -
+ {!browser.previewUrl && ( +
+ + {currentUrl} +
+ )} + )} diff --git a/src/web-ui/src/app/scenes/browser/BrowserPreview.appearance.ts b/src/web-ui/src/app/scenes/browser/BrowserPreview.appearance.ts new file mode 100644 index 0000000000..3bb6eeca26 --- /dev/null +++ b/src/web-ui/src/app/scenes/browser/BrowserPreview.appearance.ts @@ -0,0 +1,6 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const browserPreviewAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'browser-preview', + parts: [{ id: 'image', propertyProfile: 'layout', visualRole: 'content' }], +}; diff --git a/src/web-ui/src/app/scenes/browser/BrowserPreview.scss b/src/web-ui/src/app/scenes/browser/BrowserPreview.scss new file mode 100644 index 0000000000..137d2402bc --- /dev/null +++ b/src/web-ui/src/app/scenes/browser/BrowserPreview.scss @@ -0,0 +1,12 @@ +.browser-preview { + position: absolute; + display: block; + max-width: none; + margin: 0; + padding: 0; + border: 0; + border-radius: 0; + object-fit: fill; + pointer-events: none; + user-select: none; +} diff --git a/src/web-ui/src/app/scenes/browser/BrowserPreview.tsx b/src/web-ui/src/app/scenes/browser/BrowserPreview.tsx new file mode 100644 index 0000000000..d50b33a55c --- /dev/null +++ b/src/web-ui/src/app/scenes/browser/BrowserPreview.tsx @@ -0,0 +1,18 @@ +import './BrowserPreview.scss'; +import type { BrowserViewportBounds } from './browserViewportGeometry'; + +/** Decorative frozen frame, below all DOM floating surfaces and native content. */ +export function BrowserPreview({ src, bounds }: { src: string | null; bounds: BrowserViewportBounds | null }) { + return src && bounds ? ( + + ) : null; +} diff --git a/src/web-ui/src/app/scenes/browser/BrowserScene.tsx b/src/web-ui/src/app/scenes/browser/BrowserScene.tsx index dbd26fdac4..987bcb6f51 100644 --- a/src/web-ui/src/app/scenes/browser/BrowserScene.tsx +++ b/src/web-ui/src/app/scenes/browser/BrowserScene.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { createLogger } from '@/shared/utils/logger'; import { useSceneStore } from '@/app/stores/sceneStore'; import { useEmbeddedBrowserWebview } from './useEmbeddedBrowserWebview'; +import { BrowserPreview } from './BrowserPreview'; import './BrowserScene.scss'; const log = createLogger('BrowserScene'); @@ -103,10 +104,13 @@ const BrowserScene: React.FC = () => { className="browser-scene__webview-host" data-webview-label={browser.webviewLabel} > -
- - {browser.currentUrl} -
+ {!browser.previewUrl && ( +
+ + {browser.currentUrl} +
+ )} + )} diff --git a/src/web-ui/src/app/scenes/browser/README.md b/src/web-ui/src/app/scenes/browser/README.md new file mode 100644 index 0000000000..c8ad9b77b7 --- /dev/null +++ b/src/web-ui/src/app/scenes/browser/README.md @@ -0,0 +1,42 @@ +# Embedded browser occlusion + +Both `BrowserPanel` and `BrowserScene` use `useEmbeddedBrowserWebview` to own the +native child view. All show/hide/focus paths go through `nativeWebviewVisibility`. +DOM floating surfaces declare `data-openbitfun-native-webview-occlusion`; their +overlapping bounds suppress native visibility without destroying the page. + +`BrowserPreviewCache` retains one decoded JPEG in memory beneath the native view. +It samples only while the native page and document are visible, waits one second +between completed captures, and allows at most one request in flight. Hiding never +waits for a screenshot. The resulting placeholder is decorative and cannot receive +input. Navigation, native viewport size changes and target replacement invalidate +the frame and reject stale responses. Frames are not persisted. + +Native viewport edges are aligned to physical pixels using the controller's device +pixel ratio. The preview uses that same applied rectangle relative to its DOM host, +with square corners and explicit dimensions; generic image styles must not alter +its geometry. A one-physical-pixel change must reach the native view even at fractional DPI. + +The desktop `browser_webview_capture_preview` command captures the specific child +view, bounds the longest edge to 1600 pixels, and encodes JPEG at quality 80 off +the async runtime worker. Windows and macOS use the existing WebView screenshot +adapter. Linux currently returns an explicit unsupported response. Unsupported or +older hosts retain the normal placeholder; transient capture failures back off to +five seconds and keep the last valid frame. + +The command is controller-local in Peer Device Mode and never requests a screenshot +from the peer. SSH/Docker workspaces do not change ownership of this local UI view. +The web renderer keeps its existing iframe path. This command adds no preview +transport to mobile/IM remote control or detached jobs; peer hosts refuse it. + +Focused verification: + +```sh +pnpm --dir src/web-ui exec vitest run src/app/scenes/browser/browserPreviewCache.test.ts src/app/scenes/browser/nativeWebviewVisibility.test.ts src/app/scenes/browser/useEmbeddedBrowserWebview.test.tsx src/infrastructure/api/adapters/peer-device-adapter.test.ts +``` + +Manual checks: after rebuilding the desktop host, open a browser and let it settle, +then open session overview or a tab context menu over it. The last page frame should +remain in the uncovered region; closing the popup should restore the live page. +Also check navigation, resizing, multiple browser tabs and a video page. The frozen +image is not evidence that media playback or page execution has paused. diff --git a/src/web-ui/src/app/scenes/browser/browserPreviewCache.test.ts b/src/web-ui/src/app/scenes/browser/browserPreviewCache.test.ts new file mode 100644 index 0000000000..02299d79f6 --- /dev/null +++ b/src/web-ui/src/app/scenes/browser/browserPreviewCache.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { BrowserPreviewCache, type BrowserPreviewResponse } from './browserPreviewCache'; + +let cache: BrowserPreviewCache; +let capture: ReturnType Promise>>; +let prepare: ReturnType Promise>>; +let onFrame: ReturnType; +let onError: ReturnType; +const frame = (id: string): BrowserPreviewResponse => ({ status: 'ready', dataUrl: id }); + +beforeEach(() => { + vi.useFakeTimers(); + capture = vi.fn(async () => frame('first')); + prepare = vi.fn(async () => {}); + onFrame = vi.fn(); + onError = vi.fn(); + cache = new BrowserPreviewCache({ capture, prepare, onFrame, onError }); + cache.setTarget('browser-a'); + onFrame.mockClear(); +}); +afterEach(() => { cache.dispose(); vi.useRealTimers(); }); + +describe('browser preview cache', () => { + it('samples only visible native pages and retains a decoded frame while hidden', async () => { + await vi.advanceTimersByTimeAsync(2000); + expect(capture).not.toHaveBeenCalled(); + cache.setVisible(true); + await vi.advanceTimersByTimeAsync(0); + expect(onFrame).toHaveBeenLastCalledWith('first'); + expect(prepare).toHaveBeenCalledWith('first'); + await vi.advanceTimersByTimeAsync(1000); + expect(capture).toHaveBeenCalledTimes(2); + cache.setVisible(false); + await vi.advanceTimersByTimeAsync(5000); + expect(capture).toHaveBeenCalledTimes(2); + expect(onFrame).toHaveBeenLastCalledWith('first'); + }); + + it('never overlaps capture requests even across rapid hide/show transitions', async () => { + let finish!: (value: BrowserPreviewResponse) => void; + capture.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + cache.setVisible(true); + await vi.advanceTimersByTimeAsync(0); + cache.setVisible(false); + cache.setVisible(true); + await vi.advanceTimersByTimeAsync(5000); + expect(capture).toHaveBeenCalledOnce(); + finish(frame('latest')); + await vi.advanceTimersByTimeAsync(0); + expect(onFrame).toHaveBeenLastCalledWith('latest'); + }); + + it('discards in-flight frames after navigation/resize and captures the new generation', async () => { + let finish!: (value: BrowserPreviewResponse) => void; + capture.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + cache.setVisible(true); + await vi.advanceTimersByTimeAsync(0); + cache.invalidate(); + finish(frame('obsolete')); + await vi.advanceTimersByTimeAsync(1); + expect(onFrame).not.toHaveBeenCalledWith('obsolete'); + expect(capture).toHaveBeenCalledTimes(2); + expect(onFrame).toHaveBeenLastCalledWith('first'); + }); + + it('rejects frames decoded after switching targets or disposing', async () => { + let finishDecode!: () => void; + prepare.mockImplementationOnce(() => new Promise(resolve => { finishDecode = resolve; })); + cache.setVisible(true); + await vi.advanceTimersByTimeAsync(0); + cache.setTarget('browser-b'); + cache.dispose(); + finishDecode(); + await vi.advanceTimersByTimeAsync(5000); + expect(onFrame).not.toHaveBeenCalledWith('first'); + expect(capture).toHaveBeenCalledOnce(); + }); + + it.each(['unsupported-platform', 'older-host'])('stops optional sampling for %s', async mode => { + if (mode === 'older-host') capture.mockRejectedValue(new Error('Command browser_webview_capture_preview not found')); + else capture.mockResolvedValue({ status: 'unsupported', reason: 'Unavailable on this platform' }); + cache.setVisible(true); + await vi.advanceTimersByTimeAsync(0); + cache.setVisible(false); + cache.setVisible(true); + cache.invalidate(); + await vi.advanceTimersByTimeAsync(10000); + expect(capture).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledOnce(); + }); + + it('keeps the cached frame on failure and backs off without flooding logs', async () => { + cache.setVisible(true); + await vi.advanceTimersByTimeAsync(0); + capture.mockRejectedValue(new Error('Capture timed out')); + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(4000); + expect(capture).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(6000); + expect(capture).toHaveBeenCalledTimes(4); + expect(onError).toHaveBeenCalledOnce(); + expect(onFrame).toHaveBeenLastCalledWith('first'); + }); +}); diff --git a/src/web-ui/src/app/scenes/browser/browserPreviewCache.ts b/src/web-ui/src/app/scenes/browser/browserPreviewCache.ts new file mode 100644 index 0000000000..cdda5f8be2 --- /dev/null +++ b/src/web-ui/src/app/scenes/browser/browserPreviewCache.ts @@ -0,0 +1,101 @@ +export type BrowserPreviewResponse = + | { status: 'ready'; dataUrl: string } + | { status: 'unsupported'; reason: string }; + +interface PreviewOptions { + capture: (label: string) => Promise; + prepare: (dataUrl: string) => Promise; + onFrame: (dataUrl: string | null) => void; + onError: (error: unknown) => void; +} + +/** A single in-memory frame; capture work never holds up native show/hide. */ +export class BrowserPreviewCache { + private label = ''; + private generation = 0; + private visible = false; + private disposed = false; + private unsupported = false; + private inFlight = false; + private warned = false; + private timer: ReturnType | undefined; + + constructor(private readonly options: PreviewOptions) {} + + setTarget(label: string): void { + if (this.label === label) return; + this.label = label; + this.invalidate(); + } + + invalidate(): void { + this.generation++; + this.options.onFrame(null); + this.clearTimer(); + this.schedule(0); + } + + setVisible(visible: boolean): void { + if (this.visible === visible) return; + this.visible = visible; + this.clearTimer(); + if (visible) this.schedule(0); + } + + dispose(): void { + this.disposed = true; + this.generation++; + this.clearTimer(); + } + + resume(): void { + this.disposed = false; + this.schedule(0); + } + + private clearTimer(): void { + clearTimeout(this.timer); + this.timer = undefined; + } + + private schedule(delay: number): void { + if (this.disposed || !this.visible || !this.label || this.unsupported || this.inFlight || this.timer !== undefined) return; + this.timer = setTimeout(() => { this.timer = undefined; void this.capture(); }, delay); + } + + private async capture(): Promise { + const generation = this.generation; + this.inFlight = true; + let delay = 1000; + try { + const response = await this.options.capture(this.label); + if (this.disposed || generation !== this.generation) return; + if (response.status === 'unsupported') { + this.unsupported = true; + this.options.onError(response.reason); + return; + } + await this.options.prepare(response.dataUrl); + if (!this.disposed && generation === this.generation) { + this.options.onFrame(response.dataUrl); + this.warned = false; + } + } catch (error) { + if (this.disposed || generation !== this.generation) return; + // Older local hosts may not expose this optional command yet. + this.unsupported = /unknown command|command.*not found|unsupported/i.test(String(error)); + if (!this.warned) this.options.onError(error); + this.warned = true; + delay = 5000; + } finally { + this.inFlight = false; + this.schedule(generation === this.generation ? delay : 0); + } + } +} + +export async function prepareBrowserPreview(dataUrl: string): Promise { + const image = new Image(); + image.src = dataUrl; + await image.decode(); +} diff --git a/src/web-ui/src/app/scenes/browser/browserViewportGeometry.ts b/src/web-ui/src/app/scenes/browser/browserViewportGeometry.ts new file mode 100644 index 0000000000..f09ca98ac9 --- /dev/null +++ b/src/web-ui/src/app/scenes/browser/browserViewportGeometry.ts @@ -0,0 +1,11 @@ +export type BrowserViewportBounds = { left: number; top: number; width: number; height: number }; + +/** Share one physical-pixel rectangle between the native view and its DOM frame. */ +export function alignBrowserViewport(rect: BrowserViewportBounds, scale: number): BrowserViewportBounds { + const dpr = Number.isFinite(scale) && scale > 0 ? scale : 1; + const left = Math.round(rect.left * dpr); + const top = Math.round(rect.top * dpr); + const right = Math.round((rect.left + rect.width) * dpr); + const bottom = Math.round((rect.top + rect.height) * dpr); + return { left: left / dpr, top: top / dpr, width: (right - left) / dpr, height: (bottom - top) / dpr }; +} diff --git a/src/web-ui/src/app/scenes/browser/nativeWebviewVisibility.test.ts b/src/web-ui/src/app/scenes/browser/nativeWebviewVisibility.test.ts new file mode 100644 index 0000000000..890379c998 --- /dev/null +++ b/src/web-ui/src/app/scenes/browser/nativeWebviewVisibility.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createNativeWebviewVisibility, hasNativeWebviewOccluder } from './nativeWebviewVisibility'; + +afterEach(() => { + document.body.replaceChildren(); + vi.restoreAllMocks(); +}); + +function element(rect: DOMRect, occluder = false) { + const node = document.createElement('div'); + if (occluder) node.setAttribute('data-openbitfun-native-webview-occlusion', ''); + node.getBoundingClientRect = () => rect; + document.body.append(node); + return node; +} + +describe('native view occlusion geometry', () => { + it('tracks multiple overlays and ignores hidden, non-overlapping and ancestor surfaces', () => { + const bounds = new DOMRect(0, 0, 100, 100); + const viewport = element(bounds); + const first = element(new DOMRect(80, 80, 100, 100), true); + const second = element(new DOMRect(10, 10, 20, 20), true); + expect(hasNativeWebviewOccluder(viewport, bounds)).toBe(true); + first.remove(); + expect(hasNativeWebviewOccluder(viewport, bounds)).toBe(true); + second.style.visibility = 'hidden'; + expect(hasNativeWebviewOccluder(viewport, bounds)).toBe(false); + second.style.visibility = 'visible'; + second.style.opacity = '0'; + expect(hasNativeWebviewOccluder(viewport, bounds)).toBe(true); + second.style.opacity = '1'; + second.getBoundingClientRect = () => new DOMRect(100, 0, 20, 20); + expect(hasNativeWebviewOccluder(viewport, bounds)).toBe(false); + second.getBoundingClientRect = () => bounds; + second.append(viewport); + expect(hasNativeWebviewOccluder(viewport, bounds)).toBe(false); + }); +}); + +describe('native view visibility serialization', () => { + it('rechecks occlusion before focusing when show is already in flight', async () => { + let visible = true; + let finishShow!: () => void; + const calls: string[] = []; + const view = { + show: vi.fn(() => new Promise(resolve => { calls.push('show'); finishShow = resolve; })), + hide: vi.fn(async () => { calls.push('hide'); }), + setFocus: vi.fn(async () => { calls.push('focus'); }), + }; + const sync = createNativeWebviewVisibility(() => visible); + const showing = sync(view, true); + await vi.waitFor(() => expect(view.show).toHaveBeenCalledOnce()); + visible = false; + const hiding = sync(view); + finishShow(); + await Promise.all([showing, hiding]); + expect(calls).toEqual(['show', 'hide']); + }); + + it('rechecks activation after a pending hide and suppresses redundant native calls', async () => { + let visible = false; + let finishHide!: () => void; + const calls: string[] = []; + const view = { + show: async () => { calls.push('show'); }, + hide: () => new Promise(resolve => { calls.push('hide'); finishHide = resolve; }), + setFocus: async () => { calls.push('focus'); }, + }; + const sync = createNativeWebviewVisibility(() => visible); + const hiding = sync(view); + await vi.waitFor(() => expect(calls).toEqual(['hide'])); + visible = true; + const showing = sync(view); + finishHide(); + await Promise.all([hiding, showing]); + await sync(view); + expect(calls).toEqual(['hide', 'show']); + }); +}); diff --git a/src/web-ui/src/app/scenes/browser/nativeWebviewVisibility.ts b/src/web-ui/src/app/scenes/browser/nativeWebviewVisibility.ts new file mode 100644 index 0000000000..d877ee0c31 --- /dev/null +++ b/src/web-ui/src/app/scenes/browser/nativeWebviewVisibility.ts @@ -0,0 +1,72 @@ +// Native child views are outside the document's CSS stacking contexts. +// Keep legacy selectors while shared and custom floating surfaces adopt the marker. +export const NATIVE_WEBVIEW_OCCLUSION_SELECTOR = [ + '[data-openbitfun-native-webview-occlusion]', + "[data-openbitfun-component='dialog'][data-openbitfun-part='overlay']", + "[data-openbitfun-component='sheet'][data-openbitfun-part='overlay']", + '.canvas-mission-control', + "[data-openbitfun-product-component='context-menu'][data-openbitfun-product-part='root']", +].join(', '); + +type Rectangle = Pick; + +export function rectanglesIntersect(first: Rectangle, second: Rectangle): boolean { + return second.right > first.left && second.left < first.right + && second.bottom > first.top && second.top < first.bottom; +} + +export function hasNativeWebviewOccluder(viewport: HTMLElement, bounds: Rectangle): boolean { + const doc = viewport.ownerDocument; + return Array.from(doc.querySelectorAll(NATIVE_WEBVIEW_OCCLUSION_SELECTOR)).some(overlay => { + // A browser hosted inside a dialog must not be hidden by its own host. + if (overlay.contains(viewport)) return false; + const rect = overlay.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0 || !rectanglesIntersect(bounds, rect)) return false; + const style = doc.defaultView!.getComputedStyle(overlay); + // Opacity can be zero on the first frame of an entering floating surface. + // Reserve its bounds through the animation until it is hidden or unmounted. + return style.display !== 'none' && style.visibility !== 'hidden' + && style.visibility !== 'collapse'; + }); +} + +interface NativeView { + show(): Promise; + hide(): Promise; + setFocus(): Promise; +} + +/** Serialize native calls and re-read current state after every async boundary. */ +export function createNativeWebviewVisibility( + shouldShow: (view: NativeView) => boolean, + onVisibilityChanged?: (view: NativeView, visible: boolean) => void, +) { + const pending = new WeakMap>(); + const applied = new WeakMap(); + return async (view: NativeView, focus = false): Promise => { + const operation = (pending.get(view) ?? Promise.resolve()).catch(() => {}).then(async () => { + const reconcile = async () => { + let desired = shouldShow(view); + while (applied.get(view) !== desired) { + if (desired) { + await view.show(); + onVisibilityChanged?.(view, true); + } else { + onVisibilityChanged?.(view, false); + await view.hide(); + } + applied.set(view, desired); + desired = shouldShow(view); + } + }; + await reconcile(); + if (focus && shouldShow(view)) { + await view.setFocus(); + // A popup or tab switch may have arrived while native focus was pending. + await reconcile(); + } + }); + pending.set(view, operation); + await operation; + }; +} diff --git a/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.test.tsx b/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.test.tsx new file mode 100644 index 0000000000..98e6d43f1a --- /dev/null +++ b/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.test.tsx @@ -0,0 +1,189 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { useEmbeddedBrowserWebview } from './useEmbeddedBrowserWebview'; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn<(...args: unknown[]) => Promise>(async () => {}), + view: { label: 'test-browser', show: vi.fn(async () => {}), hide: vi.fn(async () => {}), + close: vi.fn(async () => {}), setFocus: vi.fn(async () => {}) }, +})); +vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ api: { invoke: mocks.invoke } })); +vi.mock('@tauri-apps/api/webview', () => ({ Webview: { getByLabel: async () => mocks.view } })); +vi.mock('@tauri-apps/api/event', () => ({ listen: async () => () => {} })); + +const log = { warn: vi.fn(), error: vi.fn() }; +let browser: ReturnType; +function Harness({ active }: { active: boolean }) { + browser = useEmbeddedBrowserWebview({ defaultUrl: 'https://example.com', isVisible: active, labelPrefix: 'test', log }); + return
; +} + +let root: Root; +let container: HTMLDivElement; +let viewportBounds: DOMRect; +beforeEach(() => { + vi.clearAllMocks(); + mocks.invoke.mockImplementation(async () => {}); + vi.stubGlobal('__TAURI__', {}); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + viewportBounds = new DOMRect(0, 0, 200, 200); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => viewportBounds); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + document.body.replaceChildren(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +async function render(active = true) { + await act(async () => root.render()); +} +async function mutate(change: () => void) { + await act(async () => { change(); }); +} +function overlay() { + const node = document.createElement('div'); + node.setAttribute('data-openbitfun-native-webview-occlusion', ''); + node.getBoundingClientRect = () => new DOMRect(50, 50, 100, 100); + return node; +} + +it('keeps creation, navigation, resize and activation hidden while any floating panel overlaps', async () => { + const first = overlay(); + const second = overlay(); + document.body.append(first, second); + await render(); + expect(mocks.view.show).not.toHaveBeenCalled(); + expect(mocks.view.setFocus).not.toHaveBeenCalled(); + + await act(async () => browser.loadUrl('https://example.com/next')); + await mutate(() => { viewportBounds = new DOMRect(0, 0, 250, 250); window.dispatchEvent(new Event('resize')); }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 180)); }); + await render(false); + await render(true); + expect(mocks.view.show).not.toHaveBeenCalled(); + expect(mocks.view.setFocus).not.toHaveBeenCalled(); + await mutate(() => first.remove()); + expect(mocks.view.show).not.toHaveBeenCalled(); + await mutate(() => second.remove()); + expect(mocks.view.show).toHaveBeenCalledOnce(); + expect(mocks.view.setFocus).not.toHaveBeenCalled(); +}); + +it('responds to an already mounted panel becoming visible and restores only the active tab', async () => { + const panel = overlay(); + panel.style.visibility = 'hidden'; + document.body.append(panel); + await render(); + expect(mocks.view.show).toHaveBeenCalledOnce(); + await mutate(() => { panel.style.visibility = 'visible'; }); + expect(mocks.view.hide).toHaveBeenCalledOnce(); + await render(false); + await mutate(() => panel.remove()); + expect(mocks.view.show).toHaveBeenCalledOnce(); + await render(true); + expect(mocks.view.show).toHaveBeenCalledTimes(2); +}); + +it('does not show or focus when navigation finishes after deactivation', async () => { + await render(); + mocks.view.show.mockClear(); + mocks.view.setFocus.mockClear(); + let finishNavigation!: () => void; + mocks.invoke.mockImplementation(async (...args) => { + if (args[0] === 'browser_webview_navigate') await new Promise(resolve => { finishNavigation = resolve; }); + }); + let navigation!: Promise; + await act(async () => { navigation = browser.loadUrl('https://example.com/slow'); }); + await render(false); + await act(async () => { finishNavigation(); await navigation; }); + expect(mocks.view.show).not.toHaveBeenCalled(); + expect(mocks.view.setFocus).not.toHaveBeenCalled(); +}); + +it('releases temporary toolbar suppression after activation settles', async () => { + await render(); + await mutate(() => window.dispatchEvent(new Event('toolbar-mode-activating'))); + expect(mocks.view.hide).toHaveBeenCalledOnce(); + await act(async () => browser.loadUrl('https://example.com/next')); + expect(mocks.view.show).toHaveBeenCalledOnce(); + await mutate(() => window.dispatchEvent(new Event('toolbar-mode-activation-finished'))); + expect(mocks.view.show).toHaveBeenCalledTimes(2); +}); + +it('keeps the old native rectangle occluded until the delayed bounds update completes', async () => { + const panel = overlay(); + document.body.append(panel); + await render(); + let finishBounds!: () => void; + mocks.invoke.mockImplementation(async (...args) => { + if (args[0] === 'browser_webview_set_bounds') await new Promise(resolve => { finishBounds = resolve; }); + }); + await mutate(() => { + viewportBounds = new DOMRect(500, 0, 200, 200); + window.dispatchEvent(new Event('resize')); + }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 180)); }); + expect(mocks.view.show).not.toHaveBeenCalled(); + await act(async () => finishBounds()); + expect(mocks.view.show).toHaveBeenCalledOnce(); +}); + +it('does not invoke native APIs in the web surface', async () => { + Reflect.deleteProperty(window, '__TAURI__'); + await render(); + await act(async () => browser.loadUrl('https://example.com/next')); + await mutate(() => document.body.append(overlay())); + expect(mocks.invoke).not.toHaveBeenCalled(); + expect(mocks.view.show).not.toHaveBeenCalled(); + expect(mocks.view.hide).not.toHaveBeenCalled(); +}); + +it('aligns native and preview edges at fractional DPI and applies a one-physical-pixel move', async () => { + vi.stubGlobal('devicePixelRatio', 1.5); + viewportBounds = new DOMRect(912.333374, 128.333343, 747, 938.333374); + await render(); + const creation = mocks.invoke.mock.calls.find(call => call[0] === 'browser_webview_create')!; + const { request } = creation[1] as { request: { x: number; y: number; width: number; height: number } }; + expect(request.x * 1.5).toBeCloseTo(1369); + expect(request.y * 1.5).toBeCloseTo(193); + expect((request.x + request.width) * 1.5).toBeCloseTo(2489); + expect((request.y + request.height) * 1.5).toBeCloseTo(1600); + expect(viewportBounds.left + browser.previewBounds!.left).toBeCloseTo(request.x); + expect(viewportBounds.top + browser.previewBounds!.top).toBeCloseTo(request.y); + expect(browser.previewBounds!.width).toBe(request.width); + expect(browser.previewBounds!.height).toBe(request.height); + + mocks.invoke.mockClear(); + await mutate(() => { + viewportBounds = new DOMRect(viewportBounds.left, viewportBounds.top + 2 / 3, viewportBounds.width, viewportBounds.height); + window.dispatchEvent(new Event('resize')); + }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 180)); }); + const update = mocks.invoke.mock.calls.find(call => call[0] === 'browser_webview_set_bounds')!; + expect(update).toBeDefined(); + const updated = (update[1] as { request: { y: number } }).request; + expect(updated.y * 1.5).toBeCloseTo(194); + expect(viewportBounds.top + browser.previewBounds!.top).toBeCloseTo(updated.y); +}); + +it('keeps the decoded preview beneath a popup without waiting for the next capture', async () => { + vi.stubGlobal('Image', class { src = ''; decode = async () => {}; }); + mocks.invoke.mockImplementation(async command => command === 'browser_webview_capture_preview' + ? { status: 'ready', dataUrl: 'data:image/jpeg;base64,preview' } : undefined); + await render(); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); + expect(browser.previewUrl).toBe('data:image/jpeg;base64,preview'); + await mutate(() => document.body.append(overlay())); + expect(mocks.view.hide).toHaveBeenCalledOnce(); + expect(browser.previewUrl).toBe('data:image/jpeg;base64,preview'); + await act(async () => browser.loadUrl('https://example.com/new-page')); + expect(browser.previewUrl).toBeNull(); +}); diff --git a/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts b/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts index fa26697408..b4bdc405c1 100644 --- a/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts +++ b/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts @@ -2,21 +2,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { BLANK_TARGET_INTERCEPT_SCRIPT } from './browserInspectorScript'; import { STREAM_RENDER_OPTIMIZATION_SCRIPT } from './browserStreamPerformanceScript'; import { validateUrl } from './browserUrlCheck'; +import { BrowserPreviewCache, prepareBrowserPreview, type BrowserPreviewResponse } from './browserPreviewCache'; +import { alignBrowserViewport, type BrowserViewportBounds } from './browserViewportGeometry'; import { api } from '@/infrastructure/api/service-api/ApiClient'; +import { createNativeWebviewVisibility, hasNativeWebviewOccluder, NATIVE_WEBVIEW_OCCLUSION_SELECTOR } from './nativeWebviewVisibility'; +export { NATIVE_WEBVIEW_OCCLUSION_SELECTOR, rectanglesIntersect } from './nativeWebviewVisibility'; const WEBVIEW_RESIZE_DEBOUNCE_MS = 160; -const WEBVIEW_BOUNDS_EPSILON = 1; const WEBVIEW_BOUNDS_WAIT_TIMEOUT_MS = 2000; -// Native child WebViews sit above the main document's CSS stacking contexts. -// Full-window DOM surfaces use this contract so the browser can be hidden while -// they are open and restored without tearing down its page state. -export const NATIVE_WEBVIEW_OCCLUSION_SELECTOR = [ - '[data-openbitfun-native-webview-occlusion]', - "[data-openbitfun-component='dialog'][data-openbitfun-part='overlay']", - "[data-openbitfun-component='sheet'][data-openbitfun-part='overlay']", - '.canvas-mission-control', - "[data-openbitfun-product-component='context-menu'][data-openbitfun-product-part='root']", -].join(', '); const BROWSER_WEBVIEW_PAGE_LOAD_EVENT = 'browser-webview-page-load'; const WEBVIEW_CREATE_RETRY_DELAYS_MS = [0, 250, 750]; @@ -29,37 +22,6 @@ export function allocateBrowserWebviewLabel(labelPrefix: string): string { return `${labelPrefix}-${nextBrowserWebviewSequence++}`; } -export function rectanglesIntersect( - first: Pick, - second: Pick, -): boolean { - return second.right > first.left - && second.left < first.right - && second.bottom > first.top - && second.top < first.bottom; -} - -// #region agent log -function writeBrowserWebviewDiagnostic( - hypothesis: string, - location: string, - message: string, - data: Record, -): void { - void fetch('http://127.0.0.1:7469/log', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - hypothesis, - location, - message, - data, - timestamp: new Date().toISOString(), - }), - }).catch(() => {}); -} -// #endregion - type BrowserLogger = { warn: (message: string, ...args: unknown[]) => void; error: (message: string, ...args: unknown[]) => void; @@ -215,6 +177,46 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti const lastBoundsRef = useRef(null); const webviewLabelRef = useRef(''); const pageLoadUnlistenRef = useRef<(() => void) | null>(null); + const activeRef = useRef(isVisible); + activeRef.current = isVisible; + const mountedRef = useRef(true); + const toolbarSuspendedRef = useRef(false); + const nativeVisibleRef = useRef(false); + const [previewUrl, setPreviewUrl] = useState(null); + const [previewBounds, setPreviewBounds] = useState(null); + const preview = useMemo(() => new BrowserPreviewCache({ + capture: label => api.invoke('browser_webview_capture_preview', { request: { label } }), + prepare: prepareBrowserPreview, + onFrame: setPreviewUrl, + onError: previewError => log.warn('Browser preview unavailable; using the default placeholder', previewError), + }), [log]); + const visibility = useMemo(() => createNativeWebviewVisibility(target => { + const viewport = viewportRef.current; + if (!mountedRef.current || !activeRef.current || toolbarSuspendedRef.current + || target !== webviewRef.current || !viewport?.isConnected) return false; + const rect = viewport.getBoundingClientRect(); + // Keep the last valid geometry during transient layout swaps, but still + // check overlays against it rather than bypassing the visibility decision. + const last = lastBoundsRef.current; + const nativeBounds = last ? { + left: last.left, top: last.top, + right: last.left + last.width, bottom: last.top + last.height, + } : null; + const bounds = rect.width > 1 && rect.height > 1 ? rect : nativeBounds; + // Bounds updates are debounced; the native view may still occupy its old + // rectangle after the DOM container has moved. + return !!bounds && !hasNativeWebviewOccluder(viewport, bounds) + && (!nativeBounds || !hasNativeWebviewOccluder(viewport, nativeBounds)); + }, (target, visible) => { + if (target !== webviewRef.current) return; + nativeVisibleRef.current = visible; + preview.setVisible(visible && document.visibilityState !== 'hidden'); + }), [preview]); + + const syncVisibility = useCallback(async (focus = false) => { + const target = webviewRef.current; + if (target) await visibility(target, focus); + }, [visibility]); const [inputValue, setInputValue] = useState(startUrl); const [currentUrl, setCurrentUrl] = useState(startUrl); @@ -228,12 +230,7 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti const rect = viewportRef.current.getBoundingClientRect(); if (rect.width <= 1 || rect.height <= 1) return null; - return { - left: Math.round(rect.left), - top: Math.round(rect.top), - width: Math.round(rect.width), - height: Math.round(rect.height), - }; + return alignBrowserViewport(rect, window.devicePixelRatio); }, []); const waitForViewportBounds = useCallback(async (): Promise => { @@ -255,68 +252,37 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti const nextBounds = readViewportBounds(); if (!nextBounds) { - // #region agent log - const viewport = viewportRef.current; - const viewportRect = viewport?.getBoundingClientRect(); - const ancestors: Array> = []; - let ancestor = viewport?.parentElement ?? null; - for (let index = 0; ancestor && index < 6; index += 1, ancestor = ancestor.parentElement) { - const style = window.getComputedStyle(ancestor); - const rect = ancestor.getBoundingClientRect(); - ancestors.push({ - tagName: ancestor.tagName, - className: ancestor.className, - display: style.display, - visibility: style.visibility, - width: rect.width, - height: rect.height, - }); - } - writeBrowserWebviewDiagnostic('F', 'useEmbeddedBrowserWebview.syncWebviewBounds', isVisible - ? 'keeping active webview at its last valid bounds while viewport is transiently unavailable' - : 'hiding inactive webview because viewport has no usable bounds', { - label: target.label, - isVisible, - hasViewport: Boolean(viewport), - isConnected: viewport?.isConnected ?? false, - viewportRect: viewportRect ? { - left: viewportRect.left, - top: viewportRect.top, - width: viewportRect.width, - height: viewportRect.height, - } : null, - windowSize: { width: window.innerWidth, height: window.innerHeight }, - ancestors, - }); - // #endregion - if (!isVisible) { - await target.hide().catch(() => {}); - } + await visibility(target); return; } const previous = lastBoundsRef.current; const boundsChanged = !previous || - Math.abs(previous.left - nextBounds.left) > WEBVIEW_BOUNDS_EPSILON || - Math.abs(previous.top - nextBounds.top) > WEBVIEW_BOUNDS_EPSILON || - Math.abs(previous.width - nextBounds.width) > WEBVIEW_BOUNDS_EPSILON || - Math.abs(previous.height - nextBounds.height) > WEBVIEW_BOUNDS_EPSILON; + previous.left !== nextBounds.left || previous.top !== nextBounds.top || + previous.width !== nextBounds.width || previous.height !== nextBounds.height; if (boundsChanged) { + if (!previous || previous.width !== nextBounds.width || previous.height !== nextBounds.height) { + preview.invalidate(); + } await setWebviewBounds(target.label, nextBounds); lastBoundsRef.current = nextBounds; + if (!previous || previous.width !== nextBounds.width || previous.height !== nextBounds.height) { + preview.invalidate(); + } } - if (isVisible) { - // #region agent log - writeBrowserWebviewDiagnostic('F', 'useEmbeddedBrowserWebview.syncWebviewBounds', 'showing webview after bounds sync', { - label: target.label, - bounds: nextBounds, - }); - // #endregion - await target.show().catch(() => {}); - } - }, [isTauri, isVisible, readViewportBounds]); + // The image is positioned relative to the host, but uses the applied native + // rectangle rather than stretching to the host's fractional CSS dimensions. + const viewport = viewportRef.current; + const applied = lastBoundsRef.current; + if (!mountedRef.current || target !== webviewRef.current || !viewport || !applied) return; + const rect = viewport.getBoundingClientRect(); + const relative = { left: applied.left - rect.left, top: applied.top - rect.top, width: applied.width, height: applied.height }; + setPreviewBounds(current => current && current.left === relative.left && current.top === relative.top + && current.width === relative.width && current.height === relative.height ? current : relative); + await visibility(target); + }, [isTauri, preview, readViewportBounds, visibility]); const closeWebview = useCallback(async (handle?: BrowserWebviewHandle | null) => { const target = handle ?? webviewRef.current; @@ -331,6 +297,9 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti } } finally { if (!handle || target === webviewRef.current) { + nativeVisibleRef.current = false; + preview.setVisible(false); + preview.setTarget(''); webviewRef.current = null; webviewLabelRef.current = ''; setWebviewLabel(''); @@ -339,7 +308,7 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti pageLoadUnlistenRef.current = null; } } - }, [log]); + }, [log, preview]); const startPageLoadListener = useCallback(async (label: string) => { pageLoadUnlistenRef.current?.(); @@ -350,6 +319,7 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti BROWSER_WEBVIEW_PAGE_LOAD_EVENT, ({ payload }) => { if (!payload || payload.label !== label) return; + preview.invalidate(); if (payload.event === 'started') { setIsLoading(true); } else { @@ -364,7 +334,7 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti } }, ); - }, []); + }, [preview]); const createWebview = useCallback(async (url: string) => { const previous = webviewRef.current; @@ -385,7 +355,12 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti setWebviewLabel(label); try { const handle = await createBrowserWebview(label, url, initialBounds, openRequestId); + if (!mountedRef.current) { + await handle.close(); + throw new Error('Browser surface disposed during webview creation'); + } webviewRef.current = handle; + preview.setTarget(label); lastBoundsRef.current = initialBounds; await injectBrowserPageScripts(label); await startPageLoadListener(label); @@ -406,7 +381,7 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti } throw lastError; - }, [closeWebview, labelPrefix, log, openRequestId, startPageLoadListener, waitForViewportBounds]); + }, [closeWebview, labelPrefix, log, openRequestId, preview, startPageLoadListener, waitForViewportBounds]); const navigateExistingWebview = useCallback(async (url: string): Promise => { const label = webviewLabelRef.current; @@ -433,6 +408,7 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti const loadUrl = useCallback(async (rawUrl: string) => { const nextUrl = normalizeUrl(rawUrl, defaultUrl); + preview.invalidate(); setInputValue(nextUrl); setCurrentUrl(nextUrl); currentUrlRef.current = nextUrl; @@ -456,10 +432,9 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti } } await syncWebviewBounds(handle); - if (isVisible) { - await handle.show(); - await handle.setFocus(); - await setAgentTargetState(handle.label, true, openRequestId); + await visibility(handle, true); + if (mountedRef.current && handle === webviewRef.current) { + await setAgentTargetState(handle.label, activeRef.current, openRequestId); } } catch (loadError) { const message = formatUnknownError(loadError); @@ -468,7 +443,7 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti } finally { setIsLoading(false); } - }, [createWebview, defaultUrl, isTauri, isVisible, log, navigateExistingWebview, openRequestId, syncWebviewBounds]); + }, [createWebview, defaultUrl, isTauri, log, navigateExistingWebview, openRequestId, preview, syncWebviewBounds, visibility]); const queueSync = useCallback(() => { if (resizeTimerRef.current !== null) window.clearTimeout(resizeTimerRef.current); @@ -482,48 +457,22 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti useEffect(() => { if (!isTauri) return; - - if (isVisible) { - // #region agent log - writeBrowserWebviewDiagnostic('G', 'useEmbeddedBrowserWebview.visibilityEffect', 'browser surface activated', { - label: webviewRef.current?.label ?? null, + if (isVisible && !webviewRef.current) { + void loadUrl(currentUrlRef.current).catch(loadError => { + log.warn('Restore browser webview failed', loadError); }); - // #endregion - if (!webviewRef.current) { - void loadUrl(currentUrlRef.current).catch((loadError) => { - log.warn('Restore browser webview failed', loadError); - }); - return; - } - - void syncWebviewBounds() - .then(() => webviewRef.current?.show()) - .then(() => webviewRef.current?.setFocus()) - .then(() => { - const label = webviewRef.current?.label; - return label ? setAgentTargetState(label, true, openRequestId) : undefined; - }) - .catch((syncError) => { - log.warn('Activate browser webview failed', syncError); - }); return; } - - if (webviewRef.current) { - // #region agent log - writeBrowserWebviewDiagnostic('G', 'useEmbeddedBrowserWebview.visibilityEffect', 'hiding webview because browser surface deactivated', { - label: webviewRef.current.label, - }); - // #endregion - const handle = webviewRef.current; - void setAgentTargetState(handle.label, false) - .catch(() => {}) - .then(() => handle.hide()) - .catch((hideError) => { - log.warn('Hide browser webview on deactivate failed', hideError); - }); - } - }, [isTauri, isVisible, loadUrl, log, openRequestId, syncWebviewBounds]); + const handle = webviewRef.current; + if (!handle) return; + void (async () => { + if (activeRef.current) await syncWebviewBounds(handle); + await visibility(handle, isVisible); + if (mountedRef.current && handle === webviewRef.current) { + await setAgentTargetState(handle.label, activeRef.current, openRequestId); + } + })().catch(syncError => log.warn('Update browser webview visibility failed', syncError)); + }, [isTauri, isVisible, loadUrl, log, openRequestId, syncWebviewBounds, visibility]); useEffect(() => { if (!isTauri) return; @@ -549,90 +498,89 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti }; }, [isTauri, isVisible, queueSync]); - useEffect(() => () => { - pageLoadUnlistenRef.current?.(); - pageLoadUnlistenRef.current = null; - if (resizeTimerRef.current !== null) { - window.clearTimeout(resizeTimerRef.current); - resizeTimerRef.current = null; - } - void closeWebview(); - }, [closeWebview]); + useEffect(() => { + mountedRef.current = true; + preview.resume(); + const handleDocumentVisibility = () => preview.setVisible(nativeVisibleRef.current && document.visibilityState !== 'hidden'); + document.addEventListener('visibilitychange', handleDocumentVisibility); + return () => { + mountedRef.current = false; + preview.dispose(); + document.removeEventListener('visibilitychange', handleDocumentVisibility); + pageLoadUnlistenRef.current?.(); + pageLoadUnlistenRef.current = null; + if (resizeTimerRef.current !== null) { + window.clearTimeout(resizeTimerRef.current); + resizeTimerRef.current = null; + } + const handle = webviewRef.current; + if (handle) { + // Drain pending show/focus operations before closing the native view. + void visibility(handle).catch(() => {}).then(() => closeWebview(handle)); + } + }; + }, [closeWebview, preview, visibility]); useEffect(() => { if (!isTauri) return; - - let hiddenByOverlay = false; + const viewport = viewportRef.current; + if (!viewport) return; + const doc = viewport.ownerDocument; + const update = () => { + void syncVisibility().catch(syncError => { + log.warn('Update browser webview occlusion failed', syncError); + }); + }; + const observed = new Set(); + const resizeObserver = new ResizeObserver(update); + resizeObserver.observe(viewport); const checkOverlays = () => { - const viewport = viewportRef.current; - const viewportRect = viewport?.getBoundingClientRect(); - const hasUsableViewport = Boolean(viewportRect && viewportRect.width > 0 && viewportRect.height > 0); - // Do not change the current visibility decision while the browser panel - // itself is temporarily unmeasurable (for example during a layout swap). - if (!hasUsableViewport || !viewportRect) return; - const hasIntersection = (overlay: HTMLElement): boolean => { - const overlayRect = overlay.getBoundingClientRect(); - return overlayRect.width > 0 - && overlayRect.height > 0 - && rectanglesIntersect(viewportRect, overlayRect); - }; - const overlay = Array.from(document.querySelectorAll(NATIVE_WEBVIEW_OCCLUSION_SELECTOR)) - .find(candidate => hasIntersection(candidate)); - const hasOverlay = overlay !== undefined; - // #region agent log - if (overlay) { - const style = window.getComputedStyle(overlay); - const rect = overlay.getBoundingClientRect(); - writeBrowserWebviewDiagnostic('E', 'useEmbeddedBrowserWebview.checkOverlays', 'overlay selector matched', { - label: webviewRef.current?.label ?? null, - className: overlay.className, - display: style.display, - visibility: style.visibility, - opacity: style.opacity, - width: rect.width, - height: rect.height, - hiddenByOverlay, - }); + const overlays = new Set(doc.querySelectorAll(NATIVE_WEBVIEW_OCCLUSION_SELECTOR)); + for (const element of observed) { + if (!overlays.has(element)) { + resizeObserver.unobserve(element); + observed.delete(element); + } } - // #endregion - if (hasOverlay && !hiddenByOverlay) { - hiddenByOverlay = true; - // #region agent log - writeBrowserWebviewDiagnostic('E', 'useEmbeddedBrowserWebview.checkOverlays', 'hiding webview because overlay selector exists', { - label: webviewRef.current?.label ?? null, - className: overlay?.className ?? null, - }); - // #endregion - void webviewRef.current?.hide().catch(() => {}); - } else if (!hasOverlay && hiddenByOverlay) { - hiddenByOverlay = false; - if (isVisible) { - // #region agent log - writeBrowserWebviewDiagnostic('E', 'useEmbeddedBrowserWebview.checkOverlays', 'showing webview because overlay selector disappeared', { - label: webviewRef.current?.label ?? null, - }); - // #endregion - void syncWebviewBounds() - .then(() => webviewRef.current?.show()) - .catch(() => {}); + for (const element of overlays) { + if (!observed.has(element)) { + resizeObserver.observe(element); + observed.add(element); } } + update(); }; - const observer = new MutationObserver(checkOverlays); - observer.observe(document.body, { childList: true, subtree: true }); + observer.observe(doc.body, { + childList: true, subtree: true, attributes: true, + attributeFilter: ['style', 'class', 'hidden', 'data-state', 'data-openbitfun-state', 'data-openbitfun-native-webview-occlusion'], + }); checkOverlays(); - const handleToolbarActivating = () => { - void webviewRef.current?.hide().catch(() => {}); + toolbarSuspendedRef.current = true; + update(); + }; + const handleToolbarSettled = () => { + toolbarSuspendedRef.current = false; + update(); }; window.addEventListener('toolbar-mode-activating', handleToolbarActivating); - + window.addEventListener('toolbar-mode-activation-finished', handleToolbarSettled); + doc.addEventListener('scroll', update, true); + window.addEventListener('resize', update); + doc.addEventListener('transitionend', update, true); + doc.addEventListener('animationend', update, true); return () => { observer.disconnect(); + resizeObserver.disconnect(); window.removeEventListener('toolbar-mode-activating', handleToolbarActivating); + window.removeEventListener('toolbar-mode-activation-finished', handleToolbarSettled); + doc.removeEventListener('scroll', update, true); + window.removeEventListener('resize', update); + doc.removeEventListener('transitionend', update, true); + doc.removeEventListener('animationend', update, true); }; - }, [isTauri, isVisible, syncWebviewBounds]); + }, [isTauri, log, syncVisibility]); const evalInWebview = useCallback(async (script: string) => { const label = webviewLabelRef.current; @@ -671,6 +619,8 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti isLoading, isTauri, loadUrl, + previewUrl, + previewBounds, reload, setInputValue, viewportRef, @@ -688,6 +638,8 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti isLoading, isTauri, loadUrl, + previewUrl, + previewBounds, reload, viewportRef, webviewLabel, diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md index 8159e456ec..1b20a71cf7 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md @@ -36,7 +36,7 @@ each missing what the other had. | `useFlowChatSearch.test.ts` | exact matching-block decoration, occurrence counting, and search navigation state | | `flowChatSearchDom.test.ts` | concrete text ranges and independent highlight ownership across rows and panes | | `flowChatSearchPresentation.test.ts` | visible source highlighting and single-line marker geometry, wrapping, scrolling, and clipping | -| `FlowChatHeader.test.tsx` | shared SearchField composition, result controls, input identity while expanding, and the default active-only Agent tree toggle | +| `FlowChatHeader.test.tsx` | shared SearchField composition, result controls, input identity while expanding, native-view occlusion declaration for session overview, and the default active-only Agent tree toggle | | `SessionTreePopover.test.tsx` | Agent selection/cancellation/deletion menus, type-only metadata, active branch filtering with ancestor retention, restoring all agents, and the active empty state | | `../../services/deleteSessionTreeBranch.test.ts` | Unloaded descendant deletion in child-first order, remote location forwarding, failure retention, and device surface guards | diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx index 86f5f6ea6f..2b6728f410 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx @@ -423,6 +423,7 @@ describe('FlowChatHeader', () => { }); const panel = document.querySelector('[data-testid="flowchat-header-session-overview-panel"]'); + expect(panel?.hasAttribute('data-openbitfun-native-webview-occlusion')).toBe(true); const items = [...(panel?.querySelector( '.flowchat-header__session-overview-list', )?.children ?? [])] as HTMLElement[]; diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx index ae3f8765ec..9c9bf55598 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx @@ -493,6 +493,7 @@ export const FlowChatHeader: React.FC = ({ className="flowchat-header__background-command-menu flowchat-header__background-command-menu--portal" data-openbitfun-component="flow-chat-header" data-openbitfun-part="commandMenu" + data-openbitfun-native-webview-occlusion aria-label={t('flowChatHeader.backgroundCommandActions')} style={backgroundCommandMenuPosition} data-testid="flowchat-header-background-menu" @@ -724,6 +725,7 @@ export const FlowChatHeader: React.FC = ({ className="flowchat-header__session-overview-panel" data-openbitfun-component="flow-chat-header" data-openbitfun-part="sessionOverviewPanel" + data-openbitfun-native-webview-occlusion data-openbitfun-placement={sessionOverviewPanelLayout?.placement ?? 'bottom'} role="dialog" aria-label={t('flowChatHeader.sessionOverview')} @@ -834,6 +836,7 @@ export const FlowChatHeader: React.FC = ({ className="flowchat-header__background-command-menu flowchat-header__background-command-menu--portal" data-openbitfun-component="flow-chat-header" data-openbitfun-part="commandMenu" + data-openbitfun-native-webview-occlusion aria-label={t('flowChatHeader.backgroundCommandActions')} style={backgroundCommandMenuPosition} data-testid="flowchat-header-background-menu" diff --git a/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.tsx b/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.tsx index c86ef240ef..2546495df3 100644 --- a/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.tsx +++ b/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.tsx @@ -509,6 +509,7 @@ export const SessionTreePopover: React.FC = ({ className="session-tree-popover__action-menu" data-openbitfun-component="flow-chat-header" data-openbitfun-part="sessionTreeMenu" + data-openbitfun-native-webview-occlusion aria-label={t('flowChatHeader.agentTreeActions')} style={actionMenuPosition} data-testid="flowchat-header-session-tree-menu" diff --git a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarModeProvider.tsx b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarModeProvider.tsx index 42069e6f69..1b5e03c78b 100644 --- a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarModeProvider.tsx +++ b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarModeProvider.tsx @@ -197,6 +197,8 @@ export const ToolbarModeProvider: React.FC = ({ childr // cannot finish, so a partial floating geometry is never persisted. log.error('Failed to restore main window after toolbar mode activation error', restoreError); } + } finally { + window.dispatchEvent(new CustomEvent('toolbar-mode-activation-finished')); } }, []); diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts index d1059113e6..5773b50f1f 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts @@ -103,6 +103,7 @@ describe('isPeerLocalOnlyCommand', () => { // them to a peer would regress (peer host does not implement them, and // they drive the controller's own embedded surfaces). expect(isPeerLocalOnlyCommand('browser_webview_create')).toBe(true); + expect(isPeerLocalOnlyCommand('browser_webview_capture_preview')).toBe(true); expect(isPeerLocalOnlyCommand('browser_webview_eval')).toBe(true); expect(isPeerLocalOnlyCommand('browser_webview_navigate')).toBe(true); expect(isPeerLocalOnlyCommand('browser_webview_reload')).toBe(true); diff --git a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts index 313b39daf4..381819814e 100644 --- a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts +++ b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts @@ -1,6 +1,6 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. // Source: openbitfun_product_domains::remote_surface (Product Operation Registry). -export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:57f26e05119c5457" as const; +export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:6c2c8c606588ee3f" as const; /** * Registered Tauri commands the Peer Device controller keeps on the controller @@ -34,6 +34,7 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([ "appearance_market_submit_package", "appearance_market_withdraw_submission", "append_flow_chat_diagnostics", + "browser_webview_capture_preview", "browser_webview_create", "browser_webview_eval", "browser_webview_navigate", diff --git a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts index b8a9abd098..6b3fc5ba31 100644 --- a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts +++ b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts @@ -178,6 +178,7 @@ import { canvasEditorGroupAppearanceDescriptor } from '@/app/components/panels/c import { canvasThumbnailAppearanceDescriptor } from '@/app/components/panels/content-canvas/mission-control/ThumbnailCard.appearance'; import { canvasTabOverflowAppearanceDescriptor } from '@/app/components/panels/content-canvas/tab-bar/TabOverflowMenu.appearance'; import { browserPanelAppearanceDescriptor } from '@/app/scenes/browser/BrowserPanel.appearance'; +import { browserPreviewAppearanceDescriptor } from '@/app/scenes/browser/BrowserPreview.appearance'; import { nurseryGalleryAppearanceDescriptor } from '@/app/scenes/profile/views/NurseryGallery.appearance'; import { gitGraphViewAppearanceDescriptor } from '@/app/scenes/git/views/GraphView.appearance'; import { navBarAppearanceDescriptor } from '@/app/components/NavBar/NavBar.appearance'; @@ -430,6 +431,7 @@ export function createDefaultAppearanceRegistry(): AppearanceRegistry { .registerComponent(canvasThumbnailAppearanceDescriptor) .registerComponent(canvasTabOverflowAppearanceDescriptor) .registerComponent(browserPanelAppearanceDescriptor) + .registerComponent(browserPreviewAppearanceDescriptor) .registerComponent(nurseryGalleryAppearanceDescriptor) .registerComponent(deepReviewConsentDialogAppearanceDescriptor) .registerComponent(flowToolCardAppearanceDescriptor)