fix(desktop): mark kept-alive panes so document lookups can skip them

Inactive tabs stay mounted and hidden with `visibility`, which preserves
their layout box — so a background tab answers a document-wide selector
with a rect identical to the visible tab's. Tag hidden layers and route
those lookups through visible-scoped query helpers.
This commit is contained in:
Brooklyn Nicholson 2026-07-25 00:09:30 -05:00
parent ca35663013
commit 9285de4a41
2 changed files with 32 additions and 1 deletions

View file

@ -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<string, string> => (hidden ? { [PANE_HIDDEN_ATTR]: '' } : {})
/** `querySelectorAll` minus anything inside an inactive tab. */
export const queryAllVisible = <T extends HTMLElement>(selector: string, root: ParentNode = document): T[] =>
[...root.querySelectorAll<T>(selector)].filter(el => !el.closest(HIDDEN_PANE))
/** `querySelector` minus anything inside an inactive tab. */
export const queryVisible = <T extends HTMLElement>(selector: string, root: ParentNode = document): null | T =>
queryAllVisible<T>(selector, root)[0] ?? null

View file

@ -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 && (
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden">
{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 ? (
<ContribBoundary id={pane.id}>{pane.render()}</ContribBoundary>