From 06a4ca57d2a40ad5f37560980f5c7a844f7635da Mon Sep 17 00:00:00 2001 From: Adam Zvada Date: Wed, 22 Apr 2026 17:35:26 +0200 Subject: [PATCH 1/7] feat: browser focus mode, webview migration, session composer store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser: migrated to `` (Codex-pattern) and replaced the legacy `WebContentsView` path; added a Codex-style focus-mode overlay with a floating chat composer. Focus mode auto-enters when the chat panel is collapsed (button or splitter drag) and exits when expanded. Removed the detached-browser feature (DetachedBrowserWindow, useBrowserDetach, BrowserDetachedPlaceholder, ViewportDropdown). Composer: introduced per-session `sessionComposerStore` (Immer + devtools) holding draft, model, thinking, plan-mode, and all staged content — pasted texts, inspected elements, file/skill mentions, image attachments. `SessionComposer` is a thin stateful wrapper over the now-presentational `MessageInput`, which reads/writes the store directly. Cross-surface drift (main chat ↔ modal ↔ focus overlay) is gone by construction. Cross-panel pushers (BrowserPanel element selector, DiffViewer comment, SimulatorPanel screenshot) now dispatch to `sessionComposerActions` directly; the `chatInsertStore` pub/sub layer and MainLayout subscriber are deleted. Visual polish: glass-pill MessageInput (bg-muted/75 + backdrop blur + shadow-lg + ring), concentric-fixed staged cards, scale-on-press on all action buttons, browser-tab-style icon cross-fade applied to chat session tabs. Follow-up: `docs/session-tabs-refactor.md` captures the deferred session-tabs component split for a separate PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/backend/test/unit/shared/events.test.ts | 20 - apps/desktop/main/browser-emulation.ts | 137 ++ apps/desktop/main/browser-views.ts | 806 ------------ apps/desktop/main/index.ts | 55 +- apps/desktop/main/native-handlers.ts | 1 - apps/desktop/preload/browser-preload.ts | 127 +- apps/desktop/preload/index.ts | 56 +- apps/web/src/app/App.tsx | 15 +- apps/web/src/app/layouts/ContentView.tsx | 25 +- apps/web/src/app/layouts/MainContent.tsx | 70 +- apps/web/src/app/layouts/MainLayout.tsx | 54 +- apps/web/src/app/layouts/MobileLayout.tsx | 11 +- apps/web/src/components/ui/resizable.tsx | 8 +- .../src/features/apps/hooks/useAppsStopped.ts | 2 +- .../browser/hooks/useBrowserDetach.ts | 86 -- .../src/features/browser/hooks/useWebview.ts | 71 + .../browser/store/browserWindowStore.ts | 59 +- apps/web/src/features/browser/store/index.ts | 1 - apps/web/src/features/browser/types.ts | 92 +- .../browser/ui/BrowserDetachedPlaceholder.tsx | 23 - .../src/features/browser/ui/BrowserPanel.tsx | 805 +++++++----- .../src/features/browser/ui/BrowserTab.tsx | 1165 +++++++---------- .../src/features/browser/ui/BrowserTabBar.tsx | 173 ++- .../browser/ui/DetachedBrowserWindow.tsx | 120 -- .../features/browser/ui/FocusModeOverlay.tsx | 132 ++ .../features/browser/ui/ViewportDropdown.tsx | 185 --- .../src/features/browser/webview-manager.ts | 252 ++++ apps/web/src/features/session/hooks/index.ts | 1 + .../session/hooks/useImageAttachments.ts | 109 +- .../session/hooks/useSessionActions.ts | 104 +- .../session/hooks/useSessionComposer.ts | 95 ++ .../features/session/lib/imageAttachments.ts | 84 ++ .../session/store/sessionComposerStore.ts | 287 ++++ .../src/features/session/ui/MessageInput.tsx | 421 +++--- .../features/session/ui/PastedImageCard.tsx | 5 +- .../features/session/ui/PastedTextCard.tsx | 2 +- .../features/session/ui/PlanModeToggle.tsx | 4 +- .../features/session/ui/SessionComposer.tsx | 187 +++ .../src/features/session/ui/SessionPanel.tsx | 255 ++-- .../renderers/RecordingToolRenderers.tsx | 9 - .../features/simulator/ui/SimulatorPanel.tsx | 8 +- .../src/features/workspace/ui/DiffViewer.tsx | 7 +- .../features/workspace/ui/MainContentTabs.tsx | 85 +- apps/web/src/platform/capabilities.ts | 8 +- apps/web/src/platform/native/browser-views.ts | 185 +-- apps/web/src/shared/stores/chatInsertStore.ts | 165 --- docs/session-tabs-refactor.md | 182 +++ shared/events.ts | 49 +- skills-lock.json | 10 + 49 files changed, 3169 insertions(+), 3644 deletions(-) create mode 100644 apps/desktop/main/browser-emulation.ts delete mode 100644 apps/desktop/main/browser-views.ts delete mode 100644 apps/web/src/features/browser/hooks/useBrowserDetach.ts create mode 100644 apps/web/src/features/browser/hooks/useWebview.ts delete mode 100644 apps/web/src/features/browser/ui/BrowserDetachedPlaceholder.tsx delete mode 100644 apps/web/src/features/browser/ui/DetachedBrowserWindow.tsx create mode 100644 apps/web/src/features/browser/ui/FocusModeOverlay.tsx delete mode 100644 apps/web/src/features/browser/ui/ViewportDropdown.tsx create mode 100644 apps/web/src/features/browser/webview-manager.ts create mode 100644 apps/web/src/features/session/hooks/useSessionComposer.ts create mode 100644 apps/web/src/features/session/lib/imageAttachments.ts create mode 100644 apps/web/src/features/session/store/sessionComposerStore.ts create mode 100644 apps/web/src/features/session/ui/SessionComposer.tsx delete mode 100644 apps/web/src/shared/stores/chatInsertStore.ts create mode 100644 docs/session-tabs-refactor.md create mode 100644 skills-lock.json diff --git a/apps/backend/test/unit/shared/events.test.ts b/apps/backend/test/unit/shared/events.test.ts index 179202b8c..fd7c82819 100644 --- a/apps/backend/test/unit/shared/events.test.ts +++ b/apps/backend/test/unit/shared/events.test.ts @@ -6,11 +6,6 @@ import { FS_CHANGED, PTY_DATA, PTY_EXIT, - BROWSER_PAGE_LOAD, - BROWSER_TITLE_CHANGED, - BROWSER_URL_CHANGE, - BROWSER_WORKSPACE_CHANGE, - BROWSER_DETACHED_CLOSED, BROWSER_NEW_TAB_REQUESTED, CHAT_INSERT, GIT_CLONE_PROGRESS, @@ -23,7 +18,6 @@ import { PtyDataSchema, ChatInsertSchema, GitCloneProgressSchema, - BrowserWorkspaceChangeSchema, // Domain constants QUERY_RESOURCES, REQUEST_RESOURCES, @@ -40,11 +34,6 @@ describe("shared/events", () => { FS_CHANGED, PTY_DATA, PTY_EXIT, - BROWSER_PAGE_LOAD, - BROWSER_TITLE_CHANGED, - BROWSER_URL_CHANGE, - BROWSER_WORKSPACE_CHANGE, - BROWSER_DETACHED_CLOSED, BROWSER_NEW_TAB_REQUESTED, CHAT_INSERT, GIT_CLONE_PROGRESS, @@ -129,15 +118,6 @@ describe("shared/events", () => { }); expect(result.success).toBe(true); }); - - it("BrowserWorkspaceChangeSchema accepts nullish fields", () => { - const result = BrowserWorkspaceChangeSchema.safeParse({ - workspaceId: "ws-1", - directoryName: null, - repoName: undefined, - }); - expect(result.success).toBe(true); - }); }); describe("schema validation — rejects invalid payloads", () => { diff --git a/apps/desktop/main/browser-emulation.ts b/apps/desktop/main/browser-emulation.ts new file mode 100644 index 000000000..d7bdf1483 --- /dev/null +++ b/apps/desktop/main/browser-emulation.ts @@ -0,0 +1,137 @@ +/** + * Main-process helpers for the -based browser path. + * + * Two things need to reach the compositor side (outside the renderer): + * 1. CDP viewport emulation (requires debugger attach — cannot be done + * from executeJavaScript, which runs in the guest page context). + * 2. DevTools with a specific dock mode. `.openDevTools()` on the + * renderer element has no `mode` parameter and always opens detached; + * going through `webContents.openDevTools({ mode: "bottom" })` on the + * main side is the only way to dock. + * + * Both identify the target by `webContentsId`, which the renderer gets from + * `webview.getWebContentsId()` after the guest page attaches. + */ + +import { ipcMain, webContents } from "electron"; + +const emulatedIds = new Set(); + +export function registerBrowserEmulationHandlers(): void { + ipcMain.handle( + "browser_webview_emulation_set", + async ( + _e, + { + webContentsId, + width, + height, + deviceScaleFactor, + mobile, + scale, + }: { + webContentsId: number; + width: number; + height: number; + deviceScaleFactor: number; + mobile: boolean; + scale?: number; + } + ): Promise<{ success: boolean; error?: string }> => { + const wc = webContents.fromId(webContentsId); + if (!wc || wc.isDestroyed()) return { success: false, error: "webContents not found" }; + + try { + if (!wc.debugger.isAttached()) wc.debugger.attach("1.3"); + + // Always apply device-metrics override so the page reflows for the + // emulated device (mobile UA + breakpoints kick in from the `mobile` + // flag + width). Separately, a sub-unity `scale` shrinks the rendered + // output so oversized viewports (Desktop 1920×1080 on a narrow panel) + // still fit — that's exactly what webContents.setZoomFactor does. + await wc.debugger.sendCommand("Emulation.setDeviceMetricsOverride", { + width, + height, + deviceScaleFactor, + mobile, + }); + wc.setZoomFactor(scale !== undefined && scale < 1 ? scale : 1); + + await wc.debugger.sendCommand("Emulation.setTouchEmulationEnabled", { + enabled: mobile, + ...(mobile ? { maxTouchPoints: 5 } : {}), + }); + + emulatedIds.add(webContentsId); + return { success: true }; + } catch (err) { + return { success: false, error: String(err) }; + } + } + ); + + ipcMain.handle( + "browser_webview_emulation_clear", + async ( + _e, + { webContentsId }: { webContentsId: number } + ): Promise<{ success: boolean; error?: string }> => { + const wc = webContents.fromId(webContentsId); + if (!wc || wc.isDestroyed()) return { success: false, error: "webContents not found" }; + + try { + if (wc.debugger.isAttached()) { + await wc.debugger.sendCommand("Emulation.clearDeviceMetricsOverride", {}); + await wc.debugger.sendCommand("Emulation.setTouchEmulationEnabled", { enabled: false }); + // Detach the debugger so Chromium fully releases emulation state and + // re-runs layout against the webview element's real dimensions. Just + // calling clearDeviceMetricsOverride leaves an active CDP session + // that can retain stale viewport state — the page stays laid-out at + // the previous mobile dims until something else (navigation, zoom + // change) invalidates layout. Detaching is the cleanest signal. + // Next setEmulation call re-attaches (it checks isAttached()). + wc.debugger.detach(); + } + wc.setZoomFactor(1); + emulatedIds.delete(webContentsId); + return { success: true }; + } catch (err) { + return { success: false, error: String(err) }; + } + } + ); + + ipcMain.handle( + "browser_webview_devtools_open", + ( + _e, + { + webContentsId, + mode = "bottom", + }: { webContentsId: number; mode?: "right" | "bottom" | "undocked" | "detach" } + ): { success: boolean; error?: string } => { + const wc = webContents.fromId(webContentsId); + if (!wc || wc.isDestroyed()) return { success: false, error: "webContents not found" }; + try { + wc.openDevTools({ mode }); + return { success: true }; + } catch (err) { + return { success: false, error: String(err) }; + } + } + ); + + ipcMain.handle( + "browser_webview_devtools_close", + (_e, { webContentsId }: { webContentsId: number }): { success: boolean; error?: string } => { + const wc = webContents.fromId(webContentsId); + if (!wc || wc.isDestroyed()) return { success: false, error: "webContents not found" }; + try { + wc.closeDevTools(); + return { success: true }; + } catch (err) { + return { success: false, error: String(err) }; + } + } + ); +} diff --git a/apps/desktop/main/browser-views.ts b/apps/desktop/main/browser-views.ts deleted file mode 100644 index c976c1bc0..000000000 --- a/apps/desktop/main/browser-views.ts +++ /dev/null @@ -1,806 +0,0 @@ -/** - * BrowserView Manager - * - * Manages Electron BrowserViews for the agent browser automation feature. - * Uses native Electron BrowserView APIs for cross-platform web automation. - * - * Z-order strategy: - * BrowserViews are currently added via contentView.addChildView(view) - * which renders them on top of the main WebContents. The renderer hides - * all views when dialogs/modals are open to prevent overlap issues. - * Rendering behind the DOM via addChildView(view, 0) with a transparent - * browser panel is tracked as a follow-up improvement. - * - * Each browser view gets: - * - A shared session partition for cookie persistence across tabs/restarts - * - A preload script for console capture + keyboard routing - * - Main-world polyfill injection (WebAuthn, local-network-access) - * - Event forwarding (page-load, title, url, navigation) - * - * Handler names match the snake_case names the renderer calls via invoke(). - * Handlers prefixed "browser:" are invoked via the generic invoke() bridge - * (back, forward, createDetachedWindow, closeDetachedWindow). - */ - -import { WebContentsView, BrowserWindow, ipcMain } from "electron"; -import { join } from "path"; -import { is } from "@electron-toolkit/utils"; - -const views = new Map(); -const viewBounds = new Map(); -/** Labels of views that were visible before hide_all — used to restore only those on show_all. */ -const previouslyVisibleLabels = new Set(); -const viewEmulation = new Map< - string, - { width: number; height: number; deviceScaleFactor: number; mobile: boolean } ->(); - -/** Reference to the detached browser window (only one at a time) */ -let detachedWindow: BrowserWindow | null = null; - -/** Centralized main window lookup — avoids repeating getAllWindows()[0] in every handler */ -function getMainWindow(): BrowserWindow | undefined { - return ( - BrowserWindow.getAllWindows().find((w) => w !== detachedWindow) ?? - BrowserWindow.getAllWindows()[0] - ); -} - -// --------------------------------------------------------------------------- -// Main-world polyfill scripts -// -// These are injected via view.webContents.executeJavaScript() on `dom-ready` -// so they run in the page's main world. The preload's webFrame.executeJavaScript() -// runs in the isolated world and cannot override page-visible APIs like -// navigator.credentials or navigator.permissions. -// --------------------------------------------------------------------------- - -const WEBAUTHN_POLYFILL_JS = `(function() { - if (typeof navigator === 'undefined' || !navigator.credentials) return; - if (navigator.credentials.__webAuthnPolyfillApplied) return; - navigator.credentials.__webAuthnPolyfillApplied = true; - - function createNotSupportedError() { - return new DOMException('WebAuthn is not supported in the Deus browser. Click "Try another way" to use password login.', 'NotSupportedError'); - } - - var origCreate = navigator.credentials.create; - var origGet = navigator.credentials.get; - - // Reject passkey/FIDO2 requests immediately so sites fall back to - // password login. Wrapping the original method with a 45s timeout - // just makes users wait — rejecting instantly shows the fallback UI. - navigator.credentials.create = function(options) { - if (options && options.publicKey) return Promise.reject(createNotSupportedError()); - return origCreate ? origCreate.apply(navigator.credentials, arguments) : Promise.reject(createNotSupportedError()); - }; - navigator.credentials.get = function(options) { - if (options && options.publicKey) return Promise.reject(createNotSupportedError()); - return origGet ? origGet.apply(navigator.credentials, arguments) : Promise.reject(createNotSupportedError()); - }; - - if (typeof PublicKeyCredential !== 'undefined') { - PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable = function() { - return Promise.resolve(false); - }; - if (typeof PublicKeyCredential.isConditionalMediationAvailable === 'function') { - PublicKeyCredential.isConditionalMediationAvailable = function() { - return Promise.resolve(false); - }; - } - } -})();`; - -const LOCAL_NETWORK_POLYFILL_JS = `(function() { - if (typeof navigator === 'undefined' || !navigator.permissions || !navigator.permissions.query) return; - if (navigator.permissions.__localNetworkPolyfillApplied) return; - navigator.permissions.__localNetworkPolyfillApplied = true; - - var origQuery = navigator.permissions.query.bind(navigator.permissions); - navigator.permissions.query = function(descriptor) { - if (descriptor && (descriptor.name === 'local-network-access' || descriptor.name === 'local-network')) { - return Promise.resolve({ - state: 'granted', - name: descriptor.name, - onchange: null, - addEventListener: function() {}, - removeEventListener: function() {}, - dispatchEvent: function() { return true; } - }); - } - return origQuery(descriptor); - }; -})();`; - -const AUTH_DOMAINS = [ - ".okta.com", - ".okta-emea.com", - ".oktapreview.com", - ".duosecurity.com", - ".duo.com", - ".login.microsoftonline.com", - ".onelogin.com", - ".auth0.com", - ".pingidentity.com", - ".pingone.com", - ".rippling.com", -]; - -export function registerBrowserViewHandlers(): void { - // ------------------------------------------------------------------------- - // Create a new browser view - // Renderer calls: invoke("create_browser_webview", { label, url, x, y, width, height, windowLabel }) - // ------------------------------------------------------------------------- - - ipcMain.handle( - "create_browser_webview", - ( - _e, - { - label, - url, - x, - y, - width, - height, - }: { - label: string; - url: string; - x: number; - y: number; - width: number; - height: number; - windowLabel?: string; - } - ) => { - const mainWindow = getMainWindow(); - if (!mainWindow) return; - - // Clean up existing view with same label - const existing = views.get(label); - if (existing) { - mainWindow.contentView.removeChildView(existing); - (existing.webContents as any).destroy?.(); - views.delete(label); - viewBounds.delete(label); - viewEmulation.delete(label); - } - - // getBoundingClientRect() returns CSS-pixel coordinates, but - // WebContentsView.setBounds() operates in the window's native coordinate - // space. When the user zooms the renderer (Cmd+/Cmd-), CSS pixels diverge - // from window points by the zoom factor — multiply to correct. - const zoomFactor = mainWindow.webContents.getZoomFactor(); - const bounds = { - x: Math.round(x * zoomFactor), - y: Math.round(y * zoomFactor), - width: Math.round(Math.max(width * zoomFactor, 100)), - height: Math.round(Math.max(height * zoomFactor, 100)), - }; - - const view = new WebContentsView({ - webPreferences: { - // Single shared partition — all tabs share cookies like a real browser. - // Login once on localhost:3000, every tab sees it. Persists across restarts. - partition: "persist:browser", - contextIsolation: true, - nodeIntegration: false, - nodeIntegrationInSubFrames: false, - sandbox: false, // ESM preload (.mjs) requires sandbox: false — TODO: convert to CJS - webviewTag: false, - navigateOnDragDrop: false, - enableBlinkFeatures: "StandardizedBrowserZoom", - preload: join(__dirname, "../preload/browser-preload.mjs"), - }, - }); - - // Add as a child of contentView. Currently renders on top of main - // WebContents (same as old BrowserView behavior). To render BEHIND - // the DOM (like Cursor/VS Code), use addChildView(view, 0) and make - // the browser panel area transparent — tracked as a follow-up. - mainWindow.contentView.addChildView(view); - view.setBounds(bounds); - views.set(label, view); - - // Register event listeners BEFORE loadURL to avoid race conditions. - // loadURL() triggers loading events that must be captured. - // - // Event semantics: - // did-start-loading — any frame starts (main + iframes/ads) - // did-stop-loading — ALL frames finished (tab spinner stops) - // did-finish-load — main frame only - // did-fail-load — main frame navigation failure - // - // We use did-start-loading + did-stop-loading to match browser tab - // spinner behavior. did-finish-load fires before subresources are - // done, and did-start-loading fires for iframes too — using - // did-stop-loading as "finished" ensures loading state clears once - // everything is truly done (no stuck spinner from ad iframes). - view.webContents.on("did-start-loading", () => { - mainWindow.webContents.send("browser:page-load", { - label, - url: view.webContents.getURL(), - event: "started", - }); - }); - - view.webContents.on("did-stop-loading", () => { - mainWindow.webContents.send("browser:page-load", { - label, - url: view.webContents.getURL(), - event: "finished", - }); - }); - - view.webContents.on( - "did-fail-load", - (_event, errorCode, errorDescription, _url, isMainFrame) => { - // Only report main frame failures — subframe failures (ads, iframes) - // shouldn't show as page-level errors. - // ERR_ABORTED (-3) fires during redirects and canceled navigations — not a real failure. - if (!isMainFrame || errorCode === -3) return; - mainWindow.webContents.send("browser:page-load", { - label, - url: view.webContents.getURL(), - event: "failed", - error: { code: errorCode, description: errorDescription }, - }); - } - ); - - view.webContents.on("page-title-updated", (_, title) => { - mainWindow.webContents.send("browser:title-changed", { label, title }); - }); - - view.webContents.on("did-navigate", () => { - mainWindow.webContents.send("browser:url-change", { - label, - url: view.webContents.getURL(), - }); - }); - - view.webContents.on("did-navigate-in-page", () => { - mainWindow.webContents.send("browser:url-change", { - label, - url: view.webContents.getURL(), - }); - }); - - // TODO: Network request tracking — disabled until renderer listener is wired. - // The events were being sent but nothing consumed them (wasted IPC traffic). - - // Certificate error handling — only accept self-signed certs on localhost - // in development mode. Production builds reject all cert errors. - view.webContents.on("certificate-error", (event, _url, _error, _certificate, callback) => { - if (is.dev) { - try { - const parsed = new URL(_url); - if (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") { - event.preventDefault(); - callback(true); - return; - } - } catch { - // Malformed URL — fall through to reject - } - } - callback(false); - }); - - // Handle popups (window.open, target="_blank", OAuth flows). - // Instead of opening in the system browser (which breaks OAuth callbacks), - // forward to the renderer so it opens as a new browser tab in the IDE. - // This matches how Cursor handles it — popup stays in-app, cookies are shared. - view.webContents.setWindowOpenHandler(({ url: linkUrl, disposition }) => { - try { - const parsed = new URL(linkUrl); - if (parsed.protocol === "http:" || parsed.protocol === "https:") { - // Send to renderer to open as a new browser tab - mainWindow.webContents.send("browser:new-tab-requested", { - url: linkUrl, - disposition, // "foreground-tab", "background-tab", "new-window" - openerLabel: label, - }); - } - } catch { - // Ignore malformed URLs - } - return { action: "deny" }; - }); - - // Handle messages from the browser preload (keyboard shortcuts + console) - view.webContents.on("ipc-message", (_event, channel, ...args) => { - if (channel === "browser:keyboard-shortcut") { - const payload = args[0]; - if (!payload || typeof payload !== "object") return; - const { shortcut } = payload as { shortcut: string }; - if (shortcut === "reload") { - view.webContents.reload(); - } else if (shortcut === "focus-url-bar") { - // Forward to renderer so the URL bar can be focused - mainWindow.webContents.send("browser:keyboard-shortcut", { shortcut }); - } - } else if (channel === "browser:console-message") { - // Forward console messages from browser views to the renderer - mainWindow.webContents.send("browser:console-message", args[0]); - } - }); - - // Inject polyfills into the page's main world. - // WebAuthn must run BEFORE page scripts — use did-start-navigation so it - // executes before any site JS checks PublicKeyCredential availability. - // dom-ready is too late: Google's auth JS runs during parsing. - view.webContents.on("did-start-navigation", (_event, _url, isInPlace, isMainFrame) => { - if (!isMainFrame || isInPlace) return; - // WebAuthn polyfill — immediate rejection for passkey/FIDO2 requests - view.webContents.executeJavaScript(WEBAUTHN_POLYFILL_JS).catch(() => {}); - }); - - // Local network polyfill needs the final URL (after redirects), so use dom-ready - view.webContents.on("dom-ready", () => { - try { - const pageUrl = view.webContents.getURL(); - const hostname = new URL(pageUrl).hostname.toLowerCase(); - if (AUTH_DOMAINS.some((d) => hostname === d.slice(1) || hostname.endsWith(d))) { - view.webContents.executeJavaScript(LOCAL_NETWORK_POLYFILL_JS).catch(() => {}); - } - } catch { - /* malformed URL — skip polyfill */ - } - }); - - // Start navigation AFTER all listeners are attached - view.webContents.loadURL(url); - } - ); - - // ------------------------------------------------------------------------- - // Navigation - // Renderer calls: invoke("navigate_browser_webview", { label, url }) - // ------------------------------------------------------------------------- - - ipcMain.handle( - "navigate_browser_webview", - (_e, { label, url }: { label: string; url: string }) => { - views.get(label)?.webContents.loadURL(url); - } - ); - - // ------------------------------------------------------------------------- - // JavaScript evaluation (fire-and-forget) - // Renderer calls: invoke("eval_browser_webview", { label, js }) - // Used by BrowserTab.tsx to inject automation scripts into the webview. - // ------------------------------------------------------------------------- - - ipcMain.handle( - "eval_browser_webview", - async (_e, { label, js }: { label: string; js: string }) => { - const view = views.get(label); - if (!view) return null; - try { - return await view.webContents.executeJavaScript(js); - } catch (err) { - console.error(`[BrowserView] eval failed for "${label}":`, err); - return null; - } - } - ); - - // ------------------------------------------------------------------------- - // JavaScript evaluation (with result capture + timeout) - // Renderer calls: invoke("eval_browser_webview_with_result", { label, js, timeout_ms }) - // Used by BrowserTab.tsx for console drain and inspect mode event drain. - // ------------------------------------------------------------------------- - - ipcMain.handle( - "eval_browser_webview_with_result", - async (_e, { label, js, timeout_ms }: { label: string; js: string; timeout_ms?: number }) => { - const view = views.get(label); - if (!view) return null; - try { - const timeout = timeout_ms ?? 30_000; - return await Promise.race([ - view.webContents.executeJavaScript(js), - new Promise((_, reject) => - setTimeout(() => reject(new Error("Eval timeout")), timeout) - ), - ]); - } catch (err) { - console.error(`[BrowserView] eval failed for "${label}":`, err); - return null; - } - } - ); - - // ------------------------------------------------------------------------- - // Screenshot - // Renderer calls: invoke("screenshot_browser_webview", { label, x?, y?, width?, height? }) - // Supports optional crop rectangle; omit for full page capture. - // ------------------------------------------------------------------------- - - ipcMain.handle( - "screenshot_browser_webview", - async ( - _e, - { - label, - x, - y, - width, - height, - }: { - label: string; - x?: number; - y?: number; - width?: number; - height?: number; - } - ) => { - const view = views.get(label); - if (!view) return null; - try { - let image; - if (x !== undefined && y !== undefined && width !== undefined && height !== undefined) { - image = await view.webContents.capturePage({ - x: Math.round(x), - y: Math.round(y), - width: Math.round(width), - height: Math.round(height), - }); - } else { - image = await view.webContents.capturePage(); - } - return image.toDataURL(); - } catch (err) { - console.error(`[BrowserView] screenshot failed for "${label}":`, err); - return null; - } - } - ); - - // ------------------------------------------------------------------------- - // DevTools - // Renderer calls: invoke("open_browser_devtools", { label }) - // invoke("close_browser_devtools", { label }) - // ------------------------------------------------------------------------- - - ipcMain.handle( - "open_browser_devtools", - (_e, { label, mode }: { label: string; mode?: "right" | "bottom" | "detach" | "undocked" }) => { - views.get(label)?.webContents.openDevTools({ mode: mode ?? "bottom" }); - } - ); - - ipcMain.handle("close_browser_devtools", (_e, { label }: { label: string }) => { - const view = views.get(label); - if (view?.webContents.isDevToolsOpened()) { - view.webContents.closeDevTools(); - } - }); - - // ------------------------------------------------------------------------- - // Bounds / visibility - // Renderer calls: invoke("set_browser_webview_bounds", { label, x, y, width, height }) - // invoke("show_browser_webview", { label }) - // invoke("hide_browser_webview", { label }) - // invoke("close_browser_webview", { label }) - // invoke("reload_browser_webview", { label }) - // ------------------------------------------------------------------------- - - ipcMain.handle( - "set_browser_webview_bounds", - ( - _e, - { - label, - x, - y, - width, - height, - }: { - label: string; - x: number; - y: number; - width: number; - height: number; - } - ) => { - // CSS-pixel → window-point conversion (see create_browser_webview comment) - const mainWindow = getMainWindow(); - const zoomFactor = mainWindow?.webContents.getZoomFactor() ?? 1; - const bounds = { - x: Math.round(x * zoomFactor), - y: Math.round(y * zoomFactor), - width: Math.round(width * zoomFactor), - height: Math.round(height * zoomFactor), - }; - const view = views.get(label); - if (view) { - view.setBounds(bounds); - // Also save to viewBounds so show() uses the latest position - // (important when setBounds is called while the view is hidden/detached) - viewBounds.set(label, bounds); - } - } - ); - - ipcMain.handle("show_browser_webview", (_e, { label }: { label: string }) => { - const view = views.get(label); - if (view) { - // Only toggle visibility — do NOT re-add as child via addChildView(). - // Re-adding can trigger Electron to reset the page navigation to - // localhost:1420 (the renderer URL), replacing the target page. - view.setVisible(true); - const savedBounds = viewBounds.get(label); - if (savedBounds) { - view.setBounds(savedBounds); - } - } - }); - - ipcMain.handle("hide_browser_webview", (_e, { label }: { label: string }) => { - const view = views.get(label); - if (view) { - viewBounds.set(label, view.getBounds()); - view.setVisible(false); - } - }); - - // Hide ALL browser views at once — called when switching workspaces - // or navigating to the welcome screen to ensure no stale native overlays. - ipcMain.handle("hide_all_browser_webviews", () => { - previouslyVisibleLabels.clear(); - for (const [label, view] of views) { - // Only track views that were attached and visible - const mainWindow = getMainWindow(); - if (mainWindow && mainWindow.contentView.children.includes(view)) { - previouslyVisibleLabels.add(label); - } - viewBounds.set(label, view.getBounds()); - view.setVisible(false); - } - }); - - // Show browser views — restores only views that were visible before hide_all. - // Only toggles visibility — does NOT re-add views as children (that can - // cause navigation resets to localhost:1420). - ipcMain.handle("show_all_browser_webviews", () => { - for (const label of previouslyVisibleLabels) { - const view = views.get(label); - if (!view) continue; - view.setVisible(true); - const savedBounds = viewBounds.get(label); - if (savedBounds) { - view.setBounds(savedBounds); - } - } - previouslyVisibleLabels.clear(); - }); - - ipcMain.handle("close_browser_webview", (_e, { label }: { label: string }) => { - const view = views.get(label); - if (!view) return; - const mainWindow = getMainWindow(); - if (mainWindow) { - mainWindow.contentView.removeChildView(view); - } - (view.webContents as any).destroy?.(); - views.delete(label); - viewBounds.delete(label); - viewEmulation.delete(label); - }); - - ipcMain.handle("reload_browser_webview", (_e, { label }: { label: string }) => { - views.get(label)?.webContents.reload(); - }); - - // ------------------------------------------------------------------------- - // View existence check (used by try-recall-before-create pattern) - // ------------------------------------------------------------------------- - - ipcMain.handle("browser_view_exists", (_e, { label }: { label: string }) => { - return views.has(label); - }); - - // ------------------------------------------------------------------------- - // Device emulation (CDP Emulation domain via webContents.debugger) - // ------------------------------------------------------------------------- - - ipcMain.handle( - "set_browser_emulation", - async ( - _e, - { - label, - width, - height, - deviceScaleFactor, - mobile, - scale, - }: { - label: string; - width: number; - height: number; - deviceScaleFactor: number; - mobile: boolean; - scale?: number; - } - ) => { - const view = views.get(label); - if (!view) return { success: false, error: "View not found" }; - - try { - if (!view.webContents.debugger.isAttached()) { - view.webContents.debugger.attach("1.3"); - } - - if (scale !== undefined && scale < 1) { - // Oversized viewport — setDeviceMetricsOverride and setZoomFactor - // conflict (they both affect the layout viewport). Use zoom alone: - // innerWidth = physicalWidth / zoomFactor = desiredWidth. - // Clear any previous CDP override first. - await view.webContents.debugger.sendCommand("Emulation.clearDeviceMetricsOverride", {}); - view.webContents.setZoomFactor(scale); - } else { - // Viewport fits in panel — use CDP for proper device emulation - // (exact dimensions, DPR, mobile flag). - await view.webContents.debugger.sendCommand("Emulation.setDeviceMetricsOverride", { - width, - height, - deviceScaleFactor, - mobile, - }); - view.webContents.setZoomFactor(1); - } - - // Touch emulation works independently of device metrics. - // maxTouchPoints must be 1-16; omit when disabling. - await view.webContents.debugger.sendCommand("Emulation.setTouchEmulationEnabled", { - enabled: mobile, - ...(mobile ? { maxTouchPoints: 5 } : {}), - }); - - viewEmulation.set(label, { width, height, deviceScaleFactor, mobile }); - return { success: true }; - } catch (err) { - console.error(`[BrowserView] set_browser_emulation failed for "${label}":`, err); - return { success: false, error: String(err) }; - } - } - ); - - ipcMain.handle("clear_browser_emulation", async (_e, { label }: { label: string }) => { - const view = views.get(label); - if (!view) return { success: false, error: "View not found" }; - - try { - if (view.webContents.debugger.isAttached()) { - await view.webContents.debugger.sendCommand("Emulation.clearDeviceMetricsOverride", {}); - await view.webContents.debugger.sendCommand("Emulation.setTouchEmulationEnabled", { - enabled: false, - }); - } - view.webContents.setZoomFactor(1); - viewEmulation.delete(label); - return { success: true }; - } catch (err) { - console.error(`[BrowserView] clear_browser_emulation failed for "${label}":`, err); - return { success: false, error: String(err) }; - } - }); - - // ------------------------------------------------------------------------- - // Back / Forward - // ------------------------------------------------------------------------- - - ipcMain.handle("browser:back", (_e, { label }: { label: string }) => { - const view = views.get(label); - if (view?.webContents.canGoBack()) { - view.webContents.goBack(); - } - }); - - ipcMain.handle("browser:forward", (_e, { label }: { label: string }) => { - const view = views.get(label); - if (view?.webContents.canGoForward()) { - view.webContents.goForward(); - } - }); - - // ------------------------------------------------------------------------- - // Detached Browser Window - // Renderer calls: invoke("browser:createDetachedWindow", { url, title, width, height, minWidth, minHeight }) - // invoke("browser:closeDetachedWindow") - // ------------------------------------------------------------------------- - - ipcMain.handle( - "browser:createDetachedWindow", - ( - _e, - { - url, - title, - width, - height, - minWidth, - minHeight, - }: { - url: string; - title: string; - width: number; - height: number; - minWidth?: number; - minHeight?: number; - } - ) => { - // Close existing detached window if any - if (detachedWindow && !detachedWindow.isDestroyed()) { - detachedWindow.close(); - detachedWindow = null; - } - - detachedWindow = new BrowserWindow({ - width, - height, - minWidth: minWidth ?? 600, - minHeight: minHeight ?? 400, - title, - titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default", - trafficLightPosition: { x: 16, y: 18 }, - webPreferences: { - preload: join(__dirname, "../preload/index.mjs"), - contextIsolation: true, - nodeIntegration: false, - sandbox: false, // ESM preload (index.mjs) requires sandbox: false - }, - }); - - // Load the renderer app with the detached window URL - if (is.dev && process.env.ELECTRON_RENDERER_URL) { - detachedWindow.loadURL(`${process.env.ELECTRON_RENDERER_URL}${url}`); - } else { - detachedWindow.loadFile(join(__dirname, "../renderer/index.html"), { - search: url.includes("?") ? url.split("?")[1] : "", - }); - } - - // Register close handler immediately after creation (before loadURL completes) - // to prevent race conditions where the window closes before the listener is attached. - detachedWindow.on("closed", () => { - detachedWindow = null; - const mainWindow = getMainWindow(); - if (mainWindow) { - mainWindow.webContents.send("browser:detached-closed"); - } - }); - } - ); - - ipcMain.handle("browser:closeDetachedWindow", () => { - if (detachedWindow && !detachedWindow.isDestroyed()) { - detachedWindow.close(); - detachedWindow = null; - } - }); -} - -/** - * Clean up all browser views. Called on app quit. - */ -export function destroyAllBrowserViews(): void { - const mainWindow = getMainWindow(); - for (const [, view] of views) { - if (mainWindow) { - mainWindow.contentView.removeChildView(view); - } - (view.webContents as any).destroy?.(); - } - views.clear(); - viewBounds.clear(); - viewEmulation.clear(); - - if (detachedWindow && !detachedWindow.isDestroyed()) { - detachedWindow.close(); - detachedWindow = null; - } -} diff --git a/apps/desktop/main/index.ts b/apps/desktop/main/index.ts index d44118967..5276043ab 100644 --- a/apps/desktop/main/index.ts +++ b/apps/desktop/main/index.ts @@ -18,7 +18,7 @@ import { homedir } from "os"; import { is } from "@electron-toolkit/utils"; import { spawnBackend, stopBackend, CDP_PORT } from "./backend-process"; import { registerNativeHandlers } from "./native-handlers"; -import { registerBrowserViewHandlers, destroyAllBrowserViews } from "./browser-views"; +import { registerBrowserEmulationHandlers } from "./browser-emulation"; // PTY, file watching, and browser server are now handled by the backend // via WebSocket commands — no Electron IPC needed for these. import { setupAutoUpdater } from "./auto-updater"; @@ -85,9 +85,45 @@ async function createWindow(): Promise { contextIsolation: true, nodeIntegration: false, sandbox: false, // ESM preload requires sandbox: false (package.json "type": "module") + webviewTag: true, // Phase 1 of WebContentsView→ migration — enables for guest pages }, }); + // Attach the guest-page preload whenever a element is mounted. + // The tag itself sets `partition` so all tabs share the + // `persist:browser` cookie jar; here we only wire the preload + isolation. + mainWindow.webContents.on("will-attach-webview", (_event, webPreferences) => { + webPreferences.preload = join(__dirname, "../preload/browser-preload.mjs"); + webPreferences.contextIsolation = true; + webPreferences.nodeIntegration = false; + }); + + // Keep popups in-app: when a guest calls window.open() or follows a + // `target="_blank"` link (common for OAuth redirects), forward the URL to + // the renderer which will open it as a new browser tab. Returning + // `{ action: "deny" }` stops Electron from spawning a standalone window. + // + // SECURITY: restrict forwarded URLs to http/https. `data:`, `javascript:`, + // `file:`, and `chrome:` schemes could be used to inject arbitrary code + // into the `persist:browser` partition, which is shared across tabs and + // inherits cookies from every legitimate site. Any non-http(s) scheme is + // silently dropped; the renderer never sees it, no new tab is spawned. + mainWindow.webContents.on("did-attach-webview", (_event, guestContents) => { + guestContents.setWindowOpenHandler(({ url, disposition }) => { + try { + const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return { action: "deny" }; + } + } catch { + // Unparseable URL — deny silently. + return { action: "deny" }; + } + mainWindow?.webContents.send("browser:new-tab-requested", { url, disposition }); + return { action: "deny" }; + }); + }); + // Show window once renderer is ready (avoids white flash) mainWindow.on("ready-to-show", () => { // Window starts hidden — the renderer calls show_main_window after @@ -238,15 +274,11 @@ app.whenReady().then(async () => { // Register IPC handlers before window creation so they're ready immediately registerNativeHandlers(); - registerBrowserViewHandlers(); + registerBrowserEmulationHandlers(); - // Cross-window event relay — when one renderer sends an event via ipcRenderer.send(), - // forward it to all OTHER windows. This enables the detached browser window to - // communicate with the main window (e.g., CHAT_INSERT events). - const RELAY_EVENTS = new Set([ - "chat-insert", // Detached browser -> main window - "browser-window:workspace-change", // Main window → detached browser window - ]); + // Cross-window event relay — forwards a sender's event to all other windows. + // Used for chat-insert (e.g. terminal/simulator feeding the main composer). + const RELAY_EVENTS = new Set(["chat-insert"]); for (const channel of RELAY_EVENTS) { ipcMain.on(channel, (event, ...args) => { @@ -264,10 +296,6 @@ app.whenReady().then(async () => { await createWindow(); logMainProcess("[main] Window created"); - // No hidden BrowserView needed — BrowserTab eagerly creates a native - // BrowserView (about:blank) on mount, giving agent-browser a CDP target - // to discover and navigate directly. - // Dev mode: swap dock icon so it's visually distinct from the production app if (is.dev && process.platform === "darwin") { try { @@ -308,7 +336,6 @@ app.on("window-all-closed", () => { app.on("before-quit", () => { destroyTray(); - destroyAllBrowserViews(); stopBackend(); }); diff --git a/apps/desktop/main/native-handlers.ts b/apps/desktop/main/native-handlers.ts index a93edc0a8..528d225aa 100644 --- a/apps/desktop/main/native-handlers.ts +++ b/apps/desktop/main/native-handlers.ts @@ -346,7 +346,6 @@ export function registerNativeHandlers(): void { // ------------------------------------------------------------------------- // Window title — renderer calls invoke("native:setTitle", { title }) - // Used by DetachedBrowserWindow to sync window title // ------------------------------------------------------------------------- ipcMain.handle("native:setTitle", (_e, { title }: { title: string }) => { diff --git a/apps/desktop/preload/browser-preload.ts b/apps/desktop/preload/browser-preload.ts index 824f0e916..02c25a86c 100644 --- a/apps/desktop/preload/browser-preload.ts +++ b/apps/desktop/preload/browser-preload.ts @@ -1,115 +1,58 @@ /** - * Browser View Preload Script + * Guest-page preload — attached to every via `will-attach-webview`. * - * Injected into BrowserView content pages (the agent browser). + * Responsibilities are intentionally narrow; the host DOM events + * (`console-message`, `did-navigate`, `page-title-updated`, `did-fail-load`, + * `did-start-loading`, …) already give the renderer everything it needs, so + * we do NOT mirror those over IPC. What remains: * - * Responsibilities: - * 1. Console capture — forward to main process for the console panel - * 2. Dialog override — alert/confirm/prompt are non-blocking (prevents app freeze) - * 3. Keyboard shortcut routing — Cmd+R/L route back to IDE - * - * NOTE: WebAuthn and local-network-access polyfills are injected from the main - * process via view.webContents.executeJavaScript() on `dom-ready`, which targets - * the page's main world. The preload's webFrame.executeJavaScript() runs in the - * isolated world and cannot override page-visible APIs. + * 1. Non-blocking dialog overrides — a page calling alert()/confirm()/prompt() + * would otherwise freeze the guest thread. + * 2. Keyboard-shortcut routing — Cmd+R inside the guest should reload the + * tab, Cmd+L should focus the URL bar in our shell. Sent via + * `ipcRenderer.sendToHost(...)`, which delivers to the host webContents + * as an `ipc-message` DOM event on the element. */ import { ipcRenderer } from "electron"; -// --------------------------------------------------------------------------- -// Console capture — forward to main process -// --------------------------------------------------------------------------- - -const originalConsole = { - log: console.log.bind(console), - warn: console.warn.bind(console), - error: console.error.bind(console), - info: console.info.bind(console), -}; - -function forwardConsole(level: string, args: unknown[]): void { - try { - const serialized = args.map((arg) => { - if (typeof arg === "string") return arg; - try { - return JSON.stringify(arg); - } catch { - return String(arg); - } - }); - ipcRenderer.send("browser:console-message", { level, args: serialized }); - } catch { - // Swallow errors — never break page JS - } -} - -console.log = (...args: unknown[]) => { - originalConsole.log(...args); - forwardConsole("log", args); -}; - -console.warn = (...args: unknown[]) => { - originalConsole.warn(...args); - forwardConsole("warn", args); -}; - -console.error = (...args: unknown[]) => { - originalConsole.error(...args); - forwardConsole("error", args); -}; - -console.info = (...args: unknown[]) => { - originalConsole.info(...args); - forwardConsole("info", args); -}; - -// --------------------------------------------------------------------------- -// Override blocking dialogs to be non-blocking -// (intentional for agent automation — prevents app freeze from page dialogs) -// --------------------------------------------------------------------------- +// ----------------------------------------------------------------------------- +// Override blocking dialogs — never freeze the app from guest JS. +// ----------------------------------------------------------------------------- window.alert = (message?: string) => { - originalConsole.log("[alert]", message); + console.log("[alert]", message); }; -window.confirm = (_message?: string) => { - originalConsole.log("[confirm]", _message); - return true; // Always confirm +window.confirm = (message?: string) => { + console.log("[confirm]", message); + return true; }; -window.prompt = (_message?: string, _defaultValue?: string) => { - originalConsole.log("[prompt]", _message); - return _defaultValue ?? null; +window.prompt = (message?: string, defaultValue?: string) => { + console.log("[prompt]", message); + return defaultValue ?? null; }; -// --------------------------------------------------------------------------- -// Keyboard shortcut routing -// -// When the BrowserView is focused, browser shortcuts (Cmd+R, Cmd+L, etc.) -// would be consumed by the webview. Route them to the IDE instead so the -// user gets expected behavior regardless of focus state. -// --------------------------------------------------------------------------- +// ----------------------------------------------------------------------------- +// Keyboard shortcut routing — Cmd/Ctrl+R reload, Cmd/Ctrl+L focus URL bar. +// Forwarded to the HOST webContents via sendToHost (no main process hop). +// ----------------------------------------------------------------------------- + +export type BrowserGuestShortcut = "reload" | "focus-url-bar"; window.addEventListener( "keydown", (e: KeyboardEvent) => { const meta = e.metaKey || e.ctrlKey; if (!meta) return; - - switch (e.key) { - case "r": - // Cmd+R → reload the BrowserView (not the IDE) - e.preventDefault(); - e.stopPropagation(); - ipcRenderer.send("browser:keyboard-shortcut", { shortcut: "reload" }); - break; - case "l": - // Cmd+L → focus the URL bar in our IDE - e.preventDefault(); - e.stopPropagation(); - ipcRenderer.send("browser:keyboard-shortcut", { shortcut: "focus-url-bar" }); - break; - } + let shortcut: BrowserGuestShortcut | null = null; + if (e.key === "r") shortcut = "reload"; + else if (e.key === "l") shortcut = "focus-url-bar"; + if (!shortcut) return; + e.preventDefault(); + e.stopPropagation(); + ipcRenderer.sendToHost("shortcut", shortcut); }, - true // capture phase — intercept before page handlers + true // capture — intercept before page handlers ); diff --git a/apps/desktop/preload/index.ts b/apps/desktop/preload/index.ts index 97065ccbb..fe7bc35e3 100644 --- a/apps/desktop/preload/index.ts +++ b/apps/desktop/preload/index.ts @@ -37,30 +37,14 @@ const ALLOWED_INVOKE_CHANNELS = new Set([ "get_installed_apps", "open_in_app", - // Browser webview management - "create_browser_webview", - "close_browser_webview", - "show_browser_webview", - "hide_browser_webview", - "hide_all_browser_webviews", - "show_all_browser_webviews", - "set_browser_webview_bounds", - "navigate_browser_webview", - "reload_browser_webview", - "eval_browser_webview", - "eval_browser_webview_with_result", - "screenshot_browser_webview", - "browser_view_exists", - "open_browser_devtools", - "close_browser_devtools", - "set_browser_emulation", - "clear_browser_emulation", - "browser:back", - "browser:forward", - - // Browser detached windows - "browser:createDetachedWindow", - "browser:closeDetachedWindow", + // Browser — only pieces that must run on the main side. + // - Emulation: needs `webContents.debugger.attach` + CDP commands. + // - DevTools: the renderer-side .openDevTools() has no mode + // parameter; opening docked requires webContents.openDevTools({mode}). + "browser_webview_emulation_set", + "browser_webview_emulation_clear", + "browser_webview_devtools_open", + "browser_webview_devtools_close", // Native operations (called via generic invoke from platform layer) "native:pickFolder", @@ -89,14 +73,10 @@ const ALLOWED_EVENT_CHANNELS = new Set([ "pty-data", "pty-exit", - // Browser automation events - "browser:page-load", - "browser:title-changed", - "browser:url-change", - "browser-window:workspace-change", - "browser:detached-closed", - "browser:keyboard-shortcut", - "browser:console-message", + // Browser — only popup requests (window.open / target="_blank") need + // main→renderer forwarding. Everything else about the (load, + // title, url, console, keyboard) is received directly as DOM events on + // the element in the renderer, with no main-process round-trip. "browser:new-tab-requested", // Simulator events — all moved to backend WS (q:event protocol) @@ -147,18 +127,6 @@ const electronAPI = { isFullscreen: (): Promise => ipcRenderer.invoke("native:isFullscreen"), toggleFullscreen: (): Promise => ipcRenderer.invoke("native:toggleFullscreen"), - // --------------------------------------------------------------------------- - // Browser views (for agent browser automation) - // --------------------------------------------------------------------------- - - browserInvoke: (method: string, args: unknown): Promise => - ipcRenderer.invoke(`browser:${method}`, args), - onBrowserEvent: (event: string, callback: (...args: unknown[]) => void): (() => void) => { - const listener = (_e: Electron.IpcRendererEvent, ...args: unknown[]): void => callback(...args); - ipcRenderer.on(event, listener); - return () => ipcRenderer.removeListener(event, listener); - }, - // --------------------------------------------------------------------------- // Auto-update // --------------------------------------------------------------------------- diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index c51b84624..7062050cd 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -3,7 +3,6 @@ import { ErrorBoundary } from "react-error-boundary"; import { QueryErrorResetBoundary } from "@tanstack/react-query"; import { RouterProvider } from "@tanstack/react-router"; import { LazyMotion, domAnimation } from "framer-motion"; -import { DetachedBrowserWindow } from "@/features/browser/ui/DetachedBrowserWindow"; import { ErrorFallback } from "@/shared/components"; import { createBoundaryErrorHandler } from "@/shared/utils/errorReporting"; import { QueryClientProvider, ThemeProvider } from "./providers"; @@ -12,11 +11,6 @@ import { native } from "@/platform"; import { DesktopShell } from "./shells/DesktopShell"; import { webRouter } from "./router"; -// Detect if this window instance is the detached browser popup. -// The main window creates it with ?window=browser-detached in the URL. -const isDetachedBrowser = - new URLSearchParams(window.location.search).get("window") === "browser-detached"; - /** * Safety net: ensure the window always becomes visible. * @@ -51,14 +45,7 @@ function App() { // Safety net -- force-show window if nothing else does within 5s useWindowShowSafetyNet(); - const content = isDetachedBrowser ? ( - // Detached browser window: minimal shell with just the browser panel - - - - - - ) : capabilities.isDesktop ? ( + const content = capabilities.isDesktop ? ( // Desktop (Electron): no router, direct MainLayout via DesktopShell diff --git a/apps/web/src/app/layouts/ContentView.tsx b/apps/web/src/app/layouts/ContentView.tsx index a032266cb..fc019c825 100644 --- a/apps/web/src/app/layouts/ContentView.tsx +++ b/apps/web/src/app/layouts/ContentView.tsx @@ -18,8 +18,6 @@ import { BrowserPanel } from "@/features/browser"; import { SimulatorPanel } from "@/features/simulator"; import { AppsLauncher, useAppsLaunched, useAppsStopped } from "@/features/apps"; import { capabilities } from "@/platform/capabilities"; -import { BrowserDetachedPlaceholder } from "@/features/browser/ui/BrowserDetachedPlaceholder"; -import { useBrowserDetach } from "@/features/browser/hooks/useBrowserDetach"; import { cn } from "@/shared/lib/utils"; import type { ContentTab } from "@/features/workspace/store"; import type { Workspace } from "@/shared/types"; @@ -39,17 +37,6 @@ export function ContentView({ isWatched = false, onReview, }: ContentViewProps) { - const { - isDetached: isBrowserDetached, - detach: detachBrowser, - reattach: reattachBrowser, - } = useBrowserDetach({ - workspaceId: workspace.id, - directoryName: workspace.slug, - repoName: workspace.repo_name, - branch: workspace.git_branch, - }); - // AAP lifecycle → Browser tabs: open on launch, close on stop/crash. // Both hooks ignore events targeting other workspaces and always mount // during a workspace session so a launch/stop completed while the user @@ -75,19 +62,11 @@ export function ContentView({ {/* Persistent tabs — always mounted, hidden when inactive */}
- {isBrowserDetached ? ( - - ) : ( - - )} +
s.detachedWindowOpen); const connectionState = useConnectionState().state; const isDisconnected = connectionState === "disconnected"; @@ -200,6 +200,18 @@ export function MainContent({ } }, [contentPanelCollapsed, selectedWorkspaceId]); + // Mirror chatPanelCollapsed state to the ResizablePanel ref so external + // callers (browser focus mode, keyboard shortcuts) can collapse/expand the + // chat by flipping the store state alone — no need to thread refs through. + useEffect(() => { + if (!selectedWorkspaceId) return; + if (chatPanelCollapsed) { + chatPanelRef.current?.collapse(); + } else { + chatPanelRef.current?.expand(); + } + }, [chatPanelCollapsed, selectedWorkspaceId]); + // --- Keyboard shortcuts --- usePanelShortcuts({ enabled: selectedWorkspace !== null && !isMobile, @@ -232,40 +244,18 @@ export function MainContent({ // eslint-disable-next-line react-hooks/exhaustive-deps -- only fire on workspace change, not on collapse toggles }, [selectedWorkspaceId]); - // --- Hide all native BrowserViews on workspace switch --- - // BrowserViews are native Electron overlays rendered ABOVE the DOM. When - // switching workspaces or going to the welcome screen, stale views from - // the previous workspace would remain visible. Hide them immediately; - // the new workspace's BrowserTab will re-show its own view when ready. - useEffect(() => { - native.browserViews.hideAll().catch(() => { - /* Expected: IPC may be unavailable in web mode; stale views are harmless */ - }); - }, [selectedWorkspaceId]); - - // --- Sync workspace changes to detached browser window --- - const detachedWorkspaceContext = useMemo( - () => - selectedWorkspace - ? { - workspaceId: selectedWorkspace.id, - directoryName: selectedWorkspace.slug, - repoName: selectedWorkspace.repo_name, - branch: selectedWorkspace.git_branch, - } - : null, - [selectedWorkspace] - ); + // Note: elements stack normally in the DOM — no IPC hideAll + // dance is needed on workspace switch. CSS visibility handles per-tab + // hide/show inside BrowserPanel. - useEffect(() => { - if (!isBrowserDetached || !detachedWorkspaceContext) return; - void native.events.send(BROWSER_WORKSPACE_CHANGE, detachedWorkspaceContext); - }, [isBrowserDetached, detachedWorkspaceContext]); - - // Insert code review prompt into chat input + // Insert code review prompt into the active chat's composer. Goes + // straight through the composer store — no SessionPanel ref round-trip, + // which means it works even if the chat panel is collapsed. const handleInsertReviewPrompt = useCallback(() => { - workspaceChatPanelRef.current?.insertText(REVIEW_CODE); - }, [workspaceChatPanelRef]); + if (!selectedWorkspaceId) return; + const sid = workspaceLayoutActions.getLayout(selectedWorkspaceId).activeChatTabSessionId; + if (sid) sessionComposerActions.appendDraft(sid, REVIEW_CODE); + }, [selectedWorkspaceId]); return ( @@ -396,7 +386,15 @@ export function MainContent({ )} - + during + * drag. Electron's webview guest eats pointermove before + * they bubble to document; without this, dragging the + * splitter rightward (cursor crosses into the webview) + * freezes because react-resizable-panels loses its + * document-level pointermove stream. */ + onDragging={(isDragging) => webviewManager.setPointerEventsEnabled(!isDragging)} + /> {/* ─── CONTENT PANEL (right, collapsible) ─── */} s.commandPaletteOpen); - const anyDialogOpen = - showNewWorkspaceModal || - showSystemPromptModal || - commandPaletteOpen || - !!githubPickerRepoId || - repoActions.showCloneModal || - repoActions.showStartNewModal; - useEffect(() => { - if (anyDialogOpen) { - native.browserViews.hideAll().catch(() => { - /* Expected: IPC may be unavailable in web mode or during shutdown */ - }); - } - }, [anyDialogOpen]); + // Note: elements stack normally under dialogs/modals — no + // hideAll IPC dance needed when dialogs open. // --- System prompt (inline — small scope, one modal) --- @@ -384,36 +364,6 @@ export function MainLayout() { }, }); - // Subscribe to chatInsertStore for content dispatched from BrowserPanel / DiffViewer / SimulatorPanel - useEffect(() => { - const unsubStore = useChatInsertStore.subscribe((state, prevState) => { - if (!state.pending || state.pending === prevState.pending) return; - - const payload = state.pending; - chatInsertActions.consume(); - - if (!workspaceChatPanelRef.current) return; - if (!isChatInsertForWorkspace(payload, selectedWorkspaceIdRef.current)) return; - - deliverChatInsertPayload(workspaceChatPanelRef.current, payload); - }); - - const unlistenChat = native.events.on(CHAT_INSERT, (data) => { - void deserializeChatInsertPayload(data) - .then((payload) => { - chatInsertActions.dispatch(payload); - }) - .catch((error) => { - console.error("Failed to deserialize detached chat insert:", error); - }); - }); - - return () => { - unsubStore(); - unlistenChat(); - }; - }, []); - const handleWorkspaceClick = useCallback( (workspace: Workspace) => { selectWorkspace(workspace.id); diff --git a/apps/web/src/app/layouts/MobileLayout.tsx b/apps/web/src/app/layouts/MobileLayout.tsx index 60c7853ec..ca018d5fd 100644 --- a/apps/web/src/app/layouts/MobileLayout.tsx +++ b/apps/web/src/app/layouts/MobileLayout.tsx @@ -10,6 +10,8 @@ import { useState, useCallback, useMemo } from "react"; import type { Dispatch, SetStateAction } from "react"; import type { SessionPanelRef } from "@/features/session"; import { REVIEW_CODE } from "@/features/session/lib/sessionPrompts"; +import { sessionComposerActions } from "@/features/session/store/sessionComposerStore"; +import { workspaceLayoutActions } from "@/features/workspace/store"; import { useFileChanges } from "@/features/workspace"; import { ChangesView } from "@/features/workspace/ui/ChangesView"; import { WorkspaceHeader } from "@/features/workspace/ui/WorkspaceHeader"; @@ -81,11 +83,14 @@ export function MobileLayout({ ); const fileChanges = useMemo(() => fileChangesData?.files ?? [], [fileChangesData]); - // Insert code review prompt into chat input and switch to chat tab + // Insert code review prompt into the active chat's composer and switch + // to the chat tab. Writes directly to the composer store so the prompt + // shows up regardless of whether the chat panel is mounted. const handleInsertReviewPrompt = useCallback(() => { - workspaceChatPanelRef.current?.insertText(REVIEW_CODE); + const sid = workspaceLayoutActions.getLayout(workspace.id).activeChatTabSessionId; + if (sid) sessionComposerActions.appendDraft(sid, REVIEW_CODE); setActiveTab("chat"); - }, [workspaceChatPanelRef]); + }, [workspace.id]); // Shared PR bar props -- avoids repeating the same prop bag twice. const prBarProps = { diff --git a/apps/web/src/components/ui/resizable.tsx b/apps/web/src/components/ui/resizable.tsx index 30901c7e3..fd422d65f 100644 --- a/apps/web/src/components/ui/resizable.tsx +++ b/apps/web/src/components/ui/resizable.tsx @@ -3,15 +3,13 @@ import * as ResizablePrimitive from "react-resizable-panels"; import { cn } from "@/shared/lib/utils"; -function ResizablePanelGroup({ - className, - ...props -}: React.ComponentProps) { +function ResizablePanelGroup(props: React.ComponentProps) { + const { className, ...rest } = props; return ( ); } diff --git a/apps/web/src/features/apps/hooks/useAppsStopped.ts b/apps/web/src/features/apps/hooks/useAppsStopped.ts index b002993aa..3b0f0af20 100644 --- a/apps/web/src/features/apps/hooks/useAppsStopped.ts +++ b/apps/web/src/features/apps/hooks/useAppsStopped.ts @@ -35,7 +35,7 @@ export function useAppsStopped(currentWorkspaceId: string | null): void { if (typeof payload.url !== "string" || payload.url.length === 0) return; // Normalize to origin-only prefix (scheme+host+port, no trailing slash, - // no path). Electron's BrowserView drops the root-path trailing slash + // no path). The 's URL drops the root-path trailing slash // after load, so a tab opened at `http://127.0.0.1:49187/` ends up with // currentUrl = `http://127.0.0.1:49187` — a raw `startsWith` against the // manifest-provided url (which keeps the slash) would miss it. Comparing diff --git a/apps/web/src/features/browser/hooks/useBrowserDetach.ts b/apps/web/src/features/browser/hooks/useBrowserDetach.ts deleted file mode 100644 index ff47f1df7..000000000 --- a/apps/web/src/features/browser/hooks/useBrowserDetach.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Hook to manage detaching/reattaching the browser into a separate OS window. - * - * In Electron, uses window.electronAPI.invoke to create a new BrowserWindow - * that loads the same React app with a query param (?window=browser-detached). - * Detached window state is tracked globally (runtime only) because there is - * only one physical detached browser window across all workspaces. - */ - -import { useCallback, useEffect } from "react"; -import { native } from "@/platform"; -import { BROWSER_WORKSPACE_CHANGE, BROWSER_DETACHED_CLOSED } from "@shared/events"; -import { - browserWindowActions, - useBrowserWindowStore, - type DetachedBrowserWorkspaceContext, -} from "@/features/browser/store"; - -function buildDetachUrl(context: DetachedBrowserWorkspaceContext): string { - const params = new URLSearchParams({ - window: "browser-detached", - workspaceId: context.workspaceId, - }); - if (context.directoryName) params.set("directoryName", context.directoryName); - if (context.repoName) params.set("repoName", context.repoName); - if (context.branch) params.set("branch", context.branch); - return `/?${params.toString()}`; -} - -function buildWindowTitle(context: DetachedBrowserWorkspaceContext): string { - const repo = context.repoName ?? context.directoryName ?? "Workspace"; - const branch = context.branch ? ` / ${context.branch}` : ""; - return `Browser - ${repo}${branch}`; -} - -export function useBrowserDetach(context: DetachedBrowserWorkspaceContext | null) { - const isDetached = useBrowserWindowStore((s) => s.detachedWindowOpen); - - const detach = useCallback(async () => { - if (!context) return; - - // Mark as detached before creating the window so the main UI updates immediately. - browserWindowActions.setDetachedWindowOpen(context); - - try { - await native.browserViews.createDetachedWindow({ - url: buildDetachUrl(context), - title: buildWindowTitle(context), - width: 960, - height: 700, - minWidth: 820, - minHeight: 560, - }); - - await native.events.send(BROWSER_WORKSPACE_CHANGE, context); - } catch (e) { - console.error("[BrowserDetach] Failed to create detached window:", e); - browserWindowActions.clearDetachedWindow(); - } - }, [context]); - - const reattach = useCallback(async () => { - browserWindowActions.clearDetachedWindow(); - - // Close the detached window if it exists - try { - await native.browserViews.closeDetachedWindow(); - } catch { - // Window may already be closed - } - }, []); - - // When the user closes the detached OS window directly (via title bar X), - // the main process sends "browser:detached-closed" — reset our store so - // the main window UI reflects the reattached state. - useEffect(() => { - // native.events.on handles the capability check internally — no-op in web mode - const unlisten = native.events.on(BROWSER_DETACHED_CLOSED, () => { - browserWindowActions.clearDetachedWindow(); - }); - - return unlisten; - }, []); - - return { isDetached, detach, reattach }; -} diff --git a/apps/web/src/features/browser/hooks/useWebview.ts b/apps/web/src/features/browser/hooks/useWebview.ts new file mode 100644 index 000000000..39fd0300d --- /dev/null +++ b/apps/web/src/features/browser/hooks/useWebview.ts @@ -0,0 +1,71 @@ +/** + * useWebview — declaratively sync a instance to React-owned state. + * + * Caller computes the target bounds from its own layout (ResizeObserver on + * a panel container, viewport emulation math, etc.) and passes them in. + * The hook does one thing: on every commit, call `instance.sync(bounds, + * isVisible)`. No DOM measurement, no ResizeObserver, no rAF gymnastics. + * All the stability (mid-transition fallback to last visible bounds) is + * inside WebviewInstance.sync() — see webview-manager.ts. + */ +/* eslint-env browser */ + +import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import { + type Bounds, + type WebviewElement, + type WebviewInstance, + webviewManager, +} from "../webview-manager"; + +interface UseWebviewOptions { + /** Stable id — usually the tab id. Reused across re-renders. */ + id: string; + /** URL used on first instantiation only. Later navigation goes through + * the returned webview element's imperative API (loadURL, goBack, …). */ + initialUrl: string; + /** Target bounds for the container, or null while the caller + * has no layout yet (e.g. before first ResizeObserver fire). */ + bounds: Bounds | null; + /** Whether the panel hosting this tab is currently visible. */ + isVisible: boolean; +} + +interface UseWebviewResult { + /** Imperative access to the element. Stable across re-renders. */ + getWebview: () => WebviewElement | null; + /** Access the underlying instance for event subscription. */ + getInstance: () => WebviewInstance | null; +} + +export function useWebview({ + id, + initialUrl, + bounds, + isVisible, +}: UseWebviewOptions): UseWebviewResult { + const instanceRef = useRef(null); + if (instanceRef.current === null) { + instanceRef.current = webviewManager.getOrCreate(id, initialUrl); + } + + // Single sync point — writes container style synchronously at commit time. + useLayoutEffect(() => { + instanceRef.current?.sync({ bounds, isVisible }); + }, [bounds, isVisible]); + + // Detach on unmount. The instance stays alive in the manager so tab switches + // don't tear down the guest page; explicit disposal happens via + // `webviewManager.dispose(id)` from close-tab handlers. + useEffect(() => { + return () => instanceRef.current?.detach(); + }, []); + + const getWebview = useCallback<() => WebviewElement | null>( + () => instanceRef.current?.webview ?? null, + [] + ); + const getInstance = useCallback<() => WebviewInstance | null>(() => instanceRef.current, []); + + return { getWebview, getInstance }; +} diff --git a/apps/web/src/features/browser/store/browserWindowStore.ts b/apps/web/src/features/browser/store/browserWindowStore.ts index 439bbd57e..e322a013f 100644 --- a/apps/web/src/features/browser/store/browserWindowStore.ts +++ b/apps/web/src/features/browser/store/browserWindowStore.ts @@ -1,13 +1,6 @@ import { create } from "zustand"; import { devtools } from "zustand/middleware"; -export interface DetachedBrowserWorkspaceContext { - workspaceId: string; - directoryName: string | null; - repoName: string | null; - branch: string | null; -} - /** Queue-style payload for requesting a Browser tab open with a pre-set URL. * Workspace-scoped so a stale request from a different workspace can't * open a tab in the current one. Pattern mirrors chatInsertStore — producer @@ -31,45 +24,25 @@ export interface PendingCloseTabRequest { } interface BrowserWindowState { - detachedWindowOpen: boolean; - detachedWorkspace: DetachedBrowserWorkspaceContext | null; pendingNewTab: PendingNewTabRequest | null; pendingCloseTab: PendingCloseTabRequest | null; - setDetachedWindowOpen: (context: DetachedBrowserWorkspaceContext) => void; - clearDetachedWindow: () => void; + /** Focus mode per workspace — boolean toggle for the Codex-style overlay. + * Not persisted (transient UI state); on app reload it resets to off. */ + focusModeByWorkspace: Record; + requestNewTab: (workspaceId: string, url: string) => void; consumePendingNewTab: () => void; requestCloseTabByUrlPrefix: (workspaceId: string, urlPrefix: string) => void; consumePendingCloseTab: () => void; + setFocusMode: (workspaceId: string, enabled: boolean) => void; } export const useBrowserWindowStore = create()( devtools( (set) => ({ - detachedWindowOpen: false, - detachedWorkspace: null, pendingNewTab: null, pendingCloseTab: null, - - setDetachedWindowOpen: (context) => - set( - { - detachedWindowOpen: true, - detachedWorkspace: context, - }, - false, - "browserWindow/setDetachedWindowOpen" - ), - - clearDetachedWindow: () => - set( - { - detachedWindowOpen: false, - detachedWorkspace: null, - }, - false, - "browserWindow/clearDetachedWindow" - ), + focusModeByWorkspace: {}, requestNewTab: (workspaceId, url) => set( @@ -94,6 +67,15 @@ export const useBrowserWindowStore = create()( consumePendingCloseTab: () => set({ pendingCloseTab: null }, false, "browserWindow/consumePendingCloseTab"), + + setFocusMode: (workspaceId, enabled) => + set( + (s) => ({ + focusModeByWorkspace: { ...s.focusModeByWorkspace, [workspaceId]: enabled }, + }), + false, + "browserWindow/setFocusMode" + ), }), { name: "browser-window-store", @@ -103,13 +85,18 @@ export const useBrowserWindowStore = create()( ); export const browserWindowActions = { - setDetachedWindowOpen: (context: DetachedBrowserWorkspaceContext) => - useBrowserWindowStore.getState().setDetachedWindowOpen(context), - clearDetachedWindow: () => useBrowserWindowStore.getState().clearDetachedWindow(), requestNewTab: (workspaceId: string, url: string) => useBrowserWindowStore.getState().requestNewTab(workspaceId, url), consumePendingNewTab: () => useBrowserWindowStore.getState().consumePendingNewTab(), requestCloseTabByUrlPrefix: (workspaceId: string, urlPrefix: string) => useBrowserWindowStore.getState().requestCloseTabByUrlPrefix(workspaceId, urlPrefix), consumePendingCloseTab: () => useBrowserWindowStore.getState().consumePendingCloseTab(), + setFocusMode: (workspaceId: string, enabled: boolean) => + useBrowserWindowStore.getState().setFocusMode(workspaceId, enabled), + toggleFocusMode: (workspaceId: string) => { + const current = useBrowserWindowStore.getState().focusModeByWorkspace[workspaceId] ?? false; + useBrowserWindowStore.getState().setFocusMode(workspaceId, !current); + }, + isFocusMode: (workspaceId: string) => + useBrowserWindowStore.getState().focusModeByWorkspace[workspaceId] ?? false, }; diff --git a/apps/web/src/features/browser/store/index.ts b/apps/web/src/features/browser/store/index.ts index 371eab390..e4037aa1d 100644 --- a/apps/web/src/features/browser/store/index.ts +++ b/apps/web/src/features/browser/store/index.ts @@ -1,2 +1 @@ export { useBrowserWindowStore, browserWindowActions } from "./browserWindowStore"; -export type { DetachedBrowserWorkspaceContext } from "./browserWindowStore"; diff --git a/apps/web/src/features/browser/types.ts b/apps/web/src/features/browser/types.ts index 6b6d03ff8..3d1111d99 100644 --- a/apps/web/src/features/browser/types.ts +++ b/apps/web/src/features/browser/types.ts @@ -4,23 +4,37 @@ export interface ConsoleLog { message: string; } -/** CDP device emulation state for a browser tab */ -export interface ViewportState { - width: number; - height: number; - deviceScaleFactor: number; - /** Whether to enable touch emulation + mobile UA. Preserved from device - * preset metadata — width heuristics misclassify tablets (820px). */ - mobile?: boolean; +/** The guest-side blank page Electron's loads before any real + * navigation. It's an implementation detail of the embedder, not a + * user-facing URL — never show it in the URL bar, tab title, or history. */ +export const BLANK_URL = "about:blank"; + +/** True for URLs that should be treated as "no page loaded yet" in the UI — + * empty string or the webview's initial `about:blank`. */ +export function isBlankUrl(url: string | null | undefined): boolean { + return !url || url === BLANK_URL; } +/** Window-level DOM event that asks the browser URL input to focus + select. + * One channel, many triggers: Cmd+L from the guest preload and the "new tab" + * button both fire it; the BrowserPanel's single listener handles them all. */ +export const FOCUS_URL_BAR_EVENT = "deus:browser:focus-url-bar"; + +/** Mobile preview dimensions — a single fixed iPhone-class viewport used when + * a tab toggles on mobile view. Not configurable by the user; the goal is + * "do my mobile breakpoints fire?", not "pixel-match iPhone 14 Pro". */ +export const MOBILE_PREVIEW_WIDTH = 390; +export const MOBILE_PREVIEW_HEIGHT = 852; +export const MOBILE_PREVIEW_DPR = 3; + /** Lightweight tab state persisted in the workspace layout store. * Only what's needed to restore a tab — webviews are destroyed/recreated. */ export interface PersistedBrowserTab { id: string; url: string; // last loaded URL title: string; // display title - viewport?: ViewportState | null; + /** Tab is in mobile preview mode (narrow centered frame + mobile CDP). */ + isMobileView?: boolean; /** Persisted so AAP `apps:stopped` can still match an app-owned tab after a * workspace reload; see BrowserTabState.openedAt for the full rationale. */ openedAt?: string; @@ -28,9 +42,7 @@ export interface PersistedBrowserTab { export interface BrowserTabState { id: string; - /** Unique label for the native Electron BrowserView instance */ - webviewLabel: string; - /** Display title: auto-derived from URL domain, overridden by native title events */ + /** Display title: auto-derived from URL domain, overridden by webview title events */ title: string; /** Value in URL input bar */ url: string; @@ -40,7 +52,8 @@ export interface BrowserTabState { history: string[]; /** Current position in history */ historyIndex: number; - /** Whether the native BrowserView is loading */ + /** Whether the guest page is loading (derived from did-start / + * did-stop-loading events) */ loading: boolean; /** Error message if page load failed */ error: string | null; @@ -54,8 +67,10 @@ export interface BrowserTabState { devtoolsOpen: boolean; /** Console logs for this tab */ consoleLogs: ConsoleLog[]; - /** CDP device emulation — null means responsive (no emulation) */ - viewport: ViewportState | null; + /** Mobile preview mode: narrow centered frame + CDP mobile emulation (UA, + * touch, DPR). When false, the webview fills the panel and no emulation + * is applied — the page sees the panel's actual pixel dimensions. */ + isMobileView: boolean; /** The URL the tab was originally opened for, or undefined for tabs opened * without a target (e.g. the "New Tab" button). Immutable once set — * navigation and load failures never overwrite it. Used to match tabs @@ -106,27 +121,37 @@ export interface BrowserTabHandle { /** Native session history forward — preserves scroll/form state */ goForward: () => void; reload: () => void; - injectAutomation: () => Promise; + /** Inject inspect-mode + visual-effects scripts. Returns true on success. */ + injectAutomation: () => Promise; toggleElementSelector: () => void; + /** Capture the current page as a PNG data URL (or null on failure). */ + captureScreenshot?: () => Promise; + /** Open devtools in the tab-owning web contents. */ + openDevtools?: () => Promise; + /** Close devtools. */ + closeDevtools?: () => Promise; } /** Extract a readable title from a URL (domain or localhost:port) */ export function deriveTitleFromUrl(url: string): string { - if (!url) return "New Tab"; + if (isBlankUrl(url)) return "New Tab"; try { const parsed = new URL(url); if (parsed.hostname === "localhost") { return `localhost${parsed.port ? ":" + parsed.port : ""}`; } + // about:* and other non-http URLs parse but have empty hostnames — fall + // back to "New Tab" rather than showing a blank title. + if (!parsed.hostname) return "New Tab"; return parsed.hostname.replace(/^www\./, ""); } catch { return "New Tab"; } } -/** Create a fresh browser tab with default state. - * When workspaceId is provided, scopes the webview label to prevent - * collisions across workspaces. */ +/** Create a fresh browser tab with default state. Tab IDs are scoped to a + * workspace so they don't collide across workspaces in the WebviewManager + * (which keys instances by tab id). */ export function createBrowserTab(workspaceId?: string | null): BrowserTabState { const rand = Math.random().toString(36).slice(2, 6); const id = workspaceId @@ -134,7 +159,6 @@ export function createBrowserTab(workspaceId?: string | null): BrowserTabState { : `browser-tab-${Date.now()}-${rand}`; return { id, - webviewLabel: id, title: "New Tab", url: "", currentUrl: "", @@ -147,24 +171,26 @@ export function createBrowserTab(workspaceId?: string | null): BrowserTabState { selectorActive: false, devtoolsOpen: false, consoleLogs: [], - viewport: null, + isMobileView: false, }; } /** Hydrate a PersistedBrowserTab into a full BrowserTabState with - * ephemeral defaults (loading, history, consoleLogs, etc.) */ + * ephemeral defaults (loading, history, consoleLogs, etc.). Blank URLs + * from legacy persistence are scrubbed here so they never re-surface in + * the URL bar or history on reload. */ export function hydratePersistedTab(persisted: PersistedBrowserTab): BrowserTabState { - // Use persisted.id as a stable webview label — same tab always maps to the - // same native view. This enables view parking: parked views can be recalled - // by label without creating a new native WebContentsView. + const storedUrl = isBlankUrl(persisted.url) ? "" : persisted.url; return { id: persisted.id, - webviewLabel: persisted.id, - title: persisted.title || deriveTitleFromUrl(persisted.url), - url: persisted.url, - currentUrl: persisted.url, - history: persisted.url ? [persisted.url] : [], - historyIndex: persisted.url ? 0 : -1, + title: + persisted.title && !isBlankUrl(persisted.title) + ? persisted.title + : deriveTitleFromUrl(storedUrl), + url: storedUrl, + currentUrl: storedUrl, + history: storedUrl ? [storedUrl] : [], + historyIndex: storedUrl ? 0 : -1, loading: false, error: null, injected: false, @@ -172,7 +198,7 @@ export function hydratePersistedTab(persisted: PersistedBrowserTab): BrowserTabS selectorActive: false, devtoolsOpen: false, consoleLogs: [], - viewport: persisted.viewport ?? null, + isMobileView: persisted.isMobileView ?? false, openedAt: persisted.openedAt, }; } diff --git a/apps/web/src/features/browser/ui/BrowserDetachedPlaceholder.tsx b/apps/web/src/features/browser/ui/BrowserDetachedPlaceholder.tsx deleted file mode 100644 index 46d258bbc..000000000 --- a/apps/web/src/features/browser/ui/BrowserDetachedPlaceholder.tsx +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Shown in the main window's right side panel when the browser - * has been popped out into a separate OS window. - */ - -import { ExternalLink } from "lucide-react"; -import { Button } from "@/components/ui/button"; - -interface BrowserDetachedPlaceholderProps { - onReattach: () => void; -} - -export function BrowserDetachedPlaceholder({ onReattach }: BrowserDetachedPlaceholderProps) { - return ( -
- -

Browser is in a separate window

- -
- ); -} diff --git a/apps/web/src/features/browser/ui/BrowserPanel.tsx b/apps/web/src/features/browser/ui/BrowserPanel.tsx index 0be5fd893..ed8814909 100644 --- a/apps/web/src/features/browser/ui/BrowserPanel.tsx +++ b/apps/web/src/features/browser/ui/BrowserPanel.tsx @@ -11,7 +11,7 @@ * unmount and lazily recreated from persisted URLs on remount. */ -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect, useRef, useCallback, type ReactNode } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -22,33 +22,46 @@ import { DropdownMenuSeparator, DropdownMenuLabel, } from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { RefreshCw, ChevronLeft, ChevronRight, Terminal, MousePointer2, - X, Cookie, Check, Loader2, Trash2, Camera, + Monitor, + Smartphone, } from "lucide-react"; import { BrowserTabBar } from "./BrowserTabBar"; import { BrowserTab } from "./BrowserTab"; +import { FocusModeOverlay } from "./FocusModeOverlay"; +import { webviewManager } from "../webview-manager"; +import { useSidebar } from "@/components/ui"; import type { BrowserTabState, BrowserTabHandle, ConsoleLog, PersistedBrowserTab, ElementSelectedEvent, - ViewportState, } from "../types"; -import { createBrowserTab, deriveTitleFromUrl, hydratePersistedTab } from "../types"; -import { ViewportDropdown } from "./ViewportDropdown"; -import { workspaceLayoutActions } from "@/features/workspace/store/workspaceLayoutStore"; -import { chatInsertActions } from "@/shared/stores/chatInsertStore"; +import { + createBrowserTab, + deriveTitleFromUrl, + hydratePersistedTab, + isBlankUrl, + FOCUS_URL_BAR_EVENT, +} from "../types"; +import { + workspaceLayoutActions, + useWorkspaceLayoutStore, +} from "@/features/workspace/store/workspaceLayoutStore"; +import { sessionComposerActions } from "@/features/session/store/sessionComposerStore"; +import { processImageFiles } from "@/features/session/lib/imageAttachments"; import { useBrowserWindowStore, browserWindowActions } from "../store/browserWindowStore"; import { native } from "@/platform"; import { BROWSER_NEW_TAB_REQUESTED } from "@shared/events"; @@ -65,14 +78,14 @@ interface InstalledBrowser { interface BrowserPanelProps { workspaceId: string | null; - /** Whether the browser panel is the active (visible) right-side tab. - * When false, the panel stays mounted (preserving webview instances) - * but all native BrowserViews are hidden via IPC. */ + /** Whether the Browser content tab is currently the active one in the + * workspace view. When false, the whole panel is hidden by its parent + * wrapper (`invisible absolute`), but elements live in + * document.body and don't see that CSS — we must forward this down so + * the webviews hide themselves via the useWebview hook. + * + * Defaults true so out-of-tree callers (storybook, tests) still work. */ panelVisible?: boolean; - /** Pop-out callback — shown as a button in the tab bar */ - onDetach?: () => void; - /** Which Electron window hosts the child BrowserViews. Defaults to "main". */ - windowLabel?: string; } /** Load or create browser tabs for a workspace from persisted layout state */ @@ -91,25 +104,34 @@ function loadWorkspaceTabs(wsId: string | null): { tabs: BrowserTabState[]; acti return { tabs: [tab], activeTabId: tab.id }; } -/** Serialize tabs for persistence — only tabs with a loaded URL */ +/** Single-line wrapper that replaces the native `title=` attribute on every + * toolbar button with the design-system Tooltip. All triggers below sit + * under one TooltipProvider so hovering across siblings reuses the delay. */ +function IconTooltip({ label, children }: { label: ReactNode; children: ReactNode }) { + return ( + + {children} + {label} + + ); +} + +/** Serialize tabs for persistence — only tabs with a real loaded URL. + * Blank/about:blank tabs are ephemeral (always recreated on mount), so we + * don't write them to localStorage. */ function serializeTabs(tabs: BrowserTabState[]): PersistedBrowserTab[] { return tabs - .filter((t) => t.currentUrl) + .filter((t) => !isBlankUrl(t.currentUrl)) .map((t) => ({ id: t.id, url: t.currentUrl, title: t.title, - ...(t.viewport ? { viewport: t.viewport } : {}), + ...(t.isMobileView ? { isMobileView: true } : {}), ...(t.openedAt ? { openedAt: t.openedAt } : {}), })); } -export function BrowserPanel({ - workspaceId, - panelVisible = true, - onDetach, - windowLabel, -}: BrowserPanelProps) { +export function BrowserPanel({ workspaceId, panelVisible = true }: BrowserPanelProps) { // --- Initialize tabs from persisted state or create a fresh empty tab --- const [{ tabs: initialTabs, activeTabId: initialActiveId }] = useState(() => loadWorkspaceTabs(workspaceId) @@ -117,6 +139,99 @@ export function BrowserPanel({ const [tabs, setTabs] = useState(initialTabs); const [activeTabId, setActiveTabId] = useState(initialActiveId); + // Focus mode — toggle lives in `browserWindowStore.focusModeByWorkspace`. + // When flipped ON, we stash the current layout and collapse chat + sidebar; + // flipped OFF, we restore. The ContentTabBar button drives this flag. + const focusMode = useBrowserWindowStore((s) => + workspaceId ? (s.focusModeByWorkspace[workspaceId] ?? false) : false + ); + + // Chat-panel collapsed state. The overlay composer appears whenever + // chat is collapsed AND we're on the Browser tab — the user either + // dragged the splitter to collapse or clicked the focus button. Either + // way, we give them the floating composer so they can keep chatting + // without a visible chat panel. + const chatCollapsed = useWorkspaceLayoutStore((s) => + workspaceId ? (s.layouts[workspaceId]?.chatPanelCollapsed ?? false) : false + ); + const showFocusOverlay = (focusMode || chatCollapsed) && panelVisible && !!workspaceId; + const { open: sidebarOpen, setOpen: setSidebarOpen } = useSidebar(); + const previousLayoutRef = useRef<{ chatCollapsed: boolean; sidebarOpen: boolean } | null>(null); + + // Hold the latest values in refs so the focus-mode side-effect doesn't + // re-fire just because `setSidebarOpen` or `sidebarOpen` changed identity + // (useSidebar re-memoises setOpen on every open flip, which used to + // reset focus mode mid-entry). + const setSidebarOpenRef = useRef(setSidebarOpen); + const sidebarOpenRef = useRef(sidebarOpen); + useEffect(() => { + setSidebarOpenRef.current = setSidebarOpen; + sidebarOpenRef.current = sidebarOpen; + }); + + // Apply / revert the layout changes when focus mode toggles. + useEffect(() => { + if (!workspaceId) return; + if (focusMode) { + if (!previousLayoutRef.current) { + const layout = workspaceLayoutActions.getLayout(workspaceId); + previousLayoutRef.current = { + chatCollapsed: layout.chatPanelCollapsed, + sidebarOpen: sidebarOpenRef.current, + }; + } + workspaceLayoutActions.setChatPanelCollapsed(workspaceId, true); + setSidebarOpenRef.current(false); + } else if (previousLayoutRef.current) { + workspaceLayoutActions.setChatPanelCollapsed( + workspaceId, + previousLayoutRef.current.chatCollapsed + ); + setSidebarOpenRef.current(previousLayoutRef.current.sidebarOpen); + previousLayoutRef.current = null; + } + }, [focusMode, workspaceId]); + + // On workspace switch: exit focus mode (so the overlay doesn't follow the + // user to a different workspace) and restore the previous layout. + useEffect(() => { + return () => { + if (!workspaceId) return; + if (previousLayoutRef.current) { + workspaceLayoutActions.setChatPanelCollapsed( + workspaceId, + previousLayoutRef.current.chatCollapsed + ); + setSidebarOpenRef.current(previousLayoutRef.current.sidebarOpen); + previousLayoutRef.current = null; + } + browserWindowActions.setFocusMode(workspaceId, false); + }; + }, [workspaceId]); + + // Esc anywhere exits focus mode. + useEffect(() => { + if (!focusMode || !workspaceId) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") browserWindowActions.setFocusMode(workspaceId, false); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [focusMode, workspaceId]); + + // Auto-exit when the Browser content tab is no longer active — otherwise + // the portal-rendered overlay would keep floating over whatever tab the + // user switched to (Apps / Files / etc). + useEffect(() => { + if (!panelVisible && focusMode && workspaceId) { + browserWindowActions.setFocusMode(workspaceId, false); + } + }, [panelVisible, focusMode, workspaceId]); + + const exitFocusMode = useCallback(() => { + if (workspaceId) browserWindowActions.setFocusMode(workspaceId, false); + }, [workspaceId]); + // Imperative handles per tab const tabRefs = useRef>(new Map()); @@ -129,35 +244,54 @@ export function BrowserPanel({ // Derived: active tab for nav bar state const activeTab = tabs.find((t) => t.id === activeTabId) ?? null; - // --- Centralized webview visibility guard --- - // Native WKWebViews render above the DOM, so CSS can't hide them. - // Individual BrowserTabs manage their own show/hide via effects, but - // race conditions during rapid tab switches can leave stale webviews - // visible. This guard explicitly hides all non-active webviews whenever - // the active tab or panel visibility changes — belt-and-suspenders. - // "Latest value" refs — intentionally updated during render so downstream - // effects / callbacks read the most recent tab list without re-subscribing. - // React 19's `react-hooks/refs` rule flags this pattern, but refactoring - // it into a post-commit effect breaks React Compiler's ability to preserve - // manual memoization of dependent callbacks below. Pre-existing; unrelated - // to AAP changes in this PR. Disable is scoped to this block only. - /* eslint-disable react-hooks/refs */ + // "Latest value" refs used by stable callbacks (closeTab, handleTabSelect) + // — child event handlers read the most recent tab list without forcing + // the callback to re-memoize. Updated in an effect (not during render) + // to satisfy react-hooks/refs; event handlers fire after commit anyway. const tabInfoRef = useRef(tabs); - tabInfoRef.current = tabs; const activeTabIdRef = useRef(activeTabId); - activeTabIdRef.current = activeTabId; - /* eslint-enable react-hooks/refs */ + useEffect(() => { + tabInfoRef.current = tabs; + activeTabIdRef.current = activeTabId; + }); + + // URL bar focus — dispatched by BrowserTab when the guest preload sees Cmd+L. + const urlInputRef = useRef(null); + + // The panel container that hosts browser tabs — used as the anchor rect + // for the portal-rendered FocusModeOverlay so it can sit visually over + // the browser view despite living outside the component tree. Held in + // state (via callback ref) rather than a plain ref so we can pass the + // element to the overlay without reading `.current` during render. + const [tabHostEl, setTabHostEl] = useState(null); + useEffect(() => { + const onFocus = () => { + urlInputRef.current?.focus(); + urlInputRef.current?.select(); + }; + window.addEventListener(FOCUS_URL_BAR_EVENT, onFocus); + return () => window.removeEventListener(FOCUS_URL_BAR_EVENT, onFocus); + }, []); + // Fire the focus-url-bar event on the next frame — rAF lets React commit + // the new tab / activeTabId first so the is enabled when the + // listener runs `urlInputRef.current?.focus()`. + const requestFocusUrlBar = useCallback(() => { + requestAnimationFrame(() => { + window.dispatchEvent(new CustomEvent(FOCUS_URL_BAR_EVENT)); + }); + }, []); + + // Auto-focus the URL bar whenever the active tab has no real URL and the + // panel is visible — covers app launch, workspace switch, and tab switch + // landing on an empty tab. Explicit user actions (addTab, close-last-tab) + // also call requestFocusUrlBar(); this effect is the passive safety net. + const activeTabUrl = activeTab?.currentUrl ?? ""; useEffect(() => { - const currentTabs = tabInfoRef.current; - for (const tab of currentTabs) { - if (tab.id !== activeTabId || !panelVisible) { - native.browserViews.hide(tab.webviewLabel).catch(() => { - /* Expected: view may not exist yet or IPC unavailable */ - }); - } - } - }, [activeTabId, panelVisible]); + if (!panelVisible) return; + if (!isBlankUrl(activeTabUrl)) return; + requestFocusUrlBar(); + }, [activeTabId, activeTabUrl, panelVisible, requestFocusUrlBar]); // --- Persist tab state to workspace layout store (debounced) --- const persistTabs = useCallback( @@ -183,49 +317,33 @@ export function BrowserPanel({ }, []); // --- Swap tabs when workspaceId changes (workspace switch) --- - // BrowserPanel stays mounted to preserve WKWebView lifecycle, so we - // manually swap tab state instead of relying on remount. - // - // CRITICAL: We must explicitly close old native webviews BEFORE setting - // new tabs. BrowserTab's unmount cleanup also calls close, but it's async - // and races with the new workspace's webviews — causing overlapping webviews - // (native webviews render above the DOM so CSS can't hide them). + // BrowserPanel stays mounted across workspace switches. The DOM-resident + // elements owned by webviewManager survive the swap too — tabs + // for other workspaces are simply hidden off-screen until their workspace + // is re-selected. No park/unpark / race-condition dance is needed: CSS + // visibility handles it naturally. useEffect(() => { const prevId = prevWorkspaceIdRef.current; if (prevId === workspaceId) return; - // Flush pending persistence for the old workspace immediately if (persistTimerRef.current) { clearTimeout(persistTimerRef.current); persistTimerRef.current = null; } - // Read refs unconditionally — always fresh, no stale closure risk - const currentTabs = tabInfoRef.current; - const currentActiveId = activeTabIdRef.current; if (prevId) { - // Persist current tabs to old workspace workspaceLayoutActions.setLayout(prevId, { - browserTabs: serializeTabs(currentTabs), - activeBrowserTabId: currentActiveId, - }); - } - - // Park old views instead of destroying them — keeps native WebContentsViews - // alive so they can be recalled without page reload when switching back. - // Views are hidden immediately (WKWebView setHidden:YES) and stay in the - // main process views Map so existing IPC handlers still work. - for (const tab of currentTabs) { - native.browserViews.hide(tab.webviewLabel).catch(() => { - /* Expected: view may already be destroyed during workspace switch */ + browserTabs: serializeTabs(tabInfoRef.current), + activeBrowserTabId: activeTabIdRef.current, }); } - // Load tabs for the new workspace (reuses loadWorkspaceTabs helper) const { tabs: newTabs, activeTabId: newActiveId } = loadWorkspaceTabs(workspaceId); - tabRefs.current.clear(); - + // Sync tabs state to the new workspace — legitimate dependency-driven + // state update (workspaceId is the external signal, tabs are derived + // from persisted per-workspace layout). + // eslint-disable-next-line react-hooks/set-state-in-effect setTabs(newTabs); setActiveTabId(newActiveId); prevWorkspaceIdRef.current = workspaceId; @@ -270,6 +388,10 @@ export function BrowserPanel({ // Stamp the opening URL so apps:stopped can match this tab even after // Electron overwrites url/currentUrl on load failure (chrome-error). newTab.openedAt = pendingNewTab.url; + // Reacting to a consumer-side effect (new-tab request dispatched via + // the global browserWindowStore) — the setState IS the sync from that + // external store into this component's state. + // eslint-disable-next-line react-hooks/set-state-in-effect setTabs((prev) => { const next = [...prev, newTab]; persistTabs(next, newTab.id); @@ -290,21 +412,17 @@ export function BrowserPanel({ return next; }); setActiveTabId(newTab.id); - }, [workspaceId, persistTabs]); + // New tabs always land on the empty state — put the cursor in the URL + // bar so the user can start typing immediately (matches Chrome/Safari). + requestFocusUrlBar(); + }, [workspaceId, persistTabs, requestFocusUrlBar]); const closeTab = useCallback( (closingTabId: string) => { setTabs((prev) => { - // Close the native webview — lookup from prev (always fresh, no stale closure). - const closingTab = prev.find((t) => t.id === closingTabId); - if (closingTab) { - native.browserViews.hide(closingTab.webviewLabel).catch(() => { - /* Expected: view may already be hidden or destroyed */ - }); - native.browserViews.close(closingTab.webviewLabel).catch(() => { - /* Expected: view may already be closed by unmount cleanup */ - }); - } + // Dispose the element for the closed tab so the guest + // page tears down. Keeps memory tight when many tabs are opened. + webviewManager.dispose(closingTabId); const idx = prev.findIndex((t) => t.id === closingTabId); let newTabs = prev.filter((t) => t.id !== closingTabId); @@ -316,6 +434,7 @@ export function BrowserPanel({ newTabs = [freshTab]; nextActiveId = freshTab.id; setActiveTabId(nextActiveId); + requestFocusUrlBar(); } else if (closingTabId === activeTabIdRef.current) { const nextIdx = Math.min(idx, newTabs.length - 1); nextActiveId = newTabs[nextIdx].id; @@ -327,7 +446,7 @@ export function BrowserPanel({ }); tabRefs.current.delete(closingTabId); }, - [workspaceId, persistTabs] + [workspaceId, persistTabs, requestFocusUrlBar] ); // Close-tab request consumer — triggered by AAP's `apps:stopped` event @@ -341,7 +460,7 @@ export function BrowserPanel({ if (pendingCloseTab.workspaceId !== workspaceId) return; // Match on `openedAt` (the URL the tab was created for) rather than - // `currentUrl`. When an app stops, Electron's BrowserView hits + // `currentUrl`. When an app stops, the hits // ERR_CONNECTION_REFUSED on reload and transitions currentUrl to // `chrome-error://chromewebdata/` — prefix matching on that would // silently miss the tab we intended to close. `openedAt` is immutable. @@ -378,7 +497,7 @@ export function BrowserPanel({ if ( updates.currentUrl !== undefined || updates.title !== undefined || - updates.viewport !== undefined || + updates.isMobileView !== undefined || (updates.loading === false && !updates.error) ) { persistTabs(next, activeTabId); @@ -404,14 +523,18 @@ export function BrowserPanel({ ); }, []); - /** Dispatch element selection to the chat input via chatInsertStore. - * Only handles "element-selected" — "area-selected" is intentionally ignored - * since area selections have no element metadata to reference. */ + /** Push an inspected element into the active chat's composer. We go + * straight to the session composer store — the workspace's active + * chat-tab sessionId is looked up in workspaceLayoutStore. Only + * "element-selected" is handled; "area-selected" is ignored (no + * element metadata to reference). */ const handleElementSelected = useCallback( (_tabId: string, event: ElementSelectedEvent) => { if (event.type !== "element-selected" || !event.element || !workspaceId) return; + const sid = workspaceLayoutActions.getLayout(workspaceId).activeChatTabSessionId; + if (!sid) return; - // Serialize Record fields as semicolon-separated strings + // Serialize Record fields as semicolon-separated strings. const serialize = (rec: Record | undefined, sep: string) => rec ? Object.entries(rec) @@ -419,7 +542,7 @@ export function BrowserPanel({ .join("; ") : undefined; - chatInsertActions.insertElement(workspaceId, { + sessionComposerActions.addInspectedElement(sid, { ref: event.ref ?? "", tagName: event.element.tagName, path: event.element.path, @@ -437,37 +560,37 @@ export function BrowserPanel({ [workspaceId] ); - /** Capture the active tab's BrowserView as PNG and dispatch to chat input */ + /** Capture the active tab's as PNG and attach it to the chat + * composer. Routes through the session composer store so the image + * card appears in every surface (main chat, focus overlay, modal). */ const handleScreenshot = useCallback(async () => { - if (!activeTab?.webviewLabel || !activeTab.currentUrl || !workspaceId) return; + if (!activeTab?.currentUrl || !workspaceId) return; + const sid = workspaceLayoutActions.getLayout(workspaceId).activeChatTabSessionId; + if (!sid) return; + const handle = tabRefs.current.get(activeTab.id); + if (!handle?.captureScreenshot) return; try { - const dataUrl = await native.browserViews.screenshot(activeTab.webviewLabel); + const dataUrl = await handle.captureScreenshot(); if (!dataUrl) return; - // capturePage().toDataURL() returns "data:image/png;base64,..." — strip prefix const base64 = dataUrl.replace(/^data:image\/\w+;base64,/, ""); const binaryStr = atob(base64); const bytes = new Uint8Array(binaryStr.length); - for (let i = 0; i < binaryStr.length; i++) { - bytes[i] = binaryStr.charCodeAt(i); - } + for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i); const blob = new Blob([bytes], { type: "image/png" }); const file = new File([blob], `browser-screenshot-${Date.now()}.png`, { type: "image/png" }); - chatInsertActions.insertFiles(workspaceId, [file]); + const processed = await processImageFiles([file]); + if (processed.length) sessionComposerActions.addImageAttachments(sid, processed); } catch (err) { console.error("Browser screenshot failed:", err); } - }, [activeTab?.webviewLabel, activeTab?.currentUrl, workspaceId]); - - /** Set viewport emulation — state update only. BrowserTab's useLayoutEffect - * handles the IPC (setEmulation/clearEmulation + setBounds) because it knows - * the panel dimensions needed to compute scale-to-fit. */ - const handleViewportChange = useCallback( - (viewport: ViewportState | null) => { - if (!activeTab?.id) return; - handleUpdateTab(activeTab.id, { viewport }); - }, - [activeTab?.id, handleUpdateTab] - ); + }, [activeTab, workspaceId]); + + /** Toggle between desktop (webview fills panel) and mobile preview + * (390-wide centered frame + CDP mobile UA/touch). Per-tab state. */ + const handleToggleMobileView = useCallback(() => { + if (!activeTab?.id) return; + handleUpdateTab(activeTab.id, { isMobileView: !activeTab.isMobileView }); + }, [activeTab, handleUpdateTab]); // --- Navigation (operates on active tab) --- @@ -582,18 +705,14 @@ export function BrowserPanel({ ); const handleToggleDevtools = useCallback(() => { - if (!activeTab?.webviewLabel) return; - if (activeTab.devtoolsOpen) { - native.browserViews - .closeDevtools(activeTab.webviewLabel) - .then(() => handleUpdateTab(activeTab.id, { devtoolsOpen: false })) - .catch((err) => handleAddLog(activeTab.id, "error", `Close devtools failed: ${err}`)); - } else { - native.browserViews - .openDevtools(activeTab.webviewLabel) - .then(() => handleUpdateTab(activeTab.id, { devtoolsOpen: true })) - .catch((err) => handleAddLog(activeTab.id, "error", `Open devtools failed: ${err}`)); - } + if (!activeTab) return; + const handle = tabRefs.current.get(activeTab.id); + if (!handle) return; + const action = activeTab.devtoolsOpen ? handle.closeDevtools : handle.openDevtools; + if (!action) return; + action() + .then(() => handleUpdateTab(activeTab.id, { devtoolsOpen: !activeTab.devtoolsOpen })) + .catch((err) => handleAddLog(activeTab.id, "error", `DevTools toggle failed: ${err}`)); }, [activeTab, handleUpdateTab, handleAddLog]); // --- Cookie Sync --- @@ -660,7 +779,7 @@ export function BrowserPanel({ } return ( -
+
{/* Tab Bar */} - {/* Navigation Bar — h-9 to align with chat tabs row */} -
- - - - - - - e.target.select()} - placeholder="Search or enter URL..." - autoComplete="off" - spellCheck={false} - data-1p-ignore - className="focus-visible:border-border h-7 min-w-0 flex-1 text-sm focus-visible:ring-0" - disabled={!activeTab || activeTab.loading} - /> - - {/* Injection failure indicator — red dot, only visible on error */} - {activeTab?.injectionFailed && ( - <> -
+ {/* Tab content — devtools opens as floating window (docked not yet supported). * See open_browser_devtools in webview.rs for full history of docking attempts. @@ -906,9 +1028,13 @@ export function BrowserPanel({ * keeps tabs in normal flow so they inherit w-[390px] naturally and * ResizeObserver fires on actual size changes. */}
-
+ {showFocusOverlay && workspaceId && ( + + )} +
{tabs.map((tab) => ( ))}
diff --git a/apps/web/src/features/browser/ui/BrowserTab.tsx b/apps/web/src/features/browser/ui/BrowserTab.tsx index 1f91d8fa3..c05f6cc8d 100644 --- a/apps/web/src/features/browser/ui/BrowserTab.tsx +++ b/apps/web/src/features/browser/ui/BrowserTab.tsx @@ -1,80 +1,37 @@ /** - * BrowserTab — native Electron BrowserView for one browser tab. + * BrowserTab — single tab rendered via Electron's HTML element. * - * Architecture: Renders a placeholder
and measures its bounds via - * ResizeObserver. Tells the main process to create/position a native BrowserView there. - * Unlike iframes, native BrowserViews bypass X-Frame-Options so any URL loads. - * - * All tabs stack via CSS Grid ([grid-area:1/1]) in the parent container. - * The native BrowserView floats above the DOM; overlays only show when view is hidden. - * - * Communication channels (each concern gets its own reliable path): - * - IPC events: page-load, title-changed, url-change (push from Electron webContents) - * - executeJavaScript: console drain + inspect event drain (pull from React) - * - * SPA navigation: pushState/replaceState patches in the init script fire - * browser:url-change events so the URL bar stays current. + * The lives in document.body (WebviewManager) and is positioned + * over a placeholder
measured by the useWebview hook. Because + * stacks normally in the DOM, overlays (loading bar, error card, + * dropdowns, focus-mode chat bar) layer above it via plain CSS — no native + * hide/show IPC dance like the old WebContentsView path required. */ +/* eslint-env browser */ import { - useRef, - useState, - useEffect, - useLayoutEffect, useCallback, + useEffect, useImperativeHandle, + useLayoutEffect, + useRef, + useState, forwardRef, } from "react"; -import { match } from "ts-pattern"; +import { AlertCircle, Globe, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { AlertCircle, Loader2, Globe } from "lucide-react"; -import { native } from "@/platform"; -import { BROWSER_PAGE_LOAD, BROWSER_TITLE_CHANGED, BROWSER_URL_CHANGE } from "@shared/events"; -import { getErrorMessage } from "@shared/lib/errors"; -import type { - BrowserTabState, - BrowserTabHandle, - ConsoleLog, - ElementSelectedEvent, - ViewportState, -} from "../types"; -import { deriveTitleFromUrl } from "../types"; - -/** Build emulation params from viewport state + computed scale */ -function emulationParams(vp: ViewportState, scale: number) { - return { - width: vp.width, - height: vp.height, - deviceScaleFactor: vp.deviceScaleFactor, - mobile: vp.mobile ?? false, - scale, - }; -} - -/** Compute WebContentsView bounds and CDP visual scale. - * - Viewport fits in panel → center at 1:1 - * - Viewport exceeds panel → scale to fit (CDP renders full layout, visually shrunk) - * - No emulation → fill panel */ -function computeViewBounds( - panelRect: DOMRect, - vp: ViewportState | null -): { bounds: { x: number; y: number; width: number; height: number }; scale: number } { - if (!vp) { - return { - bounds: { x: panelRect.x, y: panelRect.y, width: panelRect.width, height: panelRect.height }, - scale: 1, - }; - } - const scale = Math.min(panelRect.width / vp.width, panelRect.height / vp.height, 1); - const scaledW = vp.width * scale; - const scaledH = vp.height * scale; - const offsetX = (panelRect.width - scaledW) / 2; - const offsetY = (panelRect.height - scaledH) / 2; - return { - bounds: { x: panelRect.x + offsetX, y: panelRect.y + offsetY, width: scaledW, height: scaledH }, - scale, - }; -} +import { match } from "ts-pattern"; +import { useWebview } from "../hooks/useWebview"; +import { webviewManager, type Bounds } from "../webview-manager"; +import { MOBILE_PREVIEW_WIDTH, MOBILE_PREVIEW_HEIGHT, MOBILE_PREVIEW_DPR } from "../types"; +import { + setEmulation, + clearEmulation, + openDevtools as openDevtoolsMain, + closeDevtools as closeDevtoolsMain, +} from "@/platform/native/browser-views"; +import type { BrowserTabHandle, BrowserTabState, ConsoleLog, ElementSelectedEvent } from "../types"; +import { BLANK_URL, deriveTitleFromUrl, isBlankUrl, FOCUS_URL_BAR_EVENT } from "../types"; import { INSPECT_MODE_SETUP, INSPECT_MODE_ENABLE, @@ -83,577 +40,444 @@ import { INSPECT_MODE_VERIFY, } from "../automation/inspect-mode"; import { VISUAL_EFFECTS_SETUP } from "../automation/visual-effects"; +import { getErrorMessage } from "@shared/lib/errors"; -/** How often to drain console logs from the webview (ms) */ -const CONSOLE_DRAIN_INTERVAL_MS = 1500; -/** How often to drain inspect-mode events from the webview (ms) */ const INSPECT_DRAIN_INTERVAL_MS = 200; interface BrowserTabProps { tab: BrowserTabState; - /** Update tab state in the parent — (tabId, updates) */ onUpdateTab: (tabId: string, updates: Partial) => void; - /** Add a log line to the tab's console — (tabId, level, message) */ onAddLog: (tabId: string, level: ConsoleLog["level"], message: string) => void; - /** Whether this tab is the active (visible) tab */ visible: boolean; - /** Callback when user selects an element in inspect mode */ onElementSelected?: (tabId: string, event: ElementSelectedEvent) => void; - /** Which Electron window to create child BrowserViews in. Defaults to "main". */ - windowLabel?: string; +} + +/** Electron webview console-message event levels (0=verbose, 1=info, 2=warning, 3=error) */ +function levelFromWebviewEvent(level: number): ConsoleLog["level"] { + if (level >= 3) return "error"; + if (level === 2) return "warn"; + if (level === 0) return "debug"; + return "info"; } export const BrowserTab = forwardRef(function BrowserTab( - { tab, onUpdateTab, onAddLog, visible, onElementSelected, windowLabel }, + { tab, onUpdateTab, onAddLog, visible, onElementSelected }, ref ) { - const placeholderRef = useRef(null); - // Ref = source of truth for imperative callbacks (always fresh) - const webviewCreatedRef = useRef(false); - // State = triggers effects when webview is ready - const [webviewReady, setWebviewReady] = useState(false); - // Track whether first page load completed (show overlay only during initial load) - const [hasLoaded, setHasLoaded] = useState(false); - // Brief "completing" state for loading bar finish animation (fill → fade) + const tabId = tab.id; + const initialUrl = tab.currentUrl || BLANK_URL; + + // Panel rect — measured from the outer container. Drives the webview's + // target bounds (either fill the panel or a centered sub-rect when an + // emulated viewport is active). + const panelContainerRef = useRef(null); + const [panelRect, setPanelRect] = useState(null); + + useLayoutEffect(() => { + const el = panelContainerRef.current; + if (!el) return; + const update = () => { + const r = el.getBoundingClientRect(); + setPanelRect({ x: r.x, y: r.y, width: r.width, height: r.height }); + }; + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + window.addEventListener("resize", update); + window.addEventListener("scroll", update, true); + return () => { + ro.disconnect(); + window.removeEventListener("resize", update); + window.removeEventListener("scroll", update, true); + }; + }, []); + + // Bounds policy — two modes only: + // - Desktop (tab.isMobileView === false): webview fills the panel. + // - Mobile: 390-wide centered frame, full panel height. Never scaled; + // if the panel is narrower than 390, fall back to panel width. + // + // Splitter guard: react-resizable-panels' ResizableHandle has a 0-width + // visual but a child hit-zone div that extends 6px into each adjacent + // panel (`w-3 -translate-x-1/2`). The webview is `position: fixed` at + // the panel's exact rect, which paints over that 6px zone and captures + // its pointer events. Reserving 6px on each horizontal edge uncovers + // the hit zone. The visual cost is a thin sliver of panel background. + const SPLITTER_GUARD = 6; + const bounds: Bounds | null = (() => { + if (!panelRect) return null; + const available = Math.max(0, panelRect.width - SPLITTER_GUARD * 2); + const w = tab.isMobileView ? Math.min(MOBILE_PREVIEW_WIDTH, available) : available; + return { + x: panelRect.x + (panelRect.width - w) / 2, + y: panelRect.y, + width: w, + height: panelRect.height, + }; + })(); + + const { getWebview } = useWebview({ + id: tabId, + initialUrl, + bounds, + isVisible: visible, + }); + + // Start false even for hydrated tabs — eagerly setting true from + // `tab.currentUrl` would trigger the emulation effect before the + // guest attaches, causing getWebContentsId() to throw and + // the emulation to silently not apply. The webview always fires + // `did-stop-loading` when it finishes loading the initial `src`, + // which is when we flip hasLoaded → true and the effect re-runs + // with a valid webContents to attach the debugger to. + const [hasLoaded, setHasLoaded] = useState(false); const [completingLoad, setCompletingLoad] = useState(false); const completingTimerRef = useRef | null>(null); - // Guard against unmount during async webview creation - const mountedRef = useRef(true); - // Debounce bounds sync to one per animation frame - const rafRef = useRef(0); - // Track whether automation scripts (inspect mode + visual effects) have been injected - const automationInjectedRef = useRef(false); - // Ref to latest tab state — used in event handlers to read current history + + // Latest tab — read inside DOM event handlers without rebinding const tabRef = useRef(tab); tabRef.current = tab; - // Guard: suppress history push during back/forward navigation - // (did-navigate fires for loadURL too, which would double-push) - const suppressHistoryPushRef = useRef(false); + // Stable ref for onElementSelected callback (used by the inspect drain loop) - const onElementSelectedRef = useRef(onElementSelected); + const onElementSelectedRef = useRef(onElementSelected); onElementSelectedRef.current = onElementSelected; - // Track previous viewport to detect changes. Initialized to `undefined` - // (not tab.viewport) so persisted viewports are applied on first mount. - const prevViewportRef = useRef(undefined); - const tabId = tab.id; - const webviewLabel = tab.webviewLabel; + // Suppress history push when navigation was triggered by back/forward + const suppressHistoryPushRef = useRef(false); - // --- Unmount guard --- + // Guard: prevent duplicate automation injection across page loads + const automationInjectedRef = useRef(false); + + // --- Subscribe to DOM events --- useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - cancelAnimationFrame(rafRef.current); + const wv = getWebview(); + if (!wv) return; + + let didFailForCurrentNav = false; + // Every fresh starts by loading `about:blank` from its initial + // `src`. That's an Electron attachment detail, not something the user + // should perceive as "a page is loading" — it would flash the loading + // bar and disable the URL input just as the auto-focus lands. Suppress + // loading/log/state for that first blank pair; flip on first did-stop. + let sawInitialBlank = false; + + const onStartLoading = () => { + didFailForCurrentNav = false; if (completingTimerRef.current) clearTimeout(completingTimerRef.current); + setCompletingLoad(false); + if (!sawInitialBlank && isBlankUrl(tabRef.current.currentUrl)) return; + onUpdateTab(tabId, { loading: true }); }; - }, []); - // --- Create webview (lazy, on first navigation) --- + const onStopLoading = () => { + if (didFailForCurrentNav) return; + const url = wv.getURL(); + if (isBlankUrl(url)) { + // Initial attach won't have set loading=true (suppressed above), so + // this is effectively a no-op there. But if we got here via a real + // round-trip to about:blank (e.g. cookie-clear), unwind loading. + sawInitialBlank = true; + onUpdateTab(tabId, { loading: false }); + return; + } + sawInitialBlank = true; + setHasLoaded(true); + setCompletingLoad(true); + completingTimerRef.current = setTimeout(() => setCompletingLoad(false), 500); + onUpdateTab(tabId, { loading: false, currentUrl: url, error: null }); + onAddLog(tabId, "info", `Page loaded: ${url}`); + }; - const createWebviewIfNeeded = useCallback( - async (url: string) => { - if (webviewCreatedRef.current) return; + const onFailLoad = (event: Event) => { + const e = event as unknown as { + errorCode: number; + errorDescription: string; + validatedURL: string; + isMainFrame: boolean; + }; + // Ignore subframe errors and user-aborted navigations (-3 = ABORTED) + if (!e.isMainFrame) return; + if (e.errorCode === -3) return; + didFailForCurrentNav = true; + if (completingTimerRef.current) clearTimeout(completingTimerRef.current); + setCompletingLoad(false); + setHasLoaded(true); + const desc = e.errorDescription || "Page failed to load"; + onUpdateTab(tabId, { loading: false, error: desc }); + onAddLog(tabId, "error", `Page failed to load: ${desc}`); + }; - // Set flag SYNCHRONOUSLY before await to prevent the auto-navigate - // effect from re-firing during the async gap (onUpdateTab triggers - // re-render → effect re-runs → ref still false → duplicate create). - webviewCreatedRef.current = true; + const onDidNavigate = (event: Event) => { + const e = event as unknown as { url: string }; + handleNavigated(e.url); + }; + const onDidNavigateInPage = (event: Event) => { + const e = event as unknown as { url: string; isMainFrame: boolean }; + if (!e.isMainFrame) return; + handleNavigated(e.url); + }; - const el = placeholderRef.current; - if (!el) { - webviewCreatedRef.current = false; + const handleNavigated = (url: string) => { + // Guest-side blank loads (initial src, cookie-clear round-trip) must + // never enter URL bar / history / title — they're an Electron detail. + if (isBlankUrl(url)) return; + if (suppressHistoryPushRef.current) { + suppressHistoryPushRef.current = false; + onUpdateTab(tabId, { url, currentUrl: url, title: deriveTitleFromUrl(url) }); return; } - - const rect = el.getBoundingClientRect(); - - // Try to recall a parked view first (view parking keeps native views - // alive across workspace switches). If the view exists, just reposition - // and show it — no page reload, preserves scroll/form/login state. - try { - const exists = await native.browserViews.viewExists(webviewLabel); - if (exists) { - await native.browserViews.setBounds(webviewLabel, { - x: rect.x, - y: rect.y, - width: Math.max(rect.width, 100), - height: Math.max(rect.height, 100), - }); - if (!mountedRef.current) return; - setWebviewReady(true); - setHasLoaded(true); // page is already loaded in the parked view - // Re-sync native metadata for parked views — the page may have - // navigated (redirects, SPA nav) while parked, so hydrated - // URL/title could be stale. Fetch current values from the view. - try { - const currentUrl = await native.browserViews.evaluateWithResult( - webviewLabel, - "window.location.href", - 2000 - ); - const currentTitle = await native.browserViews.evaluateWithResult( - webviewLabel, - "document.title", - 2000 - ); - if (currentUrl) { - onUpdateTab(tabId, { - loading: false, - currentUrl, - url: currentUrl, - title: currentTitle ?? tab.title, - }); - } else { - onUpdateTab(tabId, { loading: false }); - } - } catch { - onUpdateTab(tabId, { loading: false }); - } - onAddLog(tabId, "info", `Recalled parked webview: ${webviewLabel}`); - return; - } - } catch (err) { - // viewExists failed — fall through to create (log for diagnostics) - onAddLog(tabId, "warn", `viewExists check failed (will create fresh): ${err}`); + const current = tabRef.current; + if (current.currentUrl === url) { + onUpdateTab(tabId, { title: deriveTitleFromUrl(url) }); + return; } + const newHistory = current.history.slice(0, current.historyIndex + 1); + newHistory.push(url); + onUpdateTab(tabId, { + url, + currentUrl: url, + title: deriveTitleFromUrl(url), + history: newHistory, + historyIndex: newHistory.length - 1, + }); + }; - try { - await native.browserViews.create({ - label: webviewLabel, - url, - x: rect.x, - y: rect.y, - width: Math.max(rect.width, 100), - height: Math.max(rect.height, 100), - windowLabel: windowLabel ?? "main", - }); - - // Guard: component may have unmounted during await - if (!mountedRef.current) { - native.browserViews.close(webviewLabel).catch(() => { - /* Expected: component unmounted during create; view may not fully exist */ - }); - return; - } - - setWebviewReady(true); + const onTitleUpdated = (event: Event) => { + const e = event as unknown as { title: string }; + // about:blank / chrome-error pages emit empty or "about:blank" titles — + // don't let them clobber the "New Tab" default. + if (isBlankUrl(e.title)) return; + onUpdateTab(tabId, { title: e.title }); + }; - // Start hidden — will show after first page load completes - native.browserViews.hide(webviewLabel).catch(() => { - /* Expected: fire-and-forget; view starts hidden until first page load */ - }); + const onConsoleMessage = (event: Event) => { + const e = event as unknown as { level: number; message: string }; + onAddLog(tabId, levelFromWebviewEvent(e.level), e.message); + }; - onAddLog(tabId, "info", `Native webview created: ${webviewLabel}`); - } catch (err) { - // Reset flag so "Try Again" can re-attempt creation - webviewCreatedRef.current = false; - if (!mountedRef.current) return; - onUpdateTab(tabId, { error: `Failed to create webview: ${err}`, loading: false }); - onAddLog(tabId, "error", `Webview creation failed: ${err}`); + // Keyboard shortcuts forwarded from the guest preload via sendToHost. + // Channel is "shortcut"; args[0] is one of "reload" | "focus-url-bar". + const onIpcMessage = (event: Event) => { + const e = event as unknown as { channel: string; args: unknown[] }; + if (e.channel !== "shortcut") return; + const shortcut = e.args[0]; + if (shortcut === "reload") { + wv.reload(); + } else if (shortcut === "focus-url-bar") { + // Emit a renderer-global event for BrowserPanel to pick up. + window.dispatchEvent(new CustomEvent(FOCUS_URL_BAR_EVENT)); } - }, - [webviewLabel, tabId, onUpdateTab, onAddLog, windowLabel] - ); + }; + + wv.addEventListener("did-start-loading", onStartLoading); + wv.addEventListener("did-stop-loading", onStopLoading); + wv.addEventListener("did-fail-load", onFailLoad); + wv.addEventListener("did-navigate", onDidNavigate); + wv.addEventListener("did-navigate-in-page", onDidNavigateInPage); + wv.addEventListener("page-title-updated", onTitleUpdated); + wv.addEventListener("console-message", onConsoleMessage); + wv.addEventListener("ipc-message", onIpcMessage); - // --- Cleanup on unmount --- - // Only hides the view — does NOT destroy it. Tab destruction is handled - // explicitly by closeTab() in BrowserPanel. This enables view parking: - // when switching workspaces, views are parked (hidden) and recalled later - // without losing page state. - useEffect(() => { - const label = webviewLabel; return () => { - if (webviewCreatedRef.current) { - native.browserViews.hide(label).catch(() => {}); - } + wv.removeEventListener("did-start-loading", onStartLoading); + wv.removeEventListener("did-stop-loading", onStopLoading); + wv.removeEventListener("did-fail-load", onFailLoad); + wv.removeEventListener("did-navigate", onDidNavigate); + wv.removeEventListener("did-navigate-in-page", onDidNavigateInPage); + wv.removeEventListener("page-title-updated", onTitleUpdated); + wv.removeEventListener("console-message", onConsoleMessage); + wv.removeEventListener("ipc-message", onIpcMessage); }; - }, [webviewLabel]); + }, [tabId, onUpdateTab, onAddLog, getWebview]); - // --- Auto-navigate hydrated tabs (restored from workspace persistence) --- - // When a tab mounts with a persisted URL but no webview, auto-create it. - // This handles workspace-switch where old webviews are destroyed and new - // BrowserTab components mount with pre-filled URLs from the layout store. - useEffect(() => { - if (!visible || webviewCreatedRef.current || !tab.currentUrl) return; - // Schedule async to avoid cascading renders from setState in effect - const timer = setTimeout(() => { - onUpdateTab(tabId, { loading: true }); - createWebviewIfNeeded(tab.currentUrl); - }, 0); - return () => clearTimeout(timer); - }, [visible, tab.currentUrl, tabId, createWebviewIfNeeded, onUpdateTab]); - - // --- Show/hide webview based on visibility + load state --- - // Native webviews render ABOVE the DOM, so we hide them to reveal overlays. - // Show only when: visible, loaded, and no initial error. useEffect(() => { - if (!webviewReady) return; - - if (visible && hasLoaded) { - // Sync bounds before showing (layout may have changed while hidden) - const el = placeholderRef.current; - if (el) { - const rect = el.getBoundingClientRect(); - native.browserViews - .setBounds(webviewLabel, { - x: rect.x, - y: rect.y, - width: rect.width, - height: rect.height, - }) - .then(() => native.browserViews.show(webviewLabel)) - .catch(() => {}); - } else { - native.browserViews.show(webviewLabel).catch(() => {}); - } - } else { - native.browserViews.hide(webviewLabel).catch(() => {}); - } - }, [visible, webviewReady, hasLoaded, webviewLabel]); + return () => { + if (completingTimerRef.current) clearTimeout(completingTimerRef.current); + }; + }, []); - // --- Sync bounds with ResizeObserver (only when visible & loaded) --- + // --- Mobile emulation via CDP --- + // + // Desktop mode → no CDP override. The page sees the webview's natural + // pixel dimensions, which means responsive CSS fires off the panel + // width the user dragged to — exactly what you'd get sizing a real + // browser window. + // + // Mobile mode → CDP override at 390×852 with mobile UA, touch, DPR 3. + // Matches the fixed-width frame computed above so the layout viewport + // exactly equals the rendered viewport. useEffect(() => { - if (!visible || !webviewReady || !hasLoaded) return; - - const el = placeholderRef.current; - if (!el) return; + if (!hasLoaded) return; + const wv = getWebview(); + if (!wv) return; - // Track last applied scale so we only re-call setEmulation when it changes - let lastScale = 1; - - const syncBounds = () => { - cancelAnimationFrame(rafRef.current); - rafRef.current = requestAnimationFrame(() => { - const rect = el.getBoundingClientRect(); - if (rect.width === 0 || rect.height === 0) return; - const { bounds, scale } = computeViewBounds(rect, tabRef.current.viewport); - native.browserViews.setBounds(webviewLabel, bounds).catch(() => {}); - - // Re-apply emulation when scale changes (panel resized while viewport active) - const vp = tabRef.current.viewport; - if (vp && Math.abs(scale - lastScale) > 0.01) { - lastScale = scale; - native.browserViews - .setEmulation(webviewLabel, emulationParams(vp, scale)) - .catch(() => {}); - } - }); - }; + let cancelled = false; + (async () => { + let webContentsId: number; + try { + webContentsId = wv.getWebContentsId(); + } catch { + return; + } + if (cancelled) return; - const observer = new ResizeObserver(syncBounds); - observer.observe(el); + if (!tab.isMobileView) { + await clearEmulation(webContentsId); + return; + } - // Also sync on window resize (catches position-only changes) - window.addEventListener("resize", syncBounds); + await setEmulation({ + webContentsId, + width: MOBILE_PREVIEW_WIDTH, + height: MOBILE_PREVIEW_HEIGHT, + deviceScaleFactor: MOBILE_PREVIEW_DPR, + mobile: true, + scale: 1, + }); + })(); return () => { - observer.disconnect(); - window.removeEventListener("resize", syncBounds); - cancelAnimationFrame(rafRef.current); + cancelled = true; }; - }, [visible, webviewReady, hasLoaded, webviewLabel]); - - // --- Re-sync bounds when viewport emulation changes --- - // When the user picks a device preset or clears emulation, the webview bounds - // need to update: either center the emulated viewport within the panel or fill - // the panel. The hide → setBounds → show cycle ensures the native compositor - // repositions the view (setBounds on an already-visible view can be coalesced). - useLayoutEffect(() => { - const prev = prevViewportRef.current; - prevViewportRef.current = tab.viewport; - // Skip when nothing changed. On first mount prev is `undefined` — only - // skip if the tab has no viewport to apply (null === null won't match). - if (prev !== undefined && prev === tab.viewport) return; - // First mount with no emulation — nothing to apply - if (prev === undefined && tab.viewport === null) return; - - if (!visible || !webviewReady || !hasLoaded) { - // Not ready yet — reset so it retries when deps change - prevViewportRef.current = undefined; - return; - } - const el = placeholderRef.current; - if (!el || !webviewCreatedRef.current) return; - - const panelRect = el.getBoundingClientRect(); - if (panelRect.width === 0 || panelRect.height === 0) return; + }, [tab.isMobileView, hasLoaded, getWebview]); - // Hide after confirming non-zero bounds (avoids permanently hidden view) - native.browserViews.hide(webviewLabel).catch(() => {}); - - const { bounds, scale } = computeViewBounds(panelRect, tab.viewport); + // --- Imperative methods exposed to parent --- - // Apply emulation or clear it, then position the view - const applyEmulation = tab.viewport - ? native.browserViews.setEmulation(webviewLabel, emulationParams(tab.viewport, scale)) - : native.browserViews.clearEmulation(webviewLabel); + const navigateToUrl = useCallback( + (url: string) => { + const wv = getWebview(); + if (!wv) return; + wv.loadURL(url).catch((err: unknown) => { + onAddLog(tabId, "error", `Navigation failed: ${String(err)}`); + }); + }, + [getWebview, tabId, onAddLog] + ); - applyEmulation - .then(() => native.browserViews.setBounds(webviewLabel, bounds)) - .then(() => native.browserViews.show(webviewLabel)) - .catch(() => {}); - }, [tab.viewport, visible, webviewReady, hasLoaded, webviewLabel]); + const goBack = useCallback(() => { + const wv = getWebview(); + if (!wv || !wv.canGoBack()) return; + suppressHistoryPushRef.current = true; + wv.goBack(); + }, [getWebview]); - // --- IPC event listeners (page load, title, SPA nav) --- - useEffect(() => { - const unlistenFns: Array<() => void> = []; + const goForward = useCallback(() => { + const wv = getWebview(); + if (!wv || !wv.canGoForward()) return; + suppressHistoryPushRef.current = true; + wv.goForward(); + }, [getWebview]); - // Track whether did-fail-load fired for this navigation cycle. - // Electron fires events: did-start-loading → did-fail-load → did-stop-loading. - // Without this flag, "finished" (did-stop-loading) overwrites the error state. - let didFailForCurrentNav = false; + const reload = useCallback(() => { + const wv = getWebview(); + wv?.reload(); + }, [getWebview]); + + const injectAutomation = useCallback(async (): Promise => { + const wv = getWebview(); + if (!wv) return false; + if (automationInjectedRef.current) return true; + try { + await wv.executeJavaScript(INSPECT_MODE_SETUP); + await wv.executeJavaScript(VISUAL_EFFECTS_SETUP); + const rawStatus = await wv.executeJavaScript(INSPECT_MODE_VERIFY); + const status = + typeof rawStatus === "string" ? (JSON.parse(rawStatus) as Record) : null; + if (!status || !status.deusInspect || !status.hasDrainEvents) { + onAddLog(tabId, "error", `Inspect mode setup incomplete: ${JSON.stringify(status)}`); + onUpdateTab(tabId, { injectionFailed: true }); + return false; + } + automationInjectedRef.current = true; + onUpdateTab(tabId, { injected: true, injectionFailed: false }); + onAddLog(tabId, "info", "Automation scripts injected"); + return true; + } catch (err) { + onAddLog(tabId, "error", `Injection failed: ${getErrorMessage(err)}`); + onUpdateTab(tabId, { injectionFailed: true }); + return false; + } + }, [getWebview, tabId, onUpdateTab, onAddLog]); - // Page load events - unlistenFns.push( - native.events.on(BROWSER_PAGE_LOAD, (data) => { - const { label, url, event: eventType } = data; - if (label !== webviewLabel) return; - - if (eventType === "started") { - didFailForCurrentNav = false; - // Clear any pending completion animation - if (completingTimerRef.current) clearTimeout(completingTimerRef.current); - setCompletingLoad(false); - // Reset injection flag — new page context destroys window.__deusVisuals - automationInjectedRef.current = false; - // Don't reset hasLoaded — once the first page has loaded, keep the - // BrowserView visible. Subsequent navigations (redirects, SPA nav, - // meta refresh) show the top loading bar, not the full-screen spinner. - onUpdateTab(tabId, { loading: true }); - } else if (eventType === "finished") { - // Skip if did-fail-load already fired — don't overwrite error state. - // Electron always fires did-stop-loading after did-fail-load. - if (didFailForCurrentNav) return; - setHasLoaded(true); - // Trigger completion animation (bar fills to 100% → fades) - setCompletingLoad(true); - completingTimerRef.current = setTimeout(() => setCompletingLoad(false), 500); - onUpdateTab(tabId, { loading: false, currentUrl: url, error: null }); - onAddLog(tabId, "info", `Page loaded: ${url}`); - } else if (eventType === "failed") { - didFailForCurrentNav = true; - // Clear loading bar animation - if (completingTimerRef.current) clearTimeout(completingTimerRef.current); - setCompletingLoad(false); - // Show error overlay instead of infinite spinner - setHasLoaded(true); - const errorDesc = data.error?.description ?? "Page failed to load"; - onUpdateTab(tabId, { loading: false, error: errorDesc }); - onAddLog(tabId, "error", `Page failed to load: ${errorDesc}`); - } - }) - ); - - // Title change events (regular, non-deus title changes) - unlistenFns.push( - native.events.on(BROWSER_TITLE_CHANGED, (data) => { - const { label, title } = data; - if (label !== webviewLabel) return; - onUpdateTab(tabId, { title }); - }) - ); - - // Navigation events (did-navigate, did-navigate-in-page, pushState/replaceState) - // Push to history so back/forward buttons work for all navigations. - unlistenFns.push( - native.events.on(BROWSER_URL_CHANGE, (data) => { - const { label, url } = data; - if (label !== webviewLabel) return; - - // Suppress history push from programmatic back/forward navigation - if (suppressHistoryPushRef.current) { - suppressHistoryPushRef.current = false; - onUpdateTab(tabId, { url, currentUrl: url, title: deriveTitleFromUrl(url) }); - return; - } + // Keep a ref to the latest injectAutomation so DOM event handlers can + // call it without re-binding when identity changes. + const injectAutomationRef = useRef(injectAutomation); + injectAutomationRef.current = injectAutomation; - const current = tabRef.current; - // Skip duplicate URLs (e.g. hash-only changes that resolve to same URL) - if (current.currentUrl === url) { - onUpdateTab(tabId, { title: deriveTitleFromUrl(url) }); - return; - } - // Truncate forward history and append the new URL - const newHistory = current.history.slice(0, current.historyIndex + 1); - newHistory.push(url); - onUpdateTab(tabId, { - url, - currentUrl: url, - title: deriveTitleFromUrl(url), - history: newHistory, - historyIndex: newHistory.length - 1, - }); - }) - ); - - // Console logs are drained via eval_browser_webview_with_result in the - // "Periodic console drain" effect below. - // - // Inspect mode events are delivered SOLELY via buffer + drain polling - // (eval_browser_webview_with_result every 200ms). We intentionally do NOT - // use the title-channel for inspect events — it has two independent writers - // (BROWSER_INIT_SCRIPT's SPA nav + inspect script) racing on document.title, - // causing silent message loss via WKWebView's title-change coalescing. - - return () => unlistenFns.forEach((fn) => fn()); - }, [webviewLabel, tabId, onUpdateTab, onAddLog]); - - // --- Periodic console drain (only when visible and webview ready) --- - // Uses eval_browser_webview_with_result (native completion handler) instead - // of the old title-channel approach (drain_browser_console). The title-channel - // suffers from WKWebView's title-change coalescing — when the 60ms restore - // window overlaps with another title write (SPA nav, page itself), the - // \x01CL: message is silently dropped and console logs are lost. - // - // The completion-handler path reads the log buffer directly and returns the - // JSON via WKWebView's evaluateJavaScript callback, which is reliable. + // Trigger injection on EVERY page load (each new page context loses the + // globals set by the setup IIFEs). Reset happens on did-start-loading; + // inject fires on did-stop-loading via the DOM listener effect below. useEffect(() => { - if (!visible || !webviewReady || !hasLoaded) return; - - // Drain console logs AND detect agent-initiated viewport changes. - // Viewport dimensions (innerWidth/innerHeight) are piggybacked onto the - // existing 1500ms drain to avoid adding a separate polling interval. - const CONSOLE_DRAIN_JS = `(function(){ - var b = window.__DEUS_LOGS__ || []; - window.__DEUS_LOGS__ = []; - return JSON.stringify({logs:b,vw:window.innerWidth,vh:window.innerHeight}); - })()`; - - // Track consecutive failures for diagnostic logging - let consoleDrainFails = 0; - // Guard against overlapping async drains — if invoke() takes longer than - // the interval, skip until the previous call completes. - let inFlight = false; - - const interval = setInterval(async () => { - if (inFlight) return; - inFlight = true; - try { - const result = await native.browserViews.evaluateWithResult( - webviewLabel, - CONSOLE_DRAIN_JS, - 2000 - ); - // Reset on success - consoleDrainFails = 0; - - if (!result || result === "{}" || result === "undefined") return; + const wv = getWebview(); + if (!wv) return; - let parsed: { logs?: Array<{ l: string; m: string; t: number }>; vw?: number; vh?: number }; - try { - parsed = JSON.parse(result); - } catch (parseErr) { - console.error( - "[BrowserTab] console drain: JSON.parse failed", - parseErr, - "raw:", - result.slice(0, 200) - ); - return; - } - - // Detect agent-initiated viewport changes via innerWidth/innerHeight - const { vw, vh } = parsed; - const currentVp = tabRef.current.viewport; - if (vw && vh && currentVp && (vw !== currentVp.width || vh !== currentVp.height)) { - // Agent changed viewport dimensions — sync dropdown - onUpdateTab(tabId, { - viewport: { - width: vw, - height: vh, - deviceScaleFactor: currentVp.deviceScaleFactor, - mobile: currentVp.mobile, - }, - }); - } - - const logs = parsed.logs; - if (!logs || logs.length === 0) return; - for (const log of logs) { - const level = match(log.l) - .with("warn", () => "warn" as const) - .with("error", () => "error" as const) - .with("debug", () => "debug" as const) - .otherwise(() => "info" as const); - onAddLog(tabId, level, log.m); - } - } catch (err) { - consoleDrainFails++; - if (consoleDrainFails <= 3 || consoleDrainFails % 50 === 0) { - console.warn( - `[BrowserTab] console drain failed (${consoleDrainFails}x):`, - getErrorMessage(err) - ); - } - } finally { - inFlight = false; - } - }, CONSOLE_DRAIN_INTERVAL_MS); + const onStart = () => { + automationInjectedRef.current = false; + onUpdateTab(tabId, { injected: false, selectorActive: false }); + }; + const onStop = () => { + // Run after the next frame so the page's scripts have a chance to + // attach — otherwise `executeJavaScript` can race with inline + //