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:
@@ -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 —
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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]()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user