diff --git a/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx b/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx index 45b28b7a25c..bba75282126 100644 --- a/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPaneOverlayLayer.tsx @@ -74,6 +74,7 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({ const { measuredRect, useCssAnchors } = useOverlaySlotGeometry({ overlayRef, groupId, + worktreeId: browserTab.worktreeId, cssAnchorsSupported: shouldUseCssAnchorPositioning(), isVisible: isPaintable }) diff --git a/src/renderer/src/components/emulator-pane/EmulatorPaneOverlayLayer.tsx b/src/renderer/src/components/emulator-pane/EmulatorPaneOverlayLayer.tsx index 11b66c6e534..2377afcd5a3 100644 --- a/src/renderer/src/components/emulator-pane/EmulatorPaneOverlayLayer.tsx +++ b/src/renderer/src/components/emulator-pane/EmulatorPaneOverlayLayer.tsx @@ -40,6 +40,7 @@ const SimulatorOverlaySlot = memo(function SimulatorOverlaySlot({ const { measuredRect, useCssAnchors } = useOverlaySlotGeometry({ overlayRef, groupId, + worktreeId: tab.worktreeId, cssAnchorsSupported: shouldUseCssAnchorPositioning(), isVisible: isActive }) 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 index 22eef139a9d..25e4fade081 100644 --- a/src/renderer/src/components/tab-group/overlay-slot-geometry.test.ts +++ b/src/renderer/src/components/tab-group/overlay-slot-geometry.test.ts @@ -3,12 +3,15 @@ import { describe, expect, it } from 'vitest' import { findTabGroupBodyElement, + isMeasurableOverlayRect, isOverlaySlotGeometryMismatched, measureOverlaySlotRect, shouldPreferMeasuredOverlayGeometry } from './overlay-slot-geometry' -function rect(partial: Partial & Pick): DOMRect { +function rect( + partial: Partial & Pick +): DOMRect { const top = partial.top const left = partial.left const width = partial.width @@ -27,16 +30,23 @@ function rect(partial: Partial & Pick { - it('finds the tab-group body by group id', () => { + it('finds the tab-group body scoped by worktree when both attributes are set', () => { + const other = document.createElement('div') + other.dataset.tabGroupBodyId = 'group-a' + other.dataset.worktreeId = 'wt-other' const body = document.createElement('div') body.dataset.tabGroupBodyId = 'group-a' - document.body.appendChild(body) - expect(findTabGroupBodyElement('group-a')).toBe(body) + body.dataset.worktreeId = 'wt-1' + document.body.append(other, body) + expect(findTabGroupBodyElement('group-a', 'wt-1')).toBe(body) + expect(findTabGroupBodyElement('group-a', 'wt-missing')).toBeNull() + expect(findTabGroupBodyElement('group-a')).toBe(other) expect(findTabGroupBodyElement('missing')).toBeNull() + other.remove() body.remove() }) - it('measures body geometry relative to the overlay parent', () => { + it('measures body geometry relative to the overlay parent (absolute containing block)', () => { const parent = document.createElement('div') const body = document.createElement('div') parent.getBoundingClientRect = () => rect({ top: 100, left: 40, width: 900, height: 700 }) @@ -69,9 +79,9 @@ describe('overlay slot geometry', () => { const overlay = document.createElement('div') const body = document.createElement('div') body.dataset.tabGroupBodyId = 'group-snap' + body.dataset.worktreeId = 'wt-1' parent.appendChild(overlay) - document.body.appendChild(parent) - document.body.appendChild(body) + document.body.append(parent, 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. @@ -82,9 +92,12 @@ describe('overlay slot geometry', () => { const result = shouldPreferMeasuredOverlayGeometry({ overlay, groupId: 'group-snap', - forceMeasured: false + worktreeId: 'wt-1', + forceMeasured: false, + mayLatchDesync: true }) expect(result.preferMeasured).toBe(true) + expect(result.bodyMeasurable).toBe(true) expect(result.measured).toEqual({ top: 36, left: 500, @@ -102,8 +115,7 @@ describe('overlay slot geometry', () => { const body = document.createElement('div') body.dataset.tabGroupBodyId = 'group-ok' parent.appendChild(overlay) - document.body.appendChild(parent) - document.body.appendChild(body) + document.body.append(parent, body) const matched = rect({ top: 36, left: 0, width: 900, height: 700 }) parent.getBoundingClientRect = () => rect({ top: 0, left: 0, width: 900, height: 736 }) @@ -113,7 +125,8 @@ describe('overlay slot geometry', () => { const result = shouldPreferMeasuredOverlayGeometry({ overlay, groupId: 'group-ok', - forceMeasured: false + forceMeasured: false, + mayLatchDesync: true }) expect(result.preferMeasured).toBe(false) expect(result.measured).toEqual({ @@ -126,4 +139,90 @@ describe('overlay slot geometry', () => { parent.remove() body.remove() }) + + it('does not latch desync from a display:none / zero-size overlay sample', () => { + const parent = document.createElement('div') + const overlay = document.createElement('div') + const body = document.createElement('div') + body.dataset.tabGroupBodyId = 'group-hidden' + parent.appendChild(overlay) + document.body.append(parent, body) + + parent.getBoundingClientRect = () => rect({ top: 0, left: 0, width: 1000, height: 800 }) + overlay.getBoundingClientRect = () => rect({ top: 0, left: 0, width: 0, height: 0 }) + body.getBoundingClientRect = () => rect({ top: 36, left: 0, width: 1000, height: 764 }) + + expect( + shouldPreferMeasuredOverlayGeometry({ + overlay, + groupId: 'group-hidden', + forceMeasured: false, + mayLatchDesync: true + }).preferMeasured + ).toBe(false) + + expect( + shouldPreferMeasuredOverlayGeometry({ + overlay, + groupId: 'group-hidden', + forceMeasured: false, + mayLatchDesync: false + }).preferMeasured + ).toBe(false) + + parent.remove() + body.remove() + }) + + it('does not latch desync when the body is not yet measurable', () => { + const parent = document.createElement('div') + const overlay = document.createElement('div') + const body = document.createElement('div') + body.dataset.tabGroupBodyId = 'group-unlaid' + parent.appendChild(overlay) + document.body.append(parent, body) + + 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: 0, left: 0, width: 0, height: 0 }) + + const result = shouldPreferMeasuredOverlayGeometry({ + overlay, + groupId: 'group-unlaid', + forceMeasured: false, + mayLatchDesync: true + }) + expect(result.bodyMeasurable).toBe(false) + expect(result.preferMeasured).toBe(false) + expect(isMeasurableOverlayRect({ width: 0, height: 0 })).toBe(false) + + parent.remove() + body.remove() + }) + + it('honors forceMeasured even when geometry currently matches', () => { + const parent = document.createElement('div') + const overlay = document.createElement('div') + const body = document.createElement('div') + body.dataset.tabGroupBodyId = 'group-forced' + parent.appendChild(overlay) + document.body.append(parent, 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 + + expect( + shouldPreferMeasuredOverlayGeometry({ + overlay, + groupId: 'group-forced', + forceMeasured: true, + mayLatchDesync: true + }).preferMeasured + ).toBe(true) + + 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 index 88990699547..e223eb3cabc 100644 --- a/src/renderer/src/components/tab-group/overlay-slot-geometry.ts +++ b/src/renderer/src/components/tab-group/overlay-slot-geometry.ts @@ -12,14 +12,30 @@ export type OverlaySlotRect = { } export const OVERLAY_SLOT_GEOMETRY_MISMATCH_PX = 2 +/** Body/overlay smaller than this is treated as not-yet-laid-out, not a desync. */ +export const OVERLAY_SLOT_MIN_MEASURABLE_EDGE_PX = 8 -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 - } +function escapeCssAttrValue(value: string): string { + if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') { + return CSS.escape(value) } - return null + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') +} + +/** Locate the tab-group body; prefer worktree-scoped match when available. */ +export function findTabGroupBodyElement( + groupId: string, + worktreeId?: string +): HTMLElement | null { + const escapedGroupId = escapeCssAttrValue(groupId) + if (worktreeId) { + const escapedWorktreeId = escapeCssAttrValue(worktreeId) + // Why: never fall through to another worktree's body — hidden worktrees stay mounted. + return document.querySelector( + `[data-tab-group-body-id="${escapedGroupId}"][data-worktree-id="${escapedWorktreeId}"]` + ) + } + return document.querySelector(`[data-tab-group-body-id="${escapedGroupId}"]`) } export function measureOverlaySlotRect( @@ -36,6 +52,13 @@ export function measureOverlaySlotRect( } } +export function isMeasurableOverlayRect( + rect: Pick, + minEdgePx = OVERLAY_SLOT_MIN_MEASURABLE_EDGE_PX +): boolean { + return rect.width >= minEdgePx && rect.height >= minEdgePx +} + export function isOverlaySlotGeometryMismatched( overlayRect: Pick, bodyRect: Pick, @@ -49,28 +72,62 @@ export function isOverlaySlotGeometryMismatched( ) } +export type OverlayGeometryDecision = { + preferMeasured: boolean + measured: OverlaySlotRect | null + /** True when body exists and is large enough to trust for measured layout. */ + bodyMeasurable: boolean +} + +/** + * Decide whether CSS-anchor layout is safe or measured body geometry is required. + * Viewport getBoundingClientRect comparison detects desync; measured offsets are + * parent-relative for `position:absolute` top/left/width/height. + */ export function shouldPreferMeasuredOverlayGeometry(args: { overlay: HTMLElement | null groupId: string | undefined + worktreeId?: string /** When true, stay on the measured path until the caller resets (e.g. new group). */ forceMeasured: boolean -}): { preferMeasured: boolean; measured: OverlaySlotRect | null } { + /** + * When false (hidden/unpainted slot), never latch onto measured from a desync + * sample — zero-size `display:none` overlays would otherwise false-positive. + */ + mayLatchDesync: boolean +}): OverlayGeometryDecision { if (!args.groupId || !args.overlay) { - return { preferMeasured: args.forceMeasured, measured: null } + return { + preferMeasured: args.forceMeasured, + measured: null, + bodyMeasurable: false + } } const parent = args.overlay.parentElement - const body = findTabGroupBodyElement(args.groupId) + const body = findTabGroupBodyElement(args.groupId, args.worktreeId) if (!parent || !body) { - return { preferMeasured: true, measured: null } + return { + preferMeasured: args.forceMeasured, + measured: null, + bodyMeasurable: false + } } const measured = measureOverlaySlotRect(parent, body) + const bodyMeasurable = isMeasurableOverlayRect(measured) if (args.forceMeasured) { - return { preferMeasured: true, measured } + return { preferMeasured: true, measured, bodyMeasurable } + } + if (!args.mayLatchDesync || !bodyMeasurable) { + return { preferMeasured: false, measured, bodyMeasurable } } const bodyRect = body.getBoundingClientRect() const overlayRect = args.overlay.getBoundingClientRect() - if (isOverlaySlotGeometryMismatched(overlayRect, bodyRect)) { - return { preferMeasured: true, measured } + // Why: skip until the overlay itself has a real box — pre-paint zeros are not desync. + if (!isMeasurableOverlayRect(overlayRect)) { + return { preferMeasured: false, measured, bodyMeasurable } } - return { preferMeasured: false, measured } + if (isOverlaySlotGeometryMismatched(overlayRect, bodyRect)) { + return { preferMeasured: true, measured, bodyMeasurable } + } + return { preferMeasured: false, measured, bodyMeasurable } } 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 index 75738d4af4e..282905a6ade 100644 --- a/src/renderer/src/components/tab-group/use-overlay-slot-geometry.ts +++ b/src/renderer/src/components/tab-group/use-overlay-slot-geometry.ts @@ -1,4 +1,4 @@ -import { useLayoutEffect, useState, type RefObject } from 'react' +import { useLayoutEffect, useRef, useState, type RefObject } from 'react' import { findTabGroupBodyElement, measureOverlaySlotRect, @@ -32,6 +32,7 @@ function stabilizeMeasuredRect( export function useOverlaySlotGeometry(args: { overlayRef: RefObject groupId: string | undefined + worktreeId?: string /** When false (e.g. web client), always use measured geometry. */ cssAnchorsSupported: boolean isVisible: boolean @@ -42,45 +43,55 @@ export function useOverlaySlotGeometry(args: { } { const [measuredRect, setMeasuredRect] = useState(null) const [forceMeasured, setForceMeasured] = useState(false) + const forceMeasuredRef = useRef(false) + forceMeasuredRef.current = forceMeasured - useLayoutEffect(() => { - setForceMeasured(false) - }, [args.groupId]) - + // Why: single layout effect owns group transitions + RO so forceMeasured + // reset and re-observe cannot race across two effects in the same commit. useLayoutEffect(() => { if (!args.groupId) { return } + forceMeasuredRef.current = false + setForceMeasured(false) + const update = (): void => { const overlay = args.overlayRef.current const parent = overlay?.parentElement - const body = findTabGroupBodyElement(args.groupId!) + const body = findTabGroupBodyElement(args.groupId!, args.worktreeId) if (!parent || !body) { - setMeasuredRect(null) + // Why: mid-reparent body absence is transient; keep last good measured + // box so we do not expand to width 100% over tab chrome. return } const next = measureOverlaySlotRect(parent, body) setMeasuredRect((prev) => stabilizeMeasuredRect(prev, next)) - if (!args.cssAnchorsSupported || forceMeasured) { + if (!args.cssAnchorsSupported) { return } const decision = shouldPreferMeasuredOverlayGeometry({ overlay: overlay ?? null, groupId: args.groupId, - forceMeasured: false + worktreeId: args.worktreeId, + forceMeasured: forceMeasuredRef.current, + mayLatchDesync: args.isVisible }) - if (decision.preferMeasured) { + if (decision.preferMeasured && !forceMeasuredRef.current) { + forceMeasuredRef.current = true setForceMeasured(true) - if (decision.measured) { - setMeasuredRect((prev) => stabilizeMeasuredRect(prev, decision.measured!)) - } + } + if (decision.measured) { + setMeasuredRect((prev) => stabilizeMeasuredRect(prev, decision.measured!)) } } update() - const body = findTabGroupBodyElement(args.groupId) + // Why: rAF catches the frame after a pane-column snap reflow when RO may + // not fire (body size unchanged but anchor resolution changed). + const rafId = requestAnimationFrame(update) + const body = findTabGroupBodyElement(args.groupId, args.worktreeId) const parent = args.overlayRef.current?.parentElement const resizeObserver = new ResizeObserver(update) if (body) { @@ -91,6 +102,7 @@ export function useOverlaySlotGeometry(args: { } window.addEventListener('resize', update) return () => { + cancelAnimationFrame(rafId) resizeObserver.disconnect() window.removeEventListener('resize', update) } @@ -99,7 +111,7 @@ export function useOverlaySlotGeometry(args: { args.groupId, args.isVisible, args.overlayRef, - forceMeasured + args.worktreeId ]) return { diff --git a/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.geometry.test.tsx b/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.geometry.test.tsx index 15fb184f552..584eead133e 100644 --- a/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.geometry.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.geometry.test.tsx @@ -109,6 +109,7 @@ beforeEach(() => { bodyEl = document.createElement('div') bodyEl.setAttribute('data-tab-group-body-id', GROUP_ID) + bodyEl.setAttribute('data-worktree-id', 'wt-1') bodyRect = createRect({ top: 36, left: 500, width: 500, height: 764 }) bodyEl.getBoundingClientRect = () => bodyRect document.body.appendChild(bodyEl) @@ -142,4 +143,41 @@ describe('TerminalOverlaySlot geometry after pane-column snap', () => { expect(overlay.style.width).toBe('500px') expect(overlay.style.height).toBe('764px') }) + + it('does not permanently latch measured geometry while the slot is hidden', () => { + root = createRoot(container) + act(() => { + root.render( + false} + leaveWorktreeIfEmpty={vi.fn()} + /> + ) + }) + const overlay = container.querySelector('[data-terminal-overlay-tab-id]') + expect(overlay).not.toBeNull() + if (!overlay) { + throw new Error('overlay not mounted') + } + overlay.getBoundingClientRect = () => createRect({ width: 0, height: 0 }) + + act(() => { + resizeCallback?.() + }) + + // Why: hidden slots stay on the CSS-anchor path so a later reveal can still + // detect a real post-snap desync instead of inheriting a false latch. + expect(overlay.dataset.overlayGeometry).toBe('anchor') + }) }) diff --git a/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx b/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx index aa04389c8aa..c05c5525fdf 100644 --- a/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx @@ -63,6 +63,7 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ const { measuredRect, forceMeasured, useCssAnchors } = useOverlaySlotGeometry({ overlayRef, groupId, + worktreeId, cssAnchorsSupported: shouldUseCssAnchorPositioning(), isVisible }) diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.react185.test.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.react185.test.tsx index b3ed18faa4f..1b401f7332f 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.react185.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.react185.test.tsx @@ -95,6 +95,7 @@ beforeEach(() => { bodyEl = document.createElement('div') bodyEl.setAttribute('data-tab-group-body-id', GROUP_ID) + bodyEl.setAttribute('data-worktree-id', 'wt-1') bodyRect = createRect({ top: 32, height: 568 }) bodyEl.getBoundingClientRect = () => bodyRect document.body.appendChild(bodyEl)