From bbe654a5c7e73cb489666437871ada6bb5785877 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 12 Aug 2026 02:11:46 -0700 Subject: [PATCH] fix(renderer): recover overlay hit-test after pane-column snap After a side-by-side tab/pane snap, CSS anchor positioning can leave worktree overlays covering tab chrome with a desynced hit-test box. Detect body/overlay geometry mismatch and pin terminal, browser, and emulator overlays to measured body rects until the owning group changes. Fixes #13297 --- .../browser-pane/BrowserPaneOverlayLayer.tsx | 62 ++++++-- .../EmulatorPaneOverlayLayer.tsx | 42 ++++- .../tab-group/overlay-slot-geometry.test.ts | 129 ++++++++++++++++ .../tab-group/overlay-slot-geometry.ts | 76 +++++++++ .../tab-group/use-overlay-slot-geometry.ts | 110 +++++++++++++ .../components/tab-group/useTabDragSplit.ts | 6 + .../TerminalOverlaySlot.geometry.test.tsx | 145 ++++++++++++++++++ .../terminal-pane/TerminalOverlaySlot.tsx | 98 +++--------- 8 files changed, 571 insertions(+), 97 deletions(-) create mode 100644 src/renderer/src/components/tab-group/overlay-slot-geometry.test.ts create mode 100644 src/renderer/src/components/tab-group/overlay-slot-geometry.ts create mode 100644 src/renderer/src/components/tab-group/use-overlay-slot-geometry.ts create mode 100644 src/renderer/src/components/terminal-pane/TerminalOverlaySlot.geometry.test.tsx diff --git a/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx b/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx index 67d6aa4da9e..45b28b7a25c 100644 --- a/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx @@ -1,13 +1,27 @@ -import { memo, useCallback, useLayoutEffect, useMemo, useState } from 'react' +import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' import { registerBrowserOverlaySlotViewport } from './browser-page-viewport' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '../../store' import type { BrowserTab as BrowserTabState, Tab, TabGroup } from '../../../../shared/types' import BrowserPane, { type BrowserFindShortcutScope } from './BrowserPane' import { tabGroupBodyAnchorName } from '../tab-group/tab-group-body-anchor' +import { useOverlaySlotGeometry } from '../tab-group/use-overlay-slot-geometry' import { useBrowserAutomationVisibilityForAny } from './browser-automation-visibility' import { useBrowserMobileDriverForAny } from '@/lib/pane-manager/browser-mobile-driver-state' +const HAS_CSS_ANCHOR_POSITIONING = + typeof CSS !== 'undefined' && + CSS.supports('position-anchor', '--orca-browser-overlay-probe') && + CSS.supports('top', 'anchor(--orca-browser-overlay-probe top)') && + CSS.supports('width', 'anchor-size(--orca-browser-overlay-probe width)') + +function shouldUseCssAnchorPositioning(): boolean { + return ( + HAS_CSS_ANCHOR_POSITIONING && + (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ !== true + ) +} + // Why: Electron destroys its guest on DOM reparent, so BrowserPanes render at worktree level and moving a tab between groups only swaps the overlay's CSS position-anchor. type BrowserOverlayAssignment = { @@ -39,6 +53,7 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({ onFocusOwningGroup, isWorktreeActive }: BrowserOverlaySlotProps): React.JSX.Element { + const overlayRef = useRef(null) // Why: persistent page viewports (webview guests) live under this root so they survive BrowserPane chrome unmounts without reparenting. const setSlotViewportRef = useCallback( (node: HTMLDivElement | null): void => { @@ -56,11 +71,17 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({ const isPaintable = isActive || automationVisible || mobileDriven // Why: hidden worktrees keep lightweight overlay slots, but park their webviews unless a remote controller needs the guest. const shouldMountPane = isWorktreeActive || automationVisible || mobileDriven - // Why: CSS anchor positioning pins the overlay to its owning group's body — a tab move only swaps positionAnchor, no measurement/state. - // Orphan branch (no anchorName) stays display:none until the tab is reassigned or destroyed. + const { measuredRect, useCssAnchors } = useOverlaySlotGeometry({ + overlayRef, + groupId, + cssAnchorsSupported: shouldUseCssAnchorPositioning(), + isVisible: isPaintable + }) + // Why: CSS anchors are preferred; after a column snap they can leave the + // webview covering tab chrome, so measured geometry is the recovery path. const style: React.CSSProperties = useMemo( () => - anchorName + anchorName && useCssAnchors ? { position: 'absolute', positionAnchor: anchorName, @@ -72,16 +93,27 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({ pointerEvents: isActive ? 'auto' : 'none', opacity: isActive ? 1 : 0 } - : { - position: 'absolute', - top: 0, - left: 0, - width: 0, - height: 0, - display: 'none', - pointerEvents: 'none' - }, - [anchorName, isActive, isPaintable] + : anchorName + ? { + position: 'absolute', + top: measuredRect?.top ?? 32, + left: measuredRect?.left ?? 0, + width: measuredRect?.width ?? '100%', + height: measuredRect?.height ?? 'calc(100% - 32px)', + display: isPaintable ? 'flex' : 'none', + pointerEvents: isActive ? 'auto' : 'none', + opacity: isActive ? 1 : 0 + } + : { + position: 'absolute', + top: 0, + left: 0, + width: 0, + height: 0, + display: 'none', + pointerEvents: 'none' + }, + [anchorName, isActive, isPaintable, measuredRect, useCssAnchors] ) const handleFocus = useCallback(() => { if (groupId !== undefined && onFocusOwningGroup) { @@ -91,9 +123,11 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({ return (
diff --git a/src/renderer/src/components/emulator-pane/EmulatorPaneOverlayLayer.tsx b/src/renderer/src/components/emulator-pane/EmulatorPaneOverlayLayer.tsx index 8d15f611003..11b66c6e534 100644 --- a/src/renderer/src/components/emulator-pane/EmulatorPaneOverlayLayer.tsx +++ b/src/renderer/src/components/emulator-pane/EmulatorPaneOverlayLayer.tsx @@ -1,13 +1,27 @@ -import { memo, useCallback, useMemo } from 'react' +import { memo, useCallback, useMemo, useRef } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import type { Tab, TabGroup } from '../../../../shared/types' import EmulatorPane from './EmulatorPane' import { tabGroupBodyAnchorName } from '../tab-group/tab-group-body-anchor' +import { useOverlaySlotGeometry } from '../tab-group/use-overlay-slot-geometry' const EMPTY_UNIFIED_TABS: readonly Tab[] = [] const EMPTY_GROUPS: readonly TabGroup[] = [] +const HAS_CSS_ANCHOR_POSITIONING = + typeof CSS !== 'undefined' && + CSS.supports('position-anchor', '--orca-emulator-overlay-probe') && + CSS.supports('top', 'anchor(--orca-emulator-overlay-probe top)') && + CSS.supports('width', 'anchor-size(--orca-emulator-overlay-probe width)') + +function shouldUseCssAnchorPositioning(): boolean { + return ( + HAS_CSS_ANCHOR_POSITIONING && + (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ !== true + ) +} + type SimulatorOverlaySlotProps = { tab: Tab groupId: string | undefined @@ -21,10 +35,17 @@ const SimulatorOverlaySlot = memo(function SimulatorOverlaySlot({ isActive, onFocusOwningGroup }: SimulatorOverlaySlotProps): React.JSX.Element { + const overlayRef = useRef(null) const anchorName = groupId !== undefined ? tabGroupBodyAnchorName(groupId) : undefined + const { measuredRect, useCssAnchors } = useOverlaySlotGeometry({ + overlayRef, + groupId, + cssAnchorsSupported: shouldUseCssAnchorPositioning(), + isVisible: isActive + }) const style: React.CSSProperties = useMemo( () => - anchorName + anchorName && useCssAnchors ? { position: 'absolute', positionAnchor: anchorName, @@ -36,14 +57,27 @@ const SimulatorOverlaySlot = memo(function SimulatorOverlaySlot({ visibility: isActive ? 'visible' : 'hidden', pointerEvents: isActive ? 'auto' : 'none' } - : { display: 'none' }, - [anchorName, isActive] + : anchorName + ? { + position: 'absolute', + top: measuredRect?.top ?? 32, + left: measuredRect?.left ?? 0, + width: measuredRect?.width ?? '100%', + height: measuredRect?.height ?? 'calc(100% - 32px)', + zIndex: isActive ? 2 : 1, + visibility: isActive ? 'visible' : 'hidden', + pointerEvents: isActive ? 'auto' : 'none' + } + : { display: 'none' }, + [anchorName, isActive, measuredRect, useCssAnchors] ) return (
{ if (groupId && onFocusOwningGroup) { onFocusOwningGroup(groupId) diff --git a/src/renderer/src/components/tab-group/overlay-slot-geometry.test.ts b/src/renderer/src/components/tab-group/overlay-slot-geometry.test.ts new file mode 100644 index 00000000000..22eef139a9d --- /dev/null +++ b/src/renderer/src/components/tab-group/overlay-slot-geometry.test.ts @@ -0,0 +1,129 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from 'vitest' +import { + findTabGroupBodyElement, + isOverlaySlotGeometryMismatched, + measureOverlaySlotRect, + shouldPreferMeasuredOverlayGeometry +} from './overlay-slot-geometry' + +function rect(partial: Partial & Pick): DOMRect { + const top = partial.top + const left = partial.left + const width = partial.width + const height = partial.height + return { + top, + left, + width, + height, + right: left + width, + bottom: top + height, + x: left, + y: top, + toJSON: () => ({}) + } as DOMRect +} + +describe('overlay slot geometry', () => { + it('finds the tab-group body by group id', () => { + const body = document.createElement('div') + body.dataset.tabGroupBodyId = 'group-a' + document.body.appendChild(body) + expect(findTabGroupBodyElement('group-a')).toBe(body) + expect(findTabGroupBodyElement('missing')).toBeNull() + body.remove() + }) + + it('measures body geometry relative to the overlay parent', () => { + const parent = document.createElement('div') + const body = document.createElement('div') + parent.getBoundingClientRect = () => rect({ top: 100, left: 40, width: 900, height: 700 }) + body.getBoundingClientRect = () => rect({ top: 136, left: 40, width: 450, height: 664 }) + expect(measureOverlaySlotRect(parent, body)).toEqual({ + top: 36, + left: 0, + width: 450, + height: 664 + }) + }) + + it('detects post-snap overlay/body desync beyond tolerance', () => { + expect( + isOverlaySlotGeometryMismatched( + rect({ top: 0, left: 0, width: 900, height: 700 }), + rect({ top: 136, left: 40, width: 450, height: 664 }) + ) + ).toBe(true) + expect( + isOverlaySlotGeometryMismatched( + rect({ top: 136.5, left: 40.5, width: 450, height: 664 }), + rect({ top: 136, left: 40, width: 450, height: 664 }) + ) + ).toBe(false) + }) + + it('forces measured geometry when CSS-anchor hit-test drifts after a side-by-side snap', () => { + const parent = document.createElement('div') + const overlay = document.createElement('div') + const body = document.createElement('div') + body.dataset.tabGroupBodyId = 'group-snap' + parent.appendChild(overlay) + document.body.appendChild(parent) + document.body.appendChild(body) + + // Why: after a column snap the overlay can still claim the pre-snap full-area + // box while the body is only the right half — clicks then land in chrome/tabs. + parent.getBoundingClientRect = () => rect({ top: 0, left: 0, width: 1000, height: 800 }) + overlay.getBoundingClientRect = () => rect({ top: 0, left: 0, width: 1000, height: 800 }) + body.getBoundingClientRect = () => rect({ top: 36, left: 500, width: 500, height: 764 }) + + const result = shouldPreferMeasuredOverlayGeometry({ + overlay, + groupId: 'group-snap', + forceMeasured: false + }) + expect(result.preferMeasured).toBe(true) + expect(result.measured).toEqual({ + top: 36, + left: 500, + width: 500, + height: 764 + }) + + parent.remove() + body.remove() + }) + + it('keeps CSS anchors when overlay geometry already matches the body', () => { + const parent = document.createElement('div') + const overlay = document.createElement('div') + const body = document.createElement('div') + body.dataset.tabGroupBodyId = 'group-ok' + parent.appendChild(overlay) + document.body.appendChild(parent) + document.body.appendChild(body) + + const matched = rect({ top: 36, left: 0, width: 900, height: 700 }) + parent.getBoundingClientRect = () => rect({ top: 0, left: 0, width: 900, height: 736 }) + overlay.getBoundingClientRect = () => matched + body.getBoundingClientRect = () => matched + + const result = shouldPreferMeasuredOverlayGeometry({ + overlay, + groupId: 'group-ok', + forceMeasured: false + }) + expect(result.preferMeasured).toBe(false) + expect(result.measured).toEqual({ + top: 36, + left: 0, + width: 900, + height: 700 + }) + + parent.remove() + body.remove() + }) +}) diff --git a/src/renderer/src/components/tab-group/overlay-slot-geometry.ts b/src/renderer/src/components/tab-group/overlay-slot-geometry.ts new file mode 100644 index 00000000000..88990699547 --- /dev/null +++ b/src/renderer/src/components/tab-group/overlay-slot-geometry.ts @@ -0,0 +1,76 @@ +// Why: worktree-level terminal/browser/emulator overlays pin to each group's +// body via CSS anchor positioning. After a pane-column snap Chromium can leave +// the painted box and hit-test box disagreeing (or leave the overlay covering +// chrome that should stay clickable). Measuring the body and correcting the +// overlay is the portable recovery path for Electron, web, and remote hosts. + +export type OverlaySlotRect = { + top: number + left: number + width: number + height: number +} + +export const OVERLAY_SLOT_GEOMETRY_MISMATCH_PX = 2 + +export function findTabGroupBodyElement(groupId: string): HTMLElement | null { + for (const candidate of document.querySelectorAll('[data-tab-group-body-id]')) { + if (candidate.dataset.tabGroupBodyId === groupId) { + return candidate + } + } + return null +} + +export function measureOverlaySlotRect( + parent: HTMLElement, + body: HTMLElement +): OverlaySlotRect { + const parentRect = parent.getBoundingClientRect() + const bodyRect = body.getBoundingClientRect() + return { + top: bodyRect.top - parentRect.top, + left: bodyRect.left - parentRect.left, + width: bodyRect.width, + height: bodyRect.height + } +} + +export function isOverlaySlotGeometryMismatched( + overlayRect: Pick, + bodyRect: Pick, + tolerancePx = OVERLAY_SLOT_GEOMETRY_MISMATCH_PX +): boolean { + return ( + Math.abs(overlayRect.top - bodyRect.top) > tolerancePx || + Math.abs(overlayRect.left - bodyRect.left) > tolerancePx || + Math.abs(overlayRect.width - bodyRect.width) > tolerancePx || + Math.abs(overlayRect.height - bodyRect.height) > tolerancePx + ) +} + +export function shouldPreferMeasuredOverlayGeometry(args: { + overlay: HTMLElement | null + groupId: string | undefined + /** When true, stay on the measured path until the caller resets (e.g. new group). */ + forceMeasured: boolean +}): { preferMeasured: boolean; measured: OverlaySlotRect | null } { + if (!args.groupId || !args.overlay) { + return { preferMeasured: args.forceMeasured, measured: null } + } + const parent = args.overlay.parentElement + const body = findTabGroupBodyElement(args.groupId) + if (!parent || !body) { + return { preferMeasured: true, measured: null } + } + const measured = measureOverlaySlotRect(parent, body) + if (args.forceMeasured) { + return { preferMeasured: true, measured } + } + const bodyRect = body.getBoundingClientRect() + const overlayRect = args.overlay.getBoundingClientRect() + if (isOverlaySlotGeometryMismatched(overlayRect, bodyRect)) { + return { preferMeasured: true, measured } + } + return { preferMeasured: false, measured } +} diff --git a/src/renderer/src/components/tab-group/use-overlay-slot-geometry.ts b/src/renderer/src/components/tab-group/use-overlay-slot-geometry.ts new file mode 100644 index 00000000000..75738d4af4e --- /dev/null +++ b/src/renderer/src/components/tab-group/use-overlay-slot-geometry.ts @@ -0,0 +1,110 @@ +import { useLayoutEffect, useState, type RefObject } from 'react' +import { + findTabGroupBodyElement, + measureOverlaySlotRect, + shouldPreferMeasuredOverlayGeometry, + type OverlaySlotRect +} from './overlay-slot-geometry' + +const FALLBACK_RECT_MIN_CHANGE_PX = 1 + +function stabilizeMeasuredRect( + prev: OverlaySlotRect | null, + next: OverlaySlotRect +): OverlaySlotRect { + if ( + prev && + Math.abs(prev.top - next.top) < FALLBACK_RECT_MIN_CHANGE_PX && + Math.abs(prev.left - next.left) < FALLBACK_RECT_MIN_CHANGE_PX && + Math.abs(prev.width - next.width) < FALLBACK_RECT_MIN_CHANGE_PX && + Math.abs(prev.height - next.height) < FALLBACK_RECT_MIN_CHANGE_PX + ) { + return prev + } + return next +} + +/** + * Tracks tab-group body geometry for a worktree-level overlay slot. + * When CSS anchors desync after a column snap, forceMeasured becomes true and + * the caller should pin the overlay with measured top/left/width/height. + */ +export function useOverlaySlotGeometry(args: { + overlayRef: RefObject + groupId: string | undefined + /** When false (e.g. web client), always use measured geometry. */ + cssAnchorsSupported: boolean + isVisible: boolean +}): { + measuredRect: OverlaySlotRect | null + forceMeasured: boolean + useCssAnchors: boolean +} { + const [measuredRect, setMeasuredRect] = useState(null) + const [forceMeasured, setForceMeasured] = useState(false) + + useLayoutEffect(() => { + setForceMeasured(false) + }, [args.groupId]) + + useLayoutEffect(() => { + if (!args.groupId) { + return + } + + const update = (): void => { + const overlay = args.overlayRef.current + const parent = overlay?.parentElement + const body = findTabGroupBodyElement(args.groupId!) + if (!parent || !body) { + setMeasuredRect(null) + return + } + const next = measureOverlaySlotRect(parent, body) + setMeasuredRect((prev) => stabilizeMeasuredRect(prev, next)) + + if (!args.cssAnchorsSupported || forceMeasured) { + return + } + const decision = shouldPreferMeasuredOverlayGeometry({ + overlay: overlay ?? null, + groupId: args.groupId, + forceMeasured: false + }) + if (decision.preferMeasured) { + setForceMeasured(true) + if (decision.measured) { + setMeasuredRect((prev) => stabilizeMeasuredRect(prev, decision.measured!)) + } + } + } + + update() + const body = findTabGroupBodyElement(args.groupId) + const parent = args.overlayRef.current?.parentElement + const resizeObserver = new ResizeObserver(update) + if (body) { + resizeObserver.observe(body) + } + if (parent) { + resizeObserver.observe(parent) + } + window.addEventListener('resize', update) + return () => { + resizeObserver.disconnect() + window.removeEventListener('resize', update) + } + }, [ + args.cssAnchorsSupported, + args.groupId, + args.isVisible, + args.overlayRef, + forceMeasured + ]) + + return { + measuredRect, + forceMeasured, + useCssAnchors: args.cssAnchorsSupported && !forceMeasured + } +} diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.ts b/src/renderer/src/components/tab-group/useTabDragSplit.ts index ce79cf03281..2ce10e11b7e 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.ts @@ -406,6 +406,12 @@ export function useTabDragSplit({ targetGroupId: paneColumnSplit.groupId, splitDirection: paneColumnSplit.zone }) + // Why: column snap reflows CSS-anchor overlays; a post-drop resize + // tick re-runs geometry verification so a desynced hit-test box + // cannot keep covering tab chrome after the snap settles. + requestAnimationFrame(() => { + window.dispatchEvent(new Event('resize')) + }) } finishDrag( shouldRestorePreDragActivation, diff --git a/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.geometry.test.tsx b/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.geometry.test.tsx new file mode 100644 index 00000000000..15fb184f552 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.geometry.test.tsx @@ -0,0 +1,145 @@ +/** @vitest-environment happy-dom */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +vi.mock('./TerminalPane', () => ({ + default: () => null +})) + +vi.mock('../../store', () => ({ + useAppStore: Object.assign(() => undefined, { + getState: () => ({ pendingStartupByTabId: {} }) + }) +})) + +import { TerminalOverlaySlot } from './TerminalOverlaySlot' + +const GROUP_ID = 'group-snap-geometry' +const TAB_ID = 'tab-snap-geometry' + +function createRect({ + top = 0, + left = 0, + width = 800, + height = 600 +}: Partial> = {}): DOMRect { + return { + top, + left, + right: left + width, + bottom: top + height, + width, + height, + x: left, + y: top, + toJSON: () => ({}) + } +} + +let container: HTMLDivElement +let bodyEl: HTMLDivElement +let bodyRect: DOMRect +let overlayRect: DOMRect +let root: Root +let resizeCallback: (() => void) | null + +class CapturingResizeObserver { + constructor(cb: () => void) { + resizeCallback = cb + } + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} + +function renderSlot(): HTMLElement { + root = createRoot(container) + act(() => { + root.render( + false} + leaveWorktreeIfEmpty={vi.fn()} + /> + ) + }) + const overlay = container.querySelector('[data-terminal-overlay-tab-id]') + if (!overlay) { + throw new Error('overlay not mounted') + } + return overlay +} + +beforeEach(() => { + resizeCallback = null + // Why: exercise the Electron CSS-anchor path so we can prove post-snap + // desync recovery switches to measured geometry (web client always measures). + delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ + const supports = (property: string, value: string): boolean => { + if (property === 'position-anchor') { + return true + } + if (property === 'top' && value.startsWith('anchor(')) { + return true + } + if (property === 'width' && value.startsWith('anchor-size(')) { + return true + } + return false + } + vi.stubGlobal('CSS', { supports }) + vi.stubGlobal('ResizeObserver', CapturingResizeObserver) + + container = document.createElement('div') + container.getBoundingClientRect = () => createRect({ width: 1000, height: 800 }) + document.body.appendChild(container) + + bodyEl = document.createElement('div') + bodyEl.setAttribute('data-tab-group-body-id', GROUP_ID) + bodyRect = createRect({ top: 36, left: 500, width: 500, height: 764 }) + bodyEl.getBoundingClientRect = () => bodyRect + document.body.appendChild(bodyEl) + + // Why: simulate Chromium leaving the overlay on the pre-snap full-area box + // while the body is only the right half after a side-by-side snap. + overlayRect = createRect({ top: 0, left: 0, width: 1000, height: 800 }) +}) + +afterEach(() => { + act(() => { + root?.unmount() + }) + container?.remove() + bodyEl?.remove() + vi.unstubAllGlobals() +}) + +describe('TerminalOverlaySlot geometry after pane-column snap', () => { + it('falls back to measured geometry when CSS-anchor hit-test desyncs from the body', () => { + const overlay = renderSlot() + overlay.getBoundingClientRect = () => overlayRect + + act(() => { + resizeCallback?.() + }) + + expect(overlay.dataset.overlayGeometry).toBe('measured') + expect(overlay.style.top).toBe('36px') + expect(overlay.style.left).toBe('500px') + expect(overlay.style.width).toBe('500px') + expect(overlay.style.height).toBe('764px') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx b/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx index 20e5f99064a..aa04389c8aa 100644 --- a/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx @@ -3,6 +3,7 @@ import { createPortal } from 'react-dom' import { useAppStore } from '../../store' import { SYNC_FIT_PANES_EVENT } from '@/constants/terminal' import { tabGroupBodyAnchorName } from '../tab-group/tab-group-body-anchor' +import { useOverlaySlotGeometry } from '../tab-group/use-overlay-slot-geometry' import type { ActivityTerminalPortalTarget } from '../activity/activity-terminal-portal' import TerminalPane from './TerminalPane' import { closeTerminalTab } from '../terminal/terminal-tab-actions' @@ -15,7 +16,6 @@ const HAS_CSS_ANCHOR_POSITIONING = CSS.supports('width', 'anchor-size(--orca-terminal-overlay-probe width)') const MIN_OVERLAY_FIT_WIDTH_PX = 48 const MIN_OVERLAY_FIT_HEIGHT_PX = 24 -const FALLBACK_RECT_MIN_CHANGE_PX = 1 function shouldUseCssAnchorPositioning(): boolean { return ( @@ -24,13 +24,6 @@ function shouldUseCssAnchorPositioning(): boolean { ) } -type MeasuredFallbackRect = { - top: number - left: number - width: number - height: number -} - type TerminalOverlaySlotProps = { terminalTabId: string terminalGeneration: number | undefined @@ -64,75 +57,21 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ }: TerminalOverlaySlotProps): React.JSX.Element { const anchorName = groupId !== undefined ? tabGroupBodyAnchorName(groupId) : undefined const overlayRef = useRef(null) - const [measuredFallbackRect, setMeasuredFallbackRect] = useState( - null - ) const [shouldMeasureHiddenStartup, setShouldMeasureHiddenStartup] = useState( () => useAppStore.getState().pendingStartupByTabId[terminalTabId] !== undefined ) + const { measuredRect, forceMeasured, useCssAnchors } = useOverlaySlotGeometry({ + overlayRef, + groupId, + cssAnchorsSupported: shouldUseCssAnchorPositioning(), + isVisible + }) + useLayoutEffect(() => { if (isVisible && shouldMeasureHiddenStartup) { setShouldMeasureHiddenStartup(false) } }, [isVisible, shouldMeasureHiddenStartup]) - useLayoutEffect(() => { - if (!anchorName || shouldUseCssAnchorPositioning() || !groupId) { - return - } - - const findBody = (): HTMLElement | null => { - for (const candidate of document.querySelectorAll('[data-tab-group-body-id]')) { - if (candidate.dataset.tabGroupBodyId === groupId) { - return candidate - } - } - return null - } - - const updateRect = (): void => { - const overlay = overlayRef.current - const parent = overlay?.parentElement - const body = findBody() - if (!parent || !body) { - setMeasuredFallbackRect(null) - return - } - const parentRect = parent.getBoundingClientRect() - const bodyRect = body.getBoundingClientRect() - const next: MeasuredFallbackRect = { - top: bodyRect.top - parentRect.top, - left: bodyRect.left - parentRect.left, - width: bodyRect.width, - height: bodyRect.height - } - // Why: ResizeObserver and xterm fit can otherwise amplify sub-pixel jitter forever. - setMeasuredFallbackRect((prev) => - prev && - Math.abs(prev.top - next.top) < FALLBACK_RECT_MIN_CHANGE_PX && - Math.abs(prev.left - next.left) < FALLBACK_RECT_MIN_CHANGE_PX && - Math.abs(prev.width - next.width) < FALLBACK_RECT_MIN_CHANGE_PX && - Math.abs(prev.height - next.height) < FALLBACK_RECT_MIN_CHANGE_PX - ? prev - : next - ) - } - - updateRect() - const body = findBody() - const parent = overlayRef.current?.parentElement - const resizeObserver = new ResizeObserver(updateRect) - if (body) { - resizeObserver.observe(body) - } - if (parent) { - resizeObserver.observe(parent) - } - window.addEventListener('resize', updateRect) - return () => { - resizeObserver.disconnect() - window.removeEventListener('resize', updateRect) - } - }, [anchorName, groupId, isVisible]) useLayoutEffect(() => { if (!isVisible || !anchorName) { @@ -167,11 +106,11 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ window.clearTimeout(retryId) window.clearTimeout(settledRetryId) } - }, [anchorName, isVisible, measuredFallbackRect]) + }, [anchorName, isVisible, measuredRect, forceMeasured]) const style: React.CSSProperties = useMemo( () => - anchorName && shouldUseCssAnchorPositioning() + anchorName && useCssAnchors ? { position: 'absolute', positionAnchor: anchorName, @@ -185,14 +124,14 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ } : anchorName ? { - // Why: Chrome builds without CSS anchor positioning otherwise - // mount the terminal into a 0x0 overlay. Measure the tab-group - // body so the fallback does not cover the tab strip. + // Why: without trusted CSS anchors (unsupported, web client, or + // post-snap desync), measure the tab-group body so the overlay + // never covers the tab strip or neighboring columns. position: 'absolute', - top: measuredFallbackRect?.top ?? 32, - left: measuredFallbackRect?.left ?? 0, - width: measuredFallbackRect?.width ?? '100%', - height: measuredFallbackRect?.height ?? 'calc(100% - 32px)', + top: measuredRect?.top ?? 32, + left: measuredRect?.left ?? 0, + width: measuredRect?.width ?? '100%', + height: measuredRect?.height ?? 'calc(100% - 32px)', display: isVisible || shouldMeasureHiddenStartup ? 'flex' : 'none', opacity: isVisible ? 1 : 0, pointerEvents: isVisible ? 'auto' : 'none' @@ -206,7 +145,7 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ display: 'none', pointerEvents: 'none' }, - [anchorName, isVisible, measuredFallbackRect, shouldMeasureHiddenStartup] + [anchorName, isVisible, measuredRect, shouldMeasureHiddenStartup, useCssAnchors] ) const focusGroup = useCallback(() => { if (groupId !== undefined && onFocusOwningGroup) { @@ -265,6 +204,7 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ ref={overlayRef} style={style} data-terminal-overlay-tab-id={terminalTabId} + data-overlay-geometry={useCssAnchors ? 'anchor' : 'measured'} onPointerDown={focusGroup} onFocusCapture={focusGroup} >