diff --git a/apps/desktop/src/app/chat/pane-mirror.ts b/apps/desktop/src/app/chat/pane-mirror.ts index 96657794ee..4b95b367ed 100644 --- a/apps/desktop/src/app/chat/pane-mirror.ts +++ b/apps/desktop/src/app/chat/pane-mirror.ts @@ -43,6 +43,8 @@ export interface PaneMirror { * 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(cfg: PaneMirror): () => 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 — diff --git a/apps/desktop/src/app/chat/preview-tile.tsx b/apps/desktop/src/app/chat/preview-tile.tsx index 7777cf954c..df6356d5e9 100644 --- a/apps/desktop/src/app/chat/preview-tile.tsx +++ b/apps/desktop/src/app/chat/preview-tile.tsx @@ -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 => , close: tabId => { forgetBrowserPage(tabId) diff --git a/apps/desktop/src/app/chat/right-rail/preview-nav.test.ts b/apps/desktop/src/app/chat/right-rail/preview-nav.test.ts index d19f5b2592..7d81b23050 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-nav.test.ts +++ b/apps/desktop/src/app/chat/right-rail/preview-nav.test.ts @@ -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() diff --git a/apps/desktop/src/app/chat/right-rail/preview-nav.ts b/apps/desktop/src/app/chat/right-rail/preview-nav.ts index 5e03db3005..dfe5b08dc7 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-nav.ts +++ b/apps/desktop/src/app/chat/right-rail/preview-nav.ts @@ -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]() 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 6ee1715da5..1604d5b343 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx @@ -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 diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/pane-body.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/pane-body.tsx new file mode 100644 index 0000000000..2e55c8658d --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/pane-body.tsx @@ -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(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 ( +
+ {children} +
+ ) +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx index 9067ee12bd..84d8764cd2 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -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 && (