fix(desktop): keep hidden browser pages and automation alive

Separate Hide/Restore from Close for the embedded Browser: a minimized
zone keeps its body mounted (PaneBody retains the last visible viewport,
visibility:hidden + inert) instead of unmounting the <webview>, url/html
preview tiles opt into lifecycleKeepAlive via PaneMirror, the collapse
action reads "Hide" for keep-alive panes, commandFocusedPreview ignores a
hidden guest with stale native focus, and drive_preview's focus() no
longer steals the host composer while the pane is hidden.

Salvage-TRIM of #114251 (kernel only). Dropped from the original:
DESIGN.md, the apps/desktop/scripts/browser-hide-restore/ Electron smoke
harness, the three-layer host-focus bounce around executeJavaScript /
sendInputEvent / focusin in preview-pane.tsx, and the extra test files
(pane-body.test.tsx, preview-script-runner.test.ts, preview-tile.test.ts,
two of three tree-group cases).
This commit is contained in:
Vaibhav Arora
2026-09-18 01:46:36 -07:00
committed by Teknium
parent 93e7c4f402
commit 73d4a4e9cb
7 changed files with 132 additions and 52 deletions

View File

@@ -43,6 +43,8 @@ export interface PaneMirror<T> {
* Per tile so a mirror can offer it for some of its tabs and not others. */
newTab?: (key: string) => (() => void) | undefined
render: (key: string) => ReactNode
/** Stateful resources must survive the zone's inactive-tab cache eviction. */
lifecycleKeepAlive?: (key: string) => boolean
/** Extra rows at the top of the zone tab menu (see PaneChrome.tabMenuPrefix). */
tabMenuPrefix?: (key: string) => ((kit: MenuKit) => ReactNode) | undefined
/** Wrap the tile's TAB (domain context menu — session verbs). */
@@ -87,6 +89,7 @@ export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
pane: cfg.anchor?.(tile) ?? 'workspace',
pos: cfg.dir?.(tile) ?? 'right'
},
lifecycleKeepAlive: cfg.lifecycleKeepAlive?.(key),
minWidth: cfg.minWidth,
newTab: cfg.newTab?.(key),
// Every mirrored tile is a full workspace surface docked beside main —

View File

@@ -266,6 +266,11 @@ const watchPreviewTileMirror = paneMirror<{ id: string }>({
// a kind and leaves the strip's "+" to whatever else the zone holds.
newTab: tabId => (targetFor(tabId)?.kind === 'url' ? newBrowserTab : undefined),
tabMenuPrefix: browserTabMenuPrefix,
lifecycleKeepAlive: tabId => {
const target = targetFor(tabId)
return target?.kind === 'url' || target?.previewKind === 'html'
},
render: tabId => <PreviewTilePane tabId={tabId} />,
close: tabId => {
forgetBrowserPage(tabId)

View File

@@ -23,6 +23,17 @@ afterEach(() => {
// inside the guest page never reaches this renderer at all — main handles that
// against the focused webContents (see `commandFocusedGuest` in main.ts).
describe('commandFocusedPreview', () => {
it('never routes host keyboard commands to a hidden guest with stale native focus', () => {
const handle = nav()
const host = mountPane('url:hidden')
const unregister = registerPreviewNav('url:hidden', handle)
host.focus()
host.setAttribute('data-pane-hidden', '')
expect(commandFocusedPreview('reload')).toBe(false)
expect(handle.reload).not.toHaveBeenCalled()
unregister()
})
it('reports no handler when focus is elsewhere, so ⌘R falls back to the window', () => {
const handle = nav()

View File

@@ -9,6 +9,7 @@
* sitting in Hermes' own DOM, where `activeElement` is authoritative.
*/
import { isElementInHiddenPane } from '@/components/pane-shell/pane-visibility'
import { $rightRailActiveTabId } from '@/store/layout'
import { $previewTabs } from '@/store/preview'
@@ -47,7 +48,9 @@ export function activePreviewNav(): PreviewNavHandle | null {
* elsewhere in the app, so the caller falls back to the app-level meaning. */
export function commandFocusedPreview(command: keyof PreviewNavHandle): boolean {
const host = document.activeElement?.closest(`[${PREVIEW_BROWSER_ATTR}]`)
const nav = host ? handles.get(host.getAttribute(PREVIEW_BROWSER_ATTR) || '') : undefined
const nav =
host && !isElementInHiddenPane(host) ? handles.get(host.getAttribute(PREVIEW_BROWSER_ATTR) || '') : undefined
nav?.[command]()

View File

@@ -9,6 +9,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { requestComposerAttachImages, requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
import { openGuestContextMenu } from '@/app/context-menu/store'
import { PanelEmpty } from '@/app/overlays/panel'
import { isElementInHiddenPane } from '@/components/pane-shell/pane-visibility'
import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { isDesktopFsRemoteMode } from '@/lib/desktop-fs'
@@ -800,7 +801,15 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest =
}
return registerPreviewInput(tabId, {
focus: () => webviewRef.current?.focus?.(),
focus: () => {
const webview = webviewRef.current
// Trusted input still reaches the guest while hidden. Focusing the
// webview element would steal the host's composer focus even when inert.
if (webview && !isElementInHiddenPane(webview)) {
webview.focus?.()
}
},
send: event => {
const webview = webviewRef.current

View File

@@ -0,0 +1,44 @@
import { type ReactNode, useLayoutEffect, useRef, useState } from 'react'
interface PaneBodyProps {
hidden: boolean
children: ReactNode
}
/** Collapse the zone, not its guests. Retaining the last visible viewport lets
* background browser input use the same page coordinates while the restore
* rail occupies only a sliver of the layout. Never detach/reparent a webview. */
export function PaneBody({ hidden, children }: PaneBodyProps) {
const ref = useRef<HTMLDivElement>(null)
const [size, setSize] = useState<{ width: number; height: number }>()
useLayoutEffect(() => {
const body = ref.current
if (hidden || !body) {
return
}
const observer = new ResizeObserver(entries => {
const { width, height } = entries[0].contentRect
if (width > 0 && height > 0) {
setSize(previous => (previous?.width === width && previous.height === height ? previous : { width, height }))
}
})
observer.observe(body)
return () => observer.disconnect()
}, [hidden])
return (
<div
className="relative min-h-0 min-w-0 flex-1 overflow-hidden"
ref={ref}
style={hidden ? { position: 'absolute', visibility: 'hidden', pointerEvents: 'none', ...size } : undefined}
>
{children}
</div>
)
}

View File

@@ -85,6 +85,7 @@ import {
} from '../tab-selection'
import { startPaneDrag } from './drag-session'
import { PaneBody } from './pane-body'
import { usePanelTitlebar } from './panel-titlebar'
import { tabStripVisibleForZone } from './strip-visibility'
import { useActiveTabVisible } from './tab-strip-scroll'
@@ -99,6 +100,7 @@ function ZoneMenu({
children,
closable,
minimizable = true,
minimizeLabel,
minimized,
nodeId,
stripVisible,
@@ -112,6 +114,7 @@ function ZoneMenu({
/** False for the zone hosting the uncloseable workspace — collapsing the
* MAIN pane strands the app behind a strip. */
minimizable?: boolean
minimizeLabel?: string
minimized?: boolean
nodeId: string
/** Whether the strip is on screen — the Hide/Show row toggles against what
@@ -206,7 +209,7 @@ function ZoneMenu({
// Same action-direction contract as the strip button below: the
// icon points where the zone will GO (restore opens upward).
icon: minimized ? 'chevron-up' : 'chevron-down',
label: minimized ? t.zones.restore : t.zones.minimize,
label: minimized ? t.zones.restore : (minimizeLabel ?? t.zones.minimize),
onSelect: () => setTreeGroupMinimized(nodeId, !minimized)
})}
</>
@@ -422,9 +425,12 @@ export function TreeGroup({
const toggleCollapse = () => (node.minimized ? restoreTreePane(activeId) : collapseTreePane(activeId))
// Same menu on the header strip and the edit veil — one prop bag.
const minimizeLabel = paneChrome(active).lifecycleKeepAlive ? t.preview.hide : t.zones.minimize
const zoneMenu = {
closable,
minimizable,
minimizeLabel,
minimized: node.minimized,
nodeId: node.id,
stripVisible,
@@ -558,7 +564,7 @@ export function TreeGroup({
<>
{minimizable && (
<button
aria-label={node.minimized ? t.zones.restore : t.zones.minimize}
aria-label={node.minimized ? t.zones.restore : minimizeLabel}
className="mx-1 grid size-5 shrink-0 place-items-center self-center [-webkit-app-region:no-drag] rounded-md text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:opacity-100 group-hover/pane-header:opacity-100"
onClick={toggleCollapse}
onPointerDown={e => e.stopPropagation()}
@@ -742,55 +748,54 @@ export function TreeGroup({
scroll positions and measurements survive the round-trip — which also
makes a hidden layer's rect identical to the visible one's, hence the
marker document-wide lookups filter on (see pane-visibility.ts). */}
{!node.minimized && (
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden">
{isEmpty ? (
<div className="grid h-full place-items-center">
{/* Same decode primitive as the CONNECTING boot overlay. */}
<DecodeText className="text-(--ui-text-quaternary)" cursor prefix={1} text="HERMES" />
</div>
) : (
keptPanes.map(paneId => {
const pane = paneFor(paneId)
const isActive = paneId === activeId
<PaneBody hidden={Boolean(node.minimized)}>
{isEmpty ? (
<div className="grid h-full place-items-center">
{/* Same decode primitive as the CONNECTING boot overlay. */}
<DecodeText className="text-(--ui-text-quaternary)" cursor prefix={1} text="HERMES" />
</div>
) : (
keptPanes.map(paneId => {
const pane = paneFor(paneId)
const isActive = paneId === activeId && !node.minimized
return (
<div
aria-hidden={!isActive || undefined}
className={cn('absolute inset-0 overflow-auto', !isActive && 'pointer-events-none invisible')}
key={paneId}
{...hiddenPaneProps(!isActive)}
>
{pane?.render ? (
// Visibility flows to the pane so a kept-alive chat surface
// can gate its hot (per-token) subscriptions while hidden;
// the group id identifies the ZONE it lives in, for state
// that is per-zone rather than per-tab (composer pop-out).
// The reload epoch keys the CONTENT, not this layer: a
// Reload remounts the contribution (effects re-run, state
// resets) while the layer — and every other tab — stays.
<PaneGroupContext.Provider value={node.id}>
<PaneLifecycleContext.Provider value={paneLifecycle[paneId]?.lifecycle ?? 'visible'}>
<PaneVisibleContext.Provider value={isActive}>
<ContribBoundary id={pane.id} key={paneEpochs[paneId] ?? 0}>
<ContribRender render={pane.render} />
</ContribBoundary>
</PaneVisibleContext.Provider>
</PaneLifecycleContext.Provider>
</PaneGroupContext.Provider>
) : (
isActive && (
<div className="p-3 font-mono text-[11px] text-(--ui-text-quaternary)">
{t.zones.missingPane(paneId)}
</div>
)
)}
</div>
)
})
)}
</div>
)}
return (
<div
aria-hidden={!isActive || undefined}
className={cn('absolute inset-0 overflow-auto', !isActive && 'pointer-events-none invisible')}
inert={!isActive || undefined}
key={paneId}
{...hiddenPaneProps(!isActive)}
>
{pane?.render ? (
// Visibility flows to the pane so a kept-alive chat surface
// can gate its hot (per-token) subscriptions while hidden;
// the group id identifies the ZONE it lives in, for state
// that is per-zone rather than per-tab (composer pop-out).
// The reload epoch keys the CONTENT, not this layer: a
// Reload remounts the contribution (effects re-run, state
// resets) while the layer — and every other tab — stays.
<PaneGroupContext.Provider value={node.id}>
<PaneLifecycleContext.Provider value={isActive ? 'visible' : 'hot-hidden'}>
<PaneVisibleContext.Provider value={isActive}>
<ContribBoundary id={pane.id} key={paneEpochs[paneId] ?? 0}>
<ContribRender render={pane.render} />
</ContribBoundary>
</PaneVisibleContext.Provider>
</PaneLifecycleContext.Provider>
</PaneGroupContext.Provider>
) : (
isActive && (
<div className="p-3 font-mono text-[11px] text-(--ui-text-quaternary)">
{t.zones.missingPane(paneId)}
</div>
)
)}
</div>
)
})
)}
</PaneBody>
{/* Edit-mode veil: the BODY is a drag handle for the active pane. It
starts below the header so tabs/headers stay directly interactive