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), },