From 7932e38d0dfdd247333d7b8b52bf1939beeae6a2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 6 May 2026 15:15:15 +0700 Subject: [PATCH] Fix browser reload focus and devtools handling Cmd+R was scoped through a stale Electron-side active browser value. Opening or focusing a browser pane set activeBrowserPaneId, but switching to an agent tab only updated workspace layout state. Electron still remembered the old browser and the app menu reloaded that hidden/previous browser instead of the app. Make workspace layout the authority. Derive the active browser from focusedPaneId plus the focused pane's focusedTabId, and sync browserId|null to Electron from one WorkspaceScreen effect. BrowserPane no longer marks itself active from URL-bar or webview focus handlers, so call sites do not have to remember to clear Electron state when focus moves to an agent, terminal, or another pane. Electron now treats the synced value as the workspace-active browser, not an independently observed focus source. Menu reload uses that workspace-derived browser when present; otherwise it reloads the app window. Direct Cmd+R events that originate inside a browser guest still reload that guest through the guest before-input-event path. Browser webviews now own their guest context menu in the main process. Add copy/paste/select-all and a development Inspect Element action for attached browser guest WebContents. DevTools opening is routed through the main process by browserId. Docked-right DevTools did not become visible for browser guest contents in practice, even though inspect mode activated, so the final behavior opens detached DevTools for both the toolbar button and Inspect Element. The renderer logs bridge/result failures, and main logs registration/open/inspect outcomes for future debugging. Regression coverage is at the workspace layout derivation layer: focused browser id follows the focused pane's active browser tab and becomes null when the active tab is not a browser. Verified with targeted layout-store tests, navigation E2E, settings E2E, npm run typecheck, and npm run lint. --- .../src/components/browser-pane.electron.tsx | 64 +++++++++---- packages/app/src/desktop/host.ts | 3 +- .../screens/workspace/workspace-screen.tsx | 21 ++++ .../src/stores/workspace-layout-actions.ts | 14 +++ .../src/stores/workspace-layout-store.test.ts | 55 ++++++++++- .../app/src/stores/workspace-layout-store.ts | 2 + .../desktop/src/features/browser-webviews.ts | 30 +++--- packages/desktop/src/features/menu.ts | 14 +-- packages/desktop/src/main.ts | 96 ++++++++++++++++++- packages/desktop/src/preload.ts | 6 +- 10 files changed, 252 insertions(+), 53 deletions(-) diff --git a/packages/app/src/components/browser-pane.electron.tsx b/packages/app/src/components/browser-pane.electron.tsx index df9efc155..fe4a2bb2b 100644 --- a/packages/app/src/components/browser-pane.electron.tsx +++ b/packages/app/src/components/browser-pane.electron.tsx @@ -8,7 +8,7 @@ import { createElement, } from "react"; import { Pressable, Text, TextInput, View } from "react-native"; -import { ArrowLeft, ArrowRight, MousePointer2, RotateCw } from "lucide-react-native"; +import { ArrowLeft, ArrowRight, MousePointer2, PencilRuler, RotateCw } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { buildWorkspaceAttachmentScopeKey, @@ -34,7 +34,6 @@ type ElectronWebview = HTMLElement & { stop?: () => void; loadURL?: (url: string) => Promise; getURL?: () => string; - openDevTools?: () => void; executeJavaScript?: (code: string) => Promise; focus?: () => void; addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void; @@ -343,15 +342,9 @@ export function BrowserPane({ }, 0); }, []); - const markActivePane = useCallback(() => { - if (!isElectronRuntime()) return; - void getDesktopHost()?.browser?.setActivePane?.(browserIdRef.current); - }, []); - const handleUrlBarFocus = useCallback(() => { - markActivePane(); selectUrlBar(); - }, [markActivePane, selectUrlBar]); + }, [selectUrlBar]); const focusUrlBar = useCallback(() => { urlInputRef.current?.focus(); @@ -481,7 +474,6 @@ export function BrowserPane({ }; const handleWebviewFocus = () => { onFocusPane?.(); - markActivePane(); }; webview.addEventListener("did-start-loading", handleStartLoading); @@ -870,6 +862,26 @@ export function BrowserPane({ startElementSelector(); }, [cancelElementSelector, selectorActive, startElementSelector]); + const handleOpenDevTools = useCallback(() => { + const currentBrowserId = browserIdRef.current; + const openDevTools = getDesktopHost()?.browser?.openDevTools; + if (typeof openDevTools !== "function") { + console.warn("[browser-pane] openDevTools bridge missing", { browserId: currentBrowserId }); + return; + } + void openDevTools(currentBrowserId) + .then((result) => { + console.info("[browser-pane] openDevTools result", { + browserId: currentBrowserId, + result, + }); + return undefined; + }) + .catch((error: unknown) => { + console.warn("[browser-pane] openDevTools failed", { browserId: currentBrowserId, error }); + }); + }, []); + const baseIconButtonStyle = useCallback( ({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [ styles.iconButton, @@ -973,17 +985,27 @@ export function BrowserPane({ {isDev ? ( - - - + <> + + + + + + + ) : null} diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index 154c58c93..896e86117 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -89,7 +89,8 @@ export interface DesktopBrowserShortcutEvent { } export interface DesktopBrowserBridge { - setActivePane?: (browserId: string | null) => Promise; + setWorkspaceActiveBrowser?: (browserId: string | null) => Promise; + openDevTools?: (browserId: string) => Promise; clearPartition?: (browserId: string) => Promise; } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 1ff7db7cd..ea74d019c 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -66,6 +66,8 @@ import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-stor import { buildWorkspaceTabPersistenceKey, collectAllTabs, + getFocusedBrowserId, + type WorkspaceLayout, useWorkspaceLayoutStore, useWorkspaceLayoutStoreHydrated, } from "@/stores/workspace-layout-store"; @@ -216,6 +218,24 @@ function decodeSegment(value: string): string { } } +function useSyncWorkspaceActiveBrowser(input: { + workspaceLayout: WorkspaceLayout | null; + isRouteFocused: boolean; +}) { + const focusedBrowserId = useMemo( + () => getFocusedBrowserId(input.workspaceLayout), + [input.workspaceLayout], + ); + const desktopActiveBrowserId = input.isRouteFocused ? focusedBrowserId : null; + + useEffect(() => { + if (!getIsElectron()) { + return; + } + void getDesktopHost()?.browser?.setWorkspaceActiveBrowser?.(desktopActiveBrowserId); + }, [desktopActiveBrowserId]); +} + function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "draft") { return "New Agent"; @@ -1705,6 +1725,7 @@ function WorkspaceScreenContent({ () => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS), [workspaceLayout], ); + useSyncWorkspaceActiveBrowser({ workspaceLayout, isRouteFocused }); const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused); const openWorkspaceTabInBackground = useWorkspaceLayoutStore( (state) => state.openTabInBackground, diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index 2aae82a0d..01b277c36 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -969,6 +969,20 @@ export function collectAllPanes(root: SplitNode): SplitPane[] { return internalRoot.group.children.flatMap((child) => collectAllPanes(child)); } +export function getFocusedBrowserId(layout: WorkspaceLayout | null | undefined): string | null { + if (!layout) { + return null; + } + const focusedPane = findPaneById(layout.root, layout.focusedPaneId); + if (!focusedPane?.focusedTabId) { + return null; + } + const focusedTab = collectAllTabs(layout.root).find( + (tab) => tab.tabId === focusedPane.focusedTabId, + ); + return focusedTab?.target.kind === "browser" ? focusedTab.target.browserId : null; +} + export function createDefaultLayout(): WorkspaceLayout { return { root: createPaneNode({ id: DEFAULT_PANE_ID }), diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 25c54343d..124320eb7 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -23,6 +23,7 @@ import { createDefaultLayout, findPaneById, findPaneContainingTab, + getFocusedBrowserId, getTreeDepth, insertSplit, removePaneFromTree, @@ -35,10 +36,10 @@ import { const SERVER_ID = "server-1"; const WORKSPACE_ID = "ws-main"; -function createTab(tabId: string): WorkspaceTab { +function createTab(tabId: string, target?: WorkspaceTab["target"]): WorkspaceTab { return { tabId, - target: { kind: "draft", draftId: tabId }, + target: target ?? { kind: "draft", draftId: tabId }, createdAt: 1, }; } @@ -47,8 +48,9 @@ function createPane(input: { id: string; tabIds: string[]; focusedTabId?: string | null; + targetsByTabId?: Record; }): SplitNode { - const tabs = input.tabIds.map((tabId) => createTab(tabId)); + const tabs = input.tabIds.map((tabId) => createTab(tabId, input.targetsByTabId?.[tabId])); return { kind: "pane", pane: { @@ -115,6 +117,53 @@ describe("workspace-layout-store helpers", () => { "tab-d", ]); }); + + it("derives the focused browser id from the focused pane active tab", () => { + const root: SplitNode = { + kind: "group", + group: { + id: "group-root", + direction: "horizontal", + sizes: [0.5, 0.5], + children: [ + createPane({ + id: "left", + tabIds: ["agent-a", "browser-a"], + focusedTabId: "browser-a", + targetsByTabId: { + "agent-a": { kind: "agent", agentId: "agent-a" }, + "browser-a": { kind: "browser", browserId: "browser-a-id" }, + }, + }), + createPane({ + id: "right", + tabIds: ["browser-b"], + focusedTabId: "browser-b", + targetsByTabId: { + "browser-b": { kind: "browser", browserId: "browser-b-id" }, + }, + }), + ], + }, + }; + + expect(getFocusedBrowserId({ root, focusedPaneId: "left" })).toBe("browser-a-id"); + expect(getFocusedBrowserId({ root, focusedPaneId: "right" })).toBe("browser-b-id"); + }); + + it("returns null when the focused pane active tab is not a browser", () => { + const root = createPane({ + id: "main", + tabIds: ["browser-a", "agent-a"], + focusedTabId: "agent-a", + targetsByTabId: { + "browser-a": { kind: "browser", browserId: "browser-a-id" }, + "agent-a": { kind: "agent", agentId: "agent-a" }, + }, + }); + + expect(getFocusedBrowserId({ root, focusedPaneId: "main" })).toBeNull(); + }); }); describe("workspace-layout-store tree transforms", () => { diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index c9367e16e..a8fb07a84 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -18,6 +18,7 @@ import { findPaneContainingTab, focusPaneInLayout, focusTabInLayout, + getFocusedBrowserId, getTreeDepth, insertSplit, moveTabToPaneInLayout, @@ -48,6 +49,7 @@ export { createDefaultLayout, findPaneById, findPaneContainingTab, + getFocusedBrowserId, getTreeDepth, insertSplit, normalizeLayout, diff --git a/packages/desktop/src/features/browser-webviews.ts b/packages/desktop/src/features/browser-webviews.ts index 9e1d5bff5..f71cb5850 100644 --- a/packages/desktop/src/features/browser-webviews.ts +++ b/packages/desktop/src/features/browser-webviews.ts @@ -1,14 +1,18 @@ import { webContents as allWebContents, type WebContents } from "electron"; const browserIdsByWebContentsId = new Map(); -let activeBrowserPaneId: string | null = null; +let workspaceActiveBrowserId: string | null = null; + +export function listRegisteredPaseoBrowserIds(): string[] { + return Array.from(new Set(browserIdsByWebContentsId.values())).sort(); +} export function registerPaseoBrowserWebContents(contents: WebContents, browserId: string): void { browserIdsByWebContentsId.set(contents.id, browserId); contents.once("destroyed", () => { browserIdsByWebContentsId.delete(contents.id); - if (activeBrowserPaneId === browserId) { - activeBrowserPaneId = null; + if (workspaceActiveBrowserId === browserId) { + workspaceActiveBrowserId = null; } }); } @@ -20,16 +24,13 @@ export function getPaseoBrowserIdForWebContents(contents: WebContents | null): s return browserIdsByWebContentsId.get(contents.id) ?? null; } -export function setActivePaseoBrowserPaneId(browserId: string | null): void { - activeBrowserPaneId = browserId; +export function setWorkspaceActivePaseoBrowserId(browserId: string | null): void { + workspaceActiveBrowserId = browserId; } -export function getActivePaseoBrowserWebContents(): WebContents | null { - if (!activeBrowserPaneId) { - return null; - } - for (const [contentsId, browserId] of browserIdsByWebContentsId) { - if (browserId !== activeBrowserPaneId) continue; +export function getPaseoBrowserWebContents(browserId: string): WebContents | null { + for (const [contentsId, registeredBrowserId] of browserIdsByWebContentsId) { + if (registeredBrowserId !== browserId) continue; const contents = allWebContents.fromId(contentsId); if (contents && !contents.isDestroyed()) { return contents; @@ -37,3 +38,10 @@ export function getActivePaseoBrowserWebContents(): WebContents | null { } return null; } + +export function getWorkspaceActivePaseoBrowserWebContents(): WebContents | null { + if (!workspaceActiveBrowserId) { + return null; + } + return getPaseoBrowserWebContents(workspaceActiveBrowserId); +} diff --git a/packages/desktop/src/features/menu.ts b/packages/desktop/src/features/menu.ts index cd66fe722..b89244482 100644 --- a/packages/desktop/src/features/menu.ts +++ b/packages/desktop/src/features/menu.ts @@ -1,8 +1,5 @@ -import { app, Menu, BrowserWindow, ipcMain, webContents } from "electron"; -import { - getActivePaseoBrowserWebContents, - getPaseoBrowserIdForWebContents, -} from "./browser-webviews.js"; +import { app, Menu, BrowserWindow, ipcMain } from "electron"; +import { getWorkspaceActivePaseoBrowserWebContents } from "./browser-webviews.js"; interface ShowContextMenuInput { kind?: "terminal"; @@ -18,13 +15,8 @@ function withBrowserWindow( }; } -function getFocusedPaseoBrowserWebContents(): Electron.WebContents | null { - const focusedContents = webContents.getFocusedWebContents(); - return getPaseoBrowserIdForWebContents(focusedContents) ? focusedContents : null; -} - function getReloadTargetBrowserWebContents(): Electron.WebContents | null { - return getFocusedPaseoBrowserWebContents() ?? getActivePaseoBrowserWebContents(); + return getWorkspaceActivePaseoBrowserWebContents(); } function reloadFocusedContentsOrWindow(win: BrowserWindow, options?: { ignoreCache?: boolean }) { diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 2e8c793d9..670403c64 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -9,7 +9,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { existsSync } from "node:fs"; import { execFileSync } from "node:child_process"; -import { app, BrowserWindow, ipcMain, nativeImage, net, protocol, session } from "electron"; +import { app, BrowserWindow, Menu, ipcMain, nativeImage, net, protocol, session } from "electron"; import { createDaemonCommandHandlers, registerDaemonManager } from "./daemon/daemon-manager.js"; import { parseCliPassthroughArgsFromArgv, @@ -35,8 +35,10 @@ import { registerOpenerHandlers } from "./features/opener.js"; import { setupApplicationMenu } from "./features/menu.js"; import { getPaseoBrowserIdForWebContents, + getPaseoBrowserWebContents, + listRegisteredPaseoBrowserIds, registerPaseoBrowserWebContents, - setActivePaseoBrowserPaneId, + setWorkspaceActivePaseoBrowserId, } from "./features/browser-webviews.js"; import { parseOpenProjectPathFromArgv } from "./open-project-routing.js"; import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js"; @@ -143,6 +145,43 @@ function isForwardablePaseoShortcutInput(input: Electron.Input): boolean { return FORWARDED_PASEO_SHORTCUT_KEYS.has(input.key.toLowerCase()); } +function showBrowserWebviewContextMenu( + win: BrowserWindow, + contents: Electron.WebContents, + params: Electron.ContextMenuParams, +): void { + const menu = Menu.buildFromTemplate([ + { role: "copy", enabled: params.selectionText.length > 0 }, + { role: "paste" }, + { type: "separator" }, + { role: "selectAll" }, + ...(app.isPackaged + ? [] + : [ + { type: "separator" as const }, + { + label: "Inspect Element", + click: () => { + log.info("[browser-devtools] inspect-element.request", { + webContentsId: contents.id, + browserId: getPaseoBrowserIdForWebContents(contents), + x: params.x, + y: params.y, + isDevToolsOpened: contents.isDevToolsOpened(), + }); + contents.openDevTools({ mode: "detach" }); + contents.inspectElement(params.x, params.y); + log.info("[browser-devtools] inspect-element.done", { + webContentsId: contents.id, + isDevToolsOpened: contents.isDevToolsOpened(), + }); + }, + }, + ]), + ]); + menu.popup({ window: win }); +} + // In dev mode, detect git worktrees and isolate each instance so multiple // Electron windows can run side-by-side (separate userData = separate lock). let devWorktreeName: string | null = null; @@ -215,8 +254,49 @@ ipcMain.handle("paseo:get-pending-open-project", () => { return result; }); -ipcMain.handle("paseo:browser:set-active-pane", (_event, browserId: unknown) => { - setActivePaseoBrowserPaneId(typeof browserId === "string" ? browserId : null); +ipcMain.handle("paseo:browser:set-workspace-active-browser", (_event, browserId: unknown) => { + setWorkspaceActivePaseoBrowserId(typeof browserId === "string" ? browserId : null); +}); + +ipcMain.handle("paseo:browser:open-devtools", (_event, browserId: unknown) => { + if (typeof browserId !== "string" || browserId.trim().length === 0) { + const result = { + ok: false, + reason: "invalid-browser-id", + browserId, + registeredBrowserIds: listRegisteredPaseoBrowserIds(), + }; + log.warn("[browser-devtools] open-devtools.invalid", result); + return result; + } + const contents = getPaseoBrowserWebContents(browserId); + if (!contents) { + const result = { + ok: false, + reason: "browser-webcontents-not-found", + browserId, + registeredBrowserIds: listRegisteredPaseoBrowserIds(), + }; + log.warn("[browser-devtools] open-devtools.not-found", result); + return result; + } + log.info("[browser-devtools] open-devtools.request", { + browserId, + webContentsId: contents.id, + isDestroyed: contents.isDestroyed(), + isDevToolsOpened: contents.isDevToolsOpened(), + registeredBrowserIds: listRegisteredPaseoBrowserIds(), + }); + contents.openDevTools({ mode: "detach" }); + const result = { + ok: true, + reason: "opened", + browserId, + webContentsId: contents.id, + isDevToolsOpened: contents.isDevToolsOpened(), + }; + log.info("[browser-devtools] open-devtools.done", result); + return result; }); ipcMain.handle("paseo:browser:clear-partition", async (_event, browserId: unknown) => { @@ -349,6 +429,11 @@ async function createMainWindow(): Promise { const browserId = pendingBrowserWebviewIds.shift() ?? null; if (browserId) { registerPaseoBrowserWebContents(contents, browserId); + log.info("[browser-webview] registered", { + browserId, + webContentsId: contents.id, + registeredBrowserIds: listRegisteredPaseoBrowserIds(), + }); } contents.on("before-input-event", (event, input) => { if (isBrowserRefreshInput(input)) { @@ -388,6 +473,9 @@ async function createMainWindow(): Promise { contents.loadURL(url).catch(() => undefined); return { action: "deny" }; }); + contents.on("context-menu", (_contextMenuEvent, params) => { + showBrowserWebviewContextMenu(mainWindow, contents, params); + }); contents.on("will-navigate", (event) => { preventUnsafeBrowserWebviewNavigation(event, event.url); }); diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index e0cea10e4..66922e0bc 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -60,8 +60,10 @@ contextBridge.exposeInMainWorld("paseoDesktop", { ipcRenderer.invoke("paseo:menu:showContextMenu", input), }, browser: { - setActivePane: (browserId: string | null) => - ipcRenderer.invoke("paseo:browser:set-active-pane", browserId), + setWorkspaceActiveBrowser: (browserId: string | null) => + ipcRenderer.invoke("paseo:browser:set-workspace-active-browser", browserId), + openDevTools: (browserId: string) => + ipcRenderer.invoke("paseo:browser:open-devtools", browserId), clearPartition: (browserId: string) => ipcRenderer.invoke("paseo:browser:clear-partition", browserId), },