diff --git a/apps/desktop/src/components/pane-shell/pane-visibility.ts b/apps/desktop/src/components/pane-shell/pane-visibility.ts new file mode 100644 index 000000000000..e16975b5224e --- /dev/null +++ b/apps/desktop/src/components/pane-shell/pane-visibility.ts @@ -0,0 +1,27 @@ +/** + * Keep-alive visibility — the one policy every document-wide lookup must obey. + * + * A tab group keeps each ever-active pane MOUNTED and hides the inactive ones + * with `visibility: hidden` (see `tree/renderer/tree-group.tsx`), deliberately + * preserving their layout box so scroll positions survive a tab round-trip. The + * cost is that an inactive tab's rect is IDENTICAL to the visible tab's, so + * neither selector order nor a rect hit-test can tell them apart. A lookup that + * resolves "the chat surface / composer / viewport" from the document therefore + * has to skip hidden panes, or it silently answers with the wrong tab. + */ + +/** Marks a mounted-but-hidden pane layer (an inactive tab in a stack). */ +export const PANE_HIDDEN_ATTR = 'data-pane-hidden' + +const HIDDEN_PANE = `[${PANE_HIDDEN_ATTR}]` + +/** Spread onto a kept pane layer so the lookups below can skip it. */ +export const hiddenPaneProps = (hidden: boolean): Record => (hidden ? { [PANE_HIDDEN_ATTR]: '' } : {}) + +/** `querySelectorAll` minus anything inside an inactive tab. */ +export const queryAllVisible = (selector: string, root: ParentNode = document): T[] => + [...root.querySelectorAll(selector)].filter(el => !el.closest(HIDDEN_PANE)) + +/** `querySelector` minus anything inside an inactive tab. */ +export const queryVisible = (selector: string, root: ParentNode = document): null | T => + queryAllVisible(selector, root)[0] ?? null 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 0d14929b4210..6df3e2163dfc 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 @@ -30,6 +30,7 @@ import { cn } from '@/lib/utils' import { $layoutEditMode } from '../../edit-mode' import { useWindowControlsOverlap } from '../../geometry' +import { hiddenPaneProps } from '../../pane-visibility' import type { DropPosition, GroupNode, RootEdge } from '../model' import { adjacentGroup } from '../model' import { @@ -520,7 +521,9 @@ export function TreeGroup({ {/* Body: the zone's pane content — every kept (ever-active) pane stays mounted in an absolute layer; only the active one is visible. `visibility` (not display) keeps the hidden pane's layout box, so - scroll positions and measurements survive the round-trip. */} + 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 && (
{isEmpty ? ( @@ -538,6 +541,7 @@ export function TreeGroup({ aria-hidden={!isActive || undefined} className={cn('absolute inset-0 overflow-auto', !isActive && 'pointer-events-none invisible')} key={paneId} + {...hiddenPaneProps(!isActive)} > {pane?.render ? ( {pane.render()}