fix(desktop): mouse back/forward and reload never act on a hidden session's Browser

After a session switch, Chromium still tracked the hidden session's kept
Browser guest as the focused webContents, so main's focused-guest gestures
(mouse back/forward app-command, swipe, Ctrl/Cmd+R) navigated or reloaded a
page the user could not see. The keep-alive host also left focus on an inert
hidden guest, where keystrokes could still land.

- A keep-alive host going hidden blurs whatever inside it holds focus.
- The preview pane tells main when its guest leaves / returns to the screen
  (hermes:preview-guest-hidden; only the embedding renderer is believed), and
  commandFocusedGuest (moved to electron/preview-guest-offscreen.ts) skips a
  hidden guest so the gesture falls through to the renderer's visible pane.
This commit is contained in:
teknium1
2026-10-01 23:47:45 -07:00
committed by Teknium
parent c2b049241d
commit 3cb409bed8
10 changed files with 313 additions and 42 deletions

View File

@@ -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 `<webview>` 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)

View File

@@ -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

View File

@@ -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<typeof fakeContents>
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)
})
})

View File

@@ -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 `<webview>` 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 `<webview>` 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<number>()
const watchedGuests = new WeakSet<WebContents>()
/** 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 `<webview>` 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
}

View File

@@ -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 `<webview>` 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<null | OffscreenGuest>): void {
const hidden = !usePaneVisible()
useEffect(() => {
const id = guestId(webviewRef.current)
if (id !== null) {
window.hermesDesktop?.setPreviewGuestHidden?.(id, hidden)
}
}, [hidden, webviewRef])
}

View File

@@ -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) => (
<PaneVisibleContext value={visible}>
<PreviewPane tabId="url:offscreen" target={target} />
</PaneVisibleContext>
)
let rendered!: ReturnType<typeof render>
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,

View File

@@ -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<HTMLDivElement | null>(null)
const webviewRef = useRef<PreviewWebview | null>(null)
usePreviewGuestOffscreen(webviewRef)
const previewServerRestart = useStore($previewServerRestart)
const consoleHeight = useStore(consoleState.$height)
const consoleOpen = useStore(consoleState.$open)

View File

@@ -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(<LayoutTreeRoot />)
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)

View File

@@ -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 (
<div
{...hiddenPaneProps(!placement.visible)}

View File

@@ -407,6 +407,8 @@ declare global {
}) => 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<void>
onExternalOpenFailed?: (callback: (payload: ExternalOpenFailedPayload) => void) => () => void
/** One-shot loopback callback listener for MCP OAuth against remote