diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 5f45ee34de..b6f6dc72f1 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -448,6 +448,7 @@ import { readPreUpdateBackupEnabled } from './pre-update-backup-config' import { capturePreviewContents } from './preview-capture' import { onPreviewWatchOwnerDestroyed, sendPreviewFileChangedToOwner } from './preview-file-watch' import { hasClosePreviewFlag, previewGuestInputAction } from './preview-guest-escape' +import { commandFocusedGuest, notePreviewGuestHidden } from './preview-guest-offscreen' import { PreviewReachRegistry } from './preview-reach' import { previewHttpUrlTarget } from './preview-url-target' import { @@ -6564,47 +6565,6 @@ function sendClosePreviewRequested() { webContents.send('hermes:close-preview-requested') } -/** - * Run a browser gesture on the guest page the user is actually in, if any. - * - * A `` guest is its own out-of-process webContents: pointer and focus - * events inside the page never reach the host document, so NOTHING in the - * renderer — not `document.activeElement`, not the layout tree's hover/focus - * ladder — can see that the user is in there. Main can: Electron tracks the - * focused webContents across processes, which is the definition of a runtime - * fact it owns. - * - * Returns false when focus is in the app's own chrome, where the renderer is - * the one that knows which pane is active. - */ -function commandFocusedGuest(command: 'back' | 'forward' | 'reload'): boolean { - const focused = electronWebContents.getFocusedWebContents() - - if (!focused || focused.isDestroyed() || focused.getType() !== 'webview') { - return false - } - - const history = focused.navigationHistory - - if (command === 'reload') { - focused.reload() - } else if (command === 'back') { - if (!history.canGoBack()) { - return true - } - - history.goBack() - } else { - if (!history.canGoForward()) { - return true - } - - history.goForward() - } - - return true -} - /** * Ask the renderer to run a browser-navigation gesture on its focused preview * pane. `reload` also has an app-level fallback (reload the window); `back` and @@ -6613,7 +6573,7 @@ function commandFocusedGuest(command: 'back' | 'forward' | 'reload'): boolean { function sendPreviewNavCommand(command: 'back' | 'forward' | 'reload') { // The user is inside the page itself — main is the only party that can see // that, so act here and never round-trip. - if (commandFocusedGuest(command)) { + if (commandFocusedGuest(command, electronWebContents.getFocusedWebContents())) { return } @@ -17165,6 +17125,16 @@ ipcMain.on('hermes:previewShortcutActive', (_event, active) => { previewShortcutActive = Boolean(active) }) +// The host renderer reports which of its Browser guests are off screen (a +// hidden session's kept-alive page), so focused-guest gestures skip them. +ipcMain.on('hermes:preview-guest-hidden', (event, payload) => { + notePreviewGuestHidden( + event.sender, + electronWebContents.fromId(Number(payload?.webContentsId)), + Boolean(payload?.hidden) + ) +}) + ipcMain.on('hermes:f12ShortcutActive', (event, active) => { if (active) { f12ShortcutActiveWindows.add(event.sender.id) diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 7efa03a9de..505df7af38 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -407,6 +407,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', { return () => ipcRenderer.removeListener('hermes:f12-shortcut', listener) }, setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)), + setPreviewGuestHidden: (webContentsId, hidden) => + ipcRenderer.send('hermes:preview-guest-hidden', { webContentsId, hidden: Boolean(hidden) }), openExternal: url => ipcRenderer.invoke('hermes:openExternal', url), mcpOauth: { // One-shot loopback listener for MCP OAuth against remote backends: bind diff --git a/apps/desktop/electron/preview-guest-offscreen.test.ts b/apps/desktop/electron/preview-guest-offscreen.test.ts new file mode 100644 index 0000000000..63f85bf9f6 --- /dev/null +++ b/apps/desktop/electron/preview-guest-offscreen.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest' + +import { commandFocusedGuest, notePreviewGuestHidden } from './preview-guest-offscreen' + +let nextId = 1 + +function fakeContents(type: 'webview' | 'window', hostWebContents?: unknown) { + const history = { + canGoBack: vi.fn(() => true), + canGoForward: vi.fn(() => true), + goBack: vi.fn(), + goForward: vi.fn() + } + + return { + id: nextId++, + hostWebContents, + navigationHistory: history, + reload: vi.fn(), + getType: () => type, + isDestroyed: () => false, + once: vi.fn() + } +} + +type Fake = ReturnType +const asContents = (fake: Fake) => fake as never + +describe('focused preview guest gestures', () => { + it('navigates the focused guest its host reports on screen', () => { + const host = fakeContents('window') + const guest = fakeContents('webview', host) + + notePreviewGuestHidden(asContents(host), asContents(guest), false) + + expect(commandFocusedGuest('back', asContents(guest))).toBe(true) + expect(guest.navigationHistory.goBack).toHaveBeenCalledOnce() + }) + + // A hidden session's kept Browser is still Chromium's focused webContents + // after the switch: a mouse back on the session now on screen must not walk + // that page's history (or reload it) — it falls through to the renderer. + it('leaves a focused guest its host reported hidden untouched', () => { + const host = fakeContents('window') + const guest = fakeContents('webview', host) + + notePreviewGuestHidden(asContents(host), asContents(guest), true) + + expect(commandFocusedGuest('back', asContents(guest))).toBe(false) + expect(commandFocusedGuest('forward', asContents(guest))).toBe(false) + expect(commandFocusedGuest('reload', asContents(guest))).toBe(false) + expect(guest.navigationHistory.goBack).not.toHaveBeenCalled() + expect(guest.navigationHistory.goForward).not.toHaveBeenCalled() + expect(guest.reload).not.toHaveBeenCalled() + + // Shown again: the user is back in it. + notePreviewGuestHidden(asContents(host), asContents(guest), false) + expect(commandFocusedGuest('back', asContents(guest))).toBe(true) + expect(guest.navigationHistory.goBack).toHaveBeenCalledOnce() + }) + + it('takes visibility only from the renderer that embeds the guest', () => { + const host = fakeContents('window') + const stranger = fakeContents('window') + const guest = fakeContents('webview', host) + + notePreviewGuestHidden(asContents(stranger), asContents(guest), true) + + expect(commandFocusedGuest('back', asContents(guest))).toBe(true) + }) + + it('leaves app chrome focus to the renderer', () => { + expect(commandFocusedGuest('back', asContents(fakeContents('window')))).toBe(false) + expect(commandFocusedGuest('back', null)).toBe(false) + }) +}) diff --git a/apps/desktop/electron/preview-guest-offscreen.ts b/apps/desktop/electron/preview-guest-offscreen.ts new file mode 100644 index 0000000000..259d226403 --- /dev/null +++ b/apps/desktop/electron/preview-guest-offscreen.ts @@ -0,0 +1,80 @@ +/** + * Preview guests that are mounted but OFF SCREEN. + * + * A hidden session's Browser stays mounted (hidden, inert) so returning to + * that session shows the same page. Chromium still tracks its guest as the + * focused webContents after the switch — nothing on the host side moves a + * guest's webContents focus when its `` is hidden — so a gesture + * that acts on "the focused guest" (mouse back/forward, swipe, ⌘R) would + * navigate a page the user cannot see, in a session they have left. + * + * Only the host renderer knows which `` is on screen (its layout + * tree, not Chromium, decides), so it reports each guest's visibility here + * and main skips a hidden one; the gesture then falls through to the + * renderer, which routes it to the visible pane. + */ + +import type { WebContents } from 'electron' + +type GuestCommand = 'back' | 'forward' | 'reload' + +const hiddenGuests = new Set() +const watchedGuests = new WeakSet() + +/** Record what the host renderer `sender` reports for one of ITS guests. A + * renderer may only speak for the guests it embeds. */ +export function notePreviewGuestHidden(sender: WebContents, guest: null | undefined | WebContents, hidden: boolean) { + if (!guest || guest.isDestroyed() || guest.getType() !== 'webview' || guest.hostWebContents !== sender) { + return + } + + if (!hidden) { + hiddenGuests.delete(guest.id) + + return + } + + hiddenGuests.add(guest.id) + + if (!watchedGuests.has(guest)) { + watchedGuests.add(guest) + guest.once('destroyed', () => hiddenGuests.delete(guest.id)) + } +} + +export function isPreviewGuestHidden(guest: WebContents): boolean { + return hiddenGuests.has(guest.id) +} + +/** + * Run a browser gesture on the guest page the user is actually in, if any. + * + * A `` guest is its own out-of-process webContents: pointer and focus + * events inside the page never reach the host document, so NOTHING in the + * renderer — not `document.activeElement`, not the layout tree's hover/focus + * ladder — can see that the user is in there. Main can: Electron tracks the + * focused webContents across processes. A guest its host reported hidden is + * not "the page the user is in", whatever Chromium's focus says. + * + * Returns false when focus is in the app's own chrome (or on a hidden guest), + * where the renderer is the one that knows which pane is active. + */ +export function commandFocusedGuest(command: GuestCommand, focused: null | undefined | WebContents): boolean { + if (!focused || focused.isDestroyed() || focused.getType() !== 'webview' || isPreviewGuestHidden(focused)) { + return false + } + + const history = focused.navigationHistory + + if (command === 'reload') { + focused.reload() + } else if (command === 'back') { + if (history.canGoBack()) { + history.goBack() + } + } else if (history.canGoForward()) { + history.goForward() + } + + return true +} diff --git a/apps/desktop/src/app/chat/right-rail/preview-guest-offscreen.ts b/apps/desktop/src/app/chat/right-rail/preview-guest-offscreen.ts new file mode 100644 index 0000000000..abe72b57e3 --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/preview-guest-offscreen.ts @@ -0,0 +1,40 @@ +/** + * A Browser guest that is mounted but OFF SCREEN — an inactive tab, a + * collapsed side, or a hidden session's kept-alive page — must not act like + * the page the user is on. + * + * Chromium keeps tracking a hidden guest as the focused webContents (nothing + * moves a guest's focus when its `` is hidden), so main's + * focused-guest gestures (mouse back/forward, swipe, ⌘R) would navigate it. + * Only this renderer knows what is on screen, so it tells main. + */ + +import { type RefObject, useEffect } from 'react' + +import { usePaneVisible } from '@/components/pane-shell/pane-visibility' + +export interface OffscreenGuest { + getWebContentsId?: () => number +} + +/** The guest's webContents id, or null before attach / after removal (Electron + * throws rather than returning nothing). */ +function guestId(webview: null | OffscreenGuest): null | number { + try { + return webview?.getWebContentsId?.() ?? null + } catch { + return null + } +} + +export function usePreviewGuestOffscreen(webviewRef: RefObject): void { + const hidden = !usePaneVisible() + + useEffect(() => { + const id = guestId(webviewRef.current) + + if (id !== null) { + window.hermesDesktop?.setPreviewGuestHidden?.(id, hidden) + } + }, [hidden, webviewRef]) +} diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx index e73d504701..a34b74f3fe 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx @@ -2,6 +2,7 @@ import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { onComposerAttachImagesRequest } from '@/app/chat/composer/focus' +import { PaneVisibleContext } from '@/components/pane-shell/pane-visibility' import { $previewTabs, closeRightRail, openPreview, previewTabId } from '@/store/preview' import { $connection, $selectedStoredSessionId } from '@/store/session' @@ -888,6 +889,62 @@ describe('PreviewPane guest external handoff', () => { }) }) +describe('PreviewPane off-screen guest', () => { + const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] } + const initialHermesDesktop = desktopWindow.hermesDesktop + + const target = { + kind: 'url', + label: 'Preview', + source: 'http://localhost:8502', + url: 'http://localhost:8502' + } as const + + afterEach(() => { + cleanup() + + if (initialHermesDesktop) { + desktopWindow.hermesDesktop = initialHermesDesktop + } else { + delete desktopWindow.hermesDesktop + } + }) + + // Chromium keeps a hidden guest as the focused webContents, so main's + // mouse back / ⌘R would act on a page the user cannot see unless the pane + // tells main the guest left the screen — and that it came back. + it('tells main when its guest leaves the screen and when it returns', async () => { + const setPreviewGuestHidden = vi.fn() + desktopWindow.hermesDesktop = { setPreviewGuestHidden } as unknown as Window['hermesDesktop'] + + const pane = (visible: boolean) => ( + + + + ) + + let rendered!: ReturnType + + await act(async () => { + rendered = render(pane(true)) + }) + + const webview = rendered.container.querySelector('webview') as HTMLElement & { getWebContentsId?: () => number } + webview.getWebContentsId = () => 41 + setPreviewGuestHidden.mockClear() + + await act(async () => { + rendered.rerender(pane(false)) + }) + expect(setPreviewGuestHidden).toHaveBeenLastCalledWith(41, true) + + await act(async () => { + rendered.rerender(pane(true)) + }) + expect(setPreviewGuestHidden).toHaveBeenLastCalledWith(41, false) + }) +}) + describe('PreviewPane local HTML Render|Source toggle', () => { const target = { kind: 'file' as const, diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx index 1c4dc0ad9b..3953e140fe 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx @@ -70,6 +70,7 @@ import { import { type ConsoleEntry } from './preview-console-state' import { previewConsoleState } from './preview-console-store' import { LocalFilePreview, PreviewEmptyState, PreviewModeSwitcher } from './preview-file' +import { usePreviewGuestOffscreen } from './preview-guest-offscreen' import { type PreviewInputEvent, registerPreviewInput, toWebviewInputSpace } from './preview-input' import { PREVIEW_BROWSER_ATTR, registerPreviewNav } from './preview-nav' import { registerPreviewPageReader } from './preview-reader' @@ -275,6 +276,7 @@ export function PreviewPane({ const lastRestartEventRef = useRef('') const previewContentRef = useRef(null) const webviewRef = useRef(null) + usePreviewGuestOffscreen(webviewRef) const previewServerRestart = useStore($previewServerRestart) const consoleHeight = useStore(consoleState.$height) const consoleOpen = useStore(consoleState.$open) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/keep-alive-panes.test.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/keep-alive-panes.test.tsx index c313e832da..70b9c9af64 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/keep-alive-panes.test.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/keep-alive-panes.test.tsx @@ -189,6 +189,36 @@ it('keeps a live body continuously connected across replacement IDs, ancestry an expect(disconnected).not.toContain(page) }) +// `inert` does not move focus off an element that already holds it: a hidden +// session's kept Browser would keep taking the keystrokes meant for the chat +// now on screen. A host going hidden hands focus back to the document. +it('releases focus held inside a live body when its host is hidden', () => { + registerPane('live') + registerPane('plain', false) + setTree(group(['live'], { id: 'shown' })) + render() + const page = guest()! + const input = page.querySelector('input')! + const doc = page.ownerDocument + + input.focus() + expect(doc.activeElement).toBe(input) + + setTree(group(['plain'], { id: 'other-session' })) + expect(guest()).toBe(page) + expect(page.closest('[data-pane-hidden]')?.hasAttribute('inert')).toBe(true) + expect(page.contains(doc.activeElement)).toBe(false) + + // Focus elsewhere is not the hidden host's to take: hiding again leaves it. + setTree(group(['live'], { id: 'shown-again' })) + const outside = doc.createElement('button') + doc.body.append(outside) + outside.focus() + setTree(group(['plain'], { id: 'other-session-again' })) + expect(doc.activeElement).toBe(outside) + outside.remove() +}) + it('destroys removed contributions and does not revive their old activation on re-registration', () => { const remove = registerPane('live') registerPane('plain', false) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/keep-alive-panes.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/keep-alive-panes.tsx index a2d015f7e6..51130f540c 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/keep-alive-panes.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/keep-alive-panes.tsx @@ -125,6 +125,18 @@ const KeepAlivePaneHost = memo(function KeepAlivePaneHost({ pane, placement, epo useResizeObserver(rememberSize, ref) + // A host going hidden gives up focus. `inert` alone leaves the focused + // element (a webview guest) as activeElement, so keystrokes would keep + // landing in a page the user can no longer see — a hidden session's kept + // Browser. Blurring hands focus back to the document. + useLayoutEffect(() => { + const active = document.activeElement + + if (!placement.visible && active instanceof HTMLElement && ref.current?.contains(active)) { + active.blur() + } + }, [placement.visible]) + return (
void ) => () => void setPreviewShortcutActive?: (active: boolean) => void + /** Tell main a preview guest is off screen, so focused-guest gestures skip it. */ + setPreviewGuestHidden?: (webContentsId: number, hidden: boolean) => void openExternal: (url: string) => Promise onExternalOpenFailed?: (callback: (payload: ExternalOpenFailedPayload) => void) => () => void /** One-shot loopback callback listener for MCP OAuth against remote