mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
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:
@@ -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}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user