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.
This commit is contained in:
Mohamed Boudra
2026-05-06 15:15:15 +07:00
parent 5e5fc97798
commit 7932e38d0d
10 changed files with 252 additions and 53 deletions
@@ -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<void>;
getURL?: () => string;
openDevTools?: () => void;
executeJavaScript?: (code: string) => Promise<unknown>;
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({
</View>
<View style={styles.chromeRight}>
{isDev ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={selectorActive ? "Cancel element selector" : "Select element"}
onPress={handleToggleElementSelector}
style={selectorIconButtonStyle}
>
<MousePointer2
size={16}
color={selectorActive ? theme.colors.accent : theme.colors.foregroundMuted}
/>
</Pressable>
<>
<Pressable
accessibilityRole="button"
accessibilityLabel="Open browser dev tools"
onPress={handleOpenDevTools}
style={baseIconButtonStyle}
>
<PencilRuler size={16} color={theme.colors.foregroundMuted} />
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel={selectorActive ? "Cancel element selector" : "Select element"}
onPress={handleToggleElementSelector}
style={selectorIconButtonStyle}
>
<MousePointer2
size={16}
color={selectorActive ? theme.colors.accent : theme.colors.foregroundMuted}
/>
</Pressable>
</>
) : null}
</View>
</View>
+2 -1
View File
@@ -89,7 +89,8 @@ export interface DesktopBrowserShortcutEvent {
}
export interface DesktopBrowserBridge {
setActivePane?: (browserId: string | null) => Promise<void>;
setWorkspaceActiveBrowser?: (browserId: string | null) => Promise<void>;
openDevTools?: (browserId: string) => Promise<unknown>;
clearPartition?: (browserId: string) => Promise<void>;
}
@@ -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,
@@ -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 }),
@@ -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<string, WorkspaceTab["target"]>;
}): 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", () => {
@@ -18,6 +18,7 @@ import {
findPaneContainingTab,
focusPaneInLayout,
focusTabInLayout,
getFocusedBrowserId,
getTreeDepth,
insertSplit,
moveTabToPaneInLayout,
@@ -48,6 +49,7 @@ export {
createDefaultLayout,
findPaneById,
findPaneContainingTab,
getFocusedBrowserId,
getTreeDepth,
insertSplit,
normalizeLayout,
@@ -1,14 +1,18 @@
import { webContents as allWebContents, type WebContents } from "electron";
const browserIdsByWebContentsId = new Map<number, string>();
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);
}
+3 -11
View File
@@ -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 }) {
+92 -4
View File
@@ -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<void> {
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<void> {
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);
});
+4 -2
View File
@@ -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),
},