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
This commit is contained in:
OrcaWin
2026-08-12 02:11:46 -07:00
parent 346e59c879
commit bbe654a5c7
8 changed files with 571 additions and 97 deletions
@@ -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 <webview> 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<HTMLDivElement | null>(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 (
<div
ref={overlayRef}
style={style}
className="relative flex min-h-0 flex-1 flex-col"
data-browser-overlay-tab-id={browserTab.id}
data-overlay-geometry={useCssAnchors ? 'anchor' : 'measured'}
onPointerDown={handleFocus}
onFocusCapture={handleFocus}
>
@@ -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<HTMLDivElement | null>(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 (
<div
ref={overlayRef}
style={style}
className="orca-emulator-overlay-slot min-h-0 min-w-0 overflow-hidden"
data-overlay-geometry={useCssAnchors ? 'anchor' : 'measured'}
onPointerDownCapture={() => {
if (groupId && onFocusOwningGroup) {
onFocusOwningGroup(groupId)
@@ -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<DOMRect> & Pick<DOMRect, 'top' | 'left' | 'width' | 'height'>): 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()
})
})
@@ -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<HTMLElement>('[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<DOMRect, 'top' | 'left' | 'width' | 'height'>,
bodyRect: Pick<DOMRect, 'top' | 'left' | 'width' | 'height'>,
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 }
}
@@ -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<HTMLElement | null>
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<OverlaySlotRect | null>(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
}
}
@@ -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,
@@ -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<Pick<DOMRect, 'top' | 'left' | 'width' | 'height'>> = {}): 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(
<TerminalOverlaySlot
terminalTabId={TAB_ID}
terminalGeneration={0}
worktreeId="wt-1"
worktreePath="wt-1"
startupCwd={undefined}
groupId={GROUP_ID}
isWorktreeActive
isVisible
isActive
activityTerminalPortal={null}
onFocusOwningGroup={vi.fn()}
consumeSuppressedPtyExit={() => false}
leaveWorktreeIfEmpty={vi.fn()}
/>
)
})
const overlay = container.querySelector<HTMLElement>('[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')
})
})
@@ -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<HTMLDivElement | null>(null)
const [measuredFallbackRect, setMeasuredFallbackRect] = useState<MeasuredFallbackRect | null>(
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<HTMLElement>('[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}
>