From ba6cf386077c32c0ed6196e5e1bce87a6adc26e0 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:09:30 +0800 Subject: [PATCH] feat(desktop): add focus debug toggle (#37465) --- packages/app/src/components/debug-bar.tsx | 54 ++++++++++++- packages/app/src/context/platform.tsx | 3 + packages/desktop/src/main/debug.ts | 95 +++++++++++++++++++++++ packages/desktop/src/main/ipc.ts | 4 + packages/desktop/src/preload/index.ts | 1 + packages/desktop/src/preload/types.ts | 1 + packages/desktop/src/renderer/index.tsx | 2 + 7 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 packages/desktop/src/main/debug.ts diff --git a/packages/app/src/components/debug-bar.tsx b/packages/app/src/components/debug-bar.tsx index e55e128b82..67c9a89929 100644 --- a/packages/app/src/components/debug-bar.tsx +++ b/packages/app/src/components/debug-bar.tsx @@ -5,6 +5,7 @@ import { makeEventListener } from "@solid-primitives/event-listener" import { Tooltip } from "@opencode-ai/ui/tooltip" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" type Mem = Performance & { memory?: { @@ -107,8 +108,45 @@ function Cell(props: { ) } +function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) { + const content = () => ( + + ) + + if (props.inline) { + return ( + + {content()} + + ) + } + + return ( + + {content()} + + ) +} + export function DebugBar(props: { inline?: boolean } = {}) { const language = useLanguage() + const platform = usePlatform() const location = useLocation() const routing = useIsRouting() const [state, setState] = createStore({ @@ -116,6 +154,7 @@ export function DebugBar(props: { inline?: boolean } = {}) { delay: undefined as number | undefined, fps: undefined as number | undefined, gap: undefined as number | undefined, + focus: false, heap: { limit: undefined as number | undefined, used: undefined as number | undefined, @@ -142,6 +181,16 @@ export function DebugBar(props: { inline?: boolean } = {}) { } const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`) const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na())) + const toggleFocus = async () => { + if (!platform.setForceFocus) return + const enabled = !state.focus + await platform.setForceFocus(enabled) + setState("focus", enabled) + } + + onCleanup(() => { + if (state.focus) void platform.setForceFocus?.(false).catch(() => undefined) + }) let prev = "" let start = 0 @@ -490,8 +539,11 @@ export function DebugBar(props: { inline?: boolean } = {}) { bad={bad(heap(), 0.8)} dim={state.heap.used === undefined} inline={props.inline} - wide + wide={!platform.setForceFocus} /> + {platform.setForceFocus && ( + void toggleFocus()} /> + )} ) diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 1a061a6675..cf343bfa7e 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -115,6 +115,9 @@ type PlatformBase = { /** Export collected diagnostic logs (desktop only) */ exportDebugLogs?(): Promise + /** Force focus styles on interactive elements through desktop devtools (desktop only) */ + setForceFocus?(enabled: boolean): Promise + /** Record a fatal renderer error in platform logs (desktop only) */ recordFatalRendererError?(error: FatalRendererErrorLog): Promise } diff --git a/packages/desktop/src/main/debug.ts b/packages/desktop/src/main/debug.ts new file mode 100644 index 0000000000..373aec237f --- /dev/null +++ b/packages/desktop/src/main/debug.ts @@ -0,0 +1,95 @@ +import type { WebContents } from "electron" + +const focusDebuggerOwners = new WeakSet() +const forcedFocusNodes = new WeakMap() +const focusableSelector = ` + a[href], + button:not([disabled]), + input:not([disabled]), + select:not([disabled]), + textarea:not([disabled]), + summary, + [contenteditable="true"], + [tabindex]:not([tabindex="-1"]) +` + +export async function setForceFocus(contents: WebContents, enabled: boolean) { + const debuggerApi = contents.debugger + if (!debuggerApi.isAttached()) { + if (!enabled) { + focusDebuggerOwners.delete(contents) + forcedFocusNodes.delete(contents) + return + } + debuggerApi.attach("1.3") + focusDebuggerOwners.add(contents) + debuggerApi.once("detach", () => { + focusDebuggerOwners.delete(contents) + forcedFocusNodes.delete(contents) + }) + } + + if (!enabled) { + await Promise.allSettled( + (forcedFocusNodes.get(contents) ?? []).map((nodeId) => + debuggerApi.sendCommand("CSS.forcePseudoState", { + nodeId, + forcedPseudoClasses: [], + }), + ), + ) + forcedFocusNodes.delete(contents) + if (!focusDebuggerOwners.delete(contents)) return + debuggerApi.detach() + return + } + + await debuggerApi.sendCommand("DOM.enable") + await debuggerApi.sendCommand("CSS.enable") + const document: unknown = await debuggerApi.sendCommand("DOM.getDocument", { + depth: -1, + pierce: true, + }) + const nodes: unknown = await debuggerApi.sendCommand("DOM.querySelectorAll", { + nodeId: readDocumentNodeId(document), + selector: focusableSelector, + }) + const nodeIds = readNodeIds(nodes) + forcedFocusNodes.set(contents, [...new Set([...(forcedFocusNodes.get(contents) ?? []), ...nodeIds])]) + await Promise.allSettled( + nodeIds.map((nodeId) => + debuggerApi.sendCommand("CSS.forcePseudoState", { + nodeId, + forcedPseudoClasses: ["focus", "focus-visible"], + }), + ), + ) +} + +function readDocumentNodeId(value: unknown) { + if ( + !value || + typeof value !== "object" || + !("root" in value) || + !value.root || + typeof value.root !== "object" || + !("nodeId" in value.root) || + typeof value.root.nodeId !== "number" + ) { + throw new Error("Invalid DOM.getDocument response") + } + return value.root.nodeId +} + +function readNodeIds(value: unknown) { + if ( + !value || + typeof value !== "object" || + !("nodeIds" in value) || + !Array.isArray(value.nodeIds) || + !value.nodeIds.every((nodeId) => typeof nodeId === "number") + ) { + throw new Error("Invalid DOM.querySelectorAll response") + } + return value.nodeIds +} diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 1103401c49..4563b688ac 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -7,6 +7,7 @@ import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu" import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types" import { runDesktopMenuAction } from "./desktop-menu-actions" +import { setForceFocus } from "./debug" import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker" import { getStore, removeStoreFileIfEmpty } from "./store" import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows" @@ -81,6 +82,9 @@ export function registerIpcHandlers(deps: Deps) { ipcMain.handle("updater-install", () => deps.updater.install()) ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color)) ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs()) + ipcMain.handle("set-force-focus", (event: IpcMainInvokeEvent, enabled: boolean) => + setForceFocus(event.sender, enabled), + ) ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) => deps.recordFatalRendererError(error), ) diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index d14dd0d00a..87eac1fa80 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -121,6 +121,7 @@ const api: ElectronAPI = { runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action), setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color), exportDebugLogs: () => ipcRenderer.invoke("export-debug-logs"), + setForceFocus: (enabled) => ipcRenderer.invoke("set-force-focus", enabled), recordFatalRendererError: (error) => ipcRenderer.invoke("record-fatal-renderer-error", error), } diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index da10f7008d..5fbace72c7 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -104,5 +104,6 @@ export type ElectronAPI = { runDesktopMenuAction: (action: DesktopMenuAction) => Promise setBackgroundColor: (color: string) => Promise exportDebugLogs: () => Promise + setForceFocus: (enabled: boolean) => Promise recordFatalRendererError: (error: FatalRendererError) => Promise } diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index 13c122cb2e..b7d2d9a74d 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -240,6 +240,8 @@ const createPlatform = (windowState: DesktopWindowState): Platform => { exportDebugLogs: () => window.api.exportDebugLogs(), + setForceFocus: (enabled) => window.api.setForceFocus(enabled), + recordFatalRendererError: (error) => window.api.recordFatalRendererError(error), restart: async () => {