mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
fix(renderer): align native chat pane layering
This commit is contained in:
@@ -400,11 +400,6 @@
|
||||
z-index: 40 !important;
|
||||
}
|
||||
|
||||
/* Above the z-40 updater/onboarding chrome, below the floating workspace panel's z-45. */
|
||||
.native-chat-pane-shell:has([data-native-chat-working='true']) {
|
||||
z-index: 44;
|
||||
}
|
||||
|
||||
[data-sonner-toaster] [data-sonner-toast][data-styled='true'] {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
|
||||
+4
-2
@@ -148,7 +148,7 @@ describe('StructuredAgentSessionPaneOverlayLayer', () => {
|
||||
expect(mocks.focusGroup).toHaveBeenCalledWith(WORKTREE_ID, GROUP_ID)
|
||||
})
|
||||
|
||||
it('keeps the base z-layer overridable by the working-chat stylesheet rule', () => {
|
||||
it('keeps a working session at the base pane layer', () => {
|
||||
const view = render(
|
||||
<StructuredAgentSessionPaneOverlayLayer worktreeId={WORKTREE_ID} isWorktreeActive />
|
||||
)
|
||||
@@ -157,7 +157,9 @@ describe('StructuredAgentSessionPaneOverlayLayer', () => {
|
||||
)
|
||||
|
||||
expect(slot).not.toBeNull()
|
||||
expect(slot?.classList.contains('native-chat-pane-shell')).toBe(true)
|
||||
expect(slot?.hasAttribute('data-retained-pane-host')).toBe(true)
|
||||
expect(slot?.classList.contains('isolate')).toBe(true)
|
||||
expect(slot?.classList.contains('overflow-hidden')).toBe(true)
|
||||
expect(slot?.classList.contains('z-10')).toBe(true)
|
||||
expect(slot?.style.zIndex).toBe('')
|
||||
expect(slot?.querySelector('[data-native-chat-working="true"]')).not.toBeNull()
|
||||
|
||||
@@ -5,7 +5,7 @@ import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-p
|
||||
import { useAppStore } from '@/store'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { getActiveRuntimeTarget, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { tabGroupBodyAnchorName } from '../tab-group/tab-group-body-anchor'
|
||||
import { RetainedPaneHost } from '../tab-group/RetainedPaneHost'
|
||||
import NativeChatView from './NativeChatView'
|
||||
|
||||
type StructuredAgentSessionTab = Tab & {
|
||||
@@ -29,37 +29,12 @@ const StructuredAgentSessionOverlaySlot = memo(function StructuredAgentSessionOv
|
||||
target: RuntimeClientTarget
|
||||
onFocusOwningGroup: ((groupId: string) => void) | undefined
|
||||
}): React.JSX.Element {
|
||||
const anchorName = groupId !== undefined ? tabGroupBodyAnchorName(groupId) : undefined
|
||||
const style = useMemo<React.CSSProperties>(
|
||||
() =>
|
||||
anchorName
|
||||
? {
|
||||
position: 'absolute',
|
||||
positionAnchor: anchorName,
|
||||
top: `anchor(${anchorName} top)`,
|
||||
left: `anchor(${anchorName} left)`,
|
||||
width: `anchor-size(${anchorName} width)`,
|
||||
height: `anchor-size(${anchorName} height)`,
|
||||
display: isActive ? 'flex' : 'none',
|
||||
pointerEvents: isActive ? 'auto' : 'none'
|
||||
}
|
||||
: { display: 'none' },
|
||||
[anchorName, isActive]
|
||||
)
|
||||
const focusOwningGroup = useCallback(() => {
|
||||
if (groupId !== undefined && onFocusOwningGroup) {
|
||||
onFocusOwningGroup(groupId)
|
||||
}
|
||||
}, [groupId, onFocusOwningGroup])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className="native-chat-pane-shell z-10 min-h-0 min-w-0"
|
||||
<RetainedPaneHost
|
||||
groupId={groupId}
|
||||
isVisible={isActive}
|
||||
data-structured-agent-session-overlay-tab-id={tab.id}
|
||||
aria-hidden={!isActive}
|
||||
onPointerDown={focusOwningGroup}
|
||||
onFocusCapture={focusOwningGroup}
|
||||
onFocusOwningGroup={onFocusOwningGroup}
|
||||
>
|
||||
<NativeChatView
|
||||
mode="structured"
|
||||
@@ -70,7 +45,7 @@ const StructuredAgentSessionOverlaySlot = memo(function StructuredAgentSessionOv
|
||||
isVisible={isActive}
|
||||
target={target}
|
||||
/>
|
||||
</div>
|
||||
</RetainedPaneHost>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -6,42 +6,17 @@ function source(path: string): string {
|
||||
return readFileSync(join(process.cwd(), path), 'utf8')
|
||||
}
|
||||
|
||||
function workingChatZIndex(css: string): number {
|
||||
const match =
|
||||
/\.native-chat-pane-shell:has\(\[data-native-chat-working='true'\]\)[^{]*\{[^}]*z-index:\s*(\d+);/s.exec(
|
||||
css
|
||||
)
|
||||
expect(match, 'working native-chat z-index rule not found in main.css').not.toBeNull()
|
||||
return Number(match?.[1])
|
||||
}
|
||||
|
||||
describe('native chat Stop layering', () => {
|
||||
it('keeps a working chat pane above bottom-right product chrome', () => {
|
||||
describe('native chat layering', () => {
|
||||
it('keeps working chat at the pane layer below app notifications and floating surfaces', () => {
|
||||
const css = source('src/renderer/src/assets/main.css')
|
||||
const terminalPane = source(
|
||||
'src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx'
|
||||
)
|
||||
|
||||
expect(terminalPane).toContain('native-chat-pane-shell absolute inset-0 z-10')
|
||||
for (const path of [
|
||||
'src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx',
|
||||
'src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx'
|
||||
]) {
|
||||
expect(source(path)).toContain('<RetainedPaneHost')
|
||||
}
|
||||
expect(css).not.toMatch(/\.native-chat-pane-shell:has\(\[data-native-chat-working/)
|
||||
expect(css).toMatch(/\[data-sonner-toaster\][^{]*\{[^}]*z-index:\s*40\s*!important;/s)
|
||||
expect(workingChatZIndex(css)).toBeGreaterThan(40)
|
||||
})
|
||||
|
||||
// Why both bounds: raising the working pane over the panel hides a summoned
|
||||
// floating workspace behind the chat column while an agent streams.
|
||||
it('stays under the floating workspace panel while working', () => {
|
||||
// Comments stripped first: the surrounding layering comment cites bare z-40/z-50
|
||||
// tiers, and a reworded one could otherwise be read as the panel's own class.
|
||||
const panel = source(
|
||||
'src/renderer/src/components/floating-terminal/FloatingTerminalPanelSurface.tsx'
|
||||
).replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '')
|
||||
const panelZIndex = Number(
|
||||
/data-floating-terminal-panel[\s\S]*?className=[\s\S]*?z-\[(\d+)\]/.exec(panel)?.[1]
|
||||
)
|
||||
|
||||
// FloatingTerminalPanel.bounds.test.tsx pins this same 45 through a real render.
|
||||
expect(panelZIndex).toBe(45)
|
||||
expect(workingChatZIndex(source('src/renderer/src/assets/main.css'))).toBeLessThan(panelZIndex)
|
||||
})
|
||||
|
||||
it('publishes working state from both structured and bridge chat roots', () => {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { RetainedPaneHost } from './RetainedPaneHost'
|
||||
|
||||
const disconnect = vi.fn()
|
||||
let notifyResize: () => void
|
||||
let anchors: HTMLDivElement[]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('__ORCA_WEB_CLIENT__', true)
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
constructor(callback: () => void) {
|
||||
notifyResize = callback
|
||||
}
|
||||
observe(): void {}
|
||||
disconnect = disconnect
|
||||
}
|
||||
)
|
||||
disconnect.mockClear()
|
||||
anchors = ['left', 'right'].map((id, index) => {
|
||||
const anchor = document.createElement('div')
|
||||
anchor.dataset.tabGroupBodyId = id
|
||||
anchor.getBoundingClientRect = () => new DOMRect(index * 400, 32, 400, 568)
|
||||
document.body.append(anchor)
|
||||
return anchor
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
anchors.forEach((anchor) => anchor.remove())
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('retains pane content across group moves and visibility changes using measured browser bounds', () => {
|
||||
const focus = vi.fn()
|
||||
const content = <input defaultValue="draft" />
|
||||
const view = render(
|
||||
<RetainedPaneHost groupId="left" isVisible onFocusOwningGroup={focus}>
|
||||
{content}
|
||||
</RetainedPaneHost>
|
||||
)
|
||||
const host = view.container.firstElementChild as HTMLDivElement
|
||||
const input = view.getByRole('textbox')
|
||||
expect(host.style.top).toBe('32px')
|
||||
expect(host.style.width).toBe('400px')
|
||||
fireEvent.change(input, { target: { value: 'unsent draft' } })
|
||||
|
||||
view.rerender(
|
||||
<RetainedPaneHost groupId="right" isVisible onFocusOwningGroup={focus}>
|
||||
{content}
|
||||
</RetainedPaneHost>
|
||||
)
|
||||
expect(host.style.left).toBe('400px')
|
||||
expect(view.getByRole('textbox')).toBe(input)
|
||||
expect((input as HTMLInputElement).value).toBe('unsent draft')
|
||||
fireEvent.pointerDown(input)
|
||||
expect(focus).toHaveBeenLastCalledWith('right')
|
||||
|
||||
anchors[1].getBoundingClientRect = () => new DOMRect(450, 32, 350, 500)
|
||||
act(() => notifyResize())
|
||||
expect(host.style.left).toBe('450px')
|
||||
expect(host.style.width).toBe('350px')
|
||||
|
||||
view.rerender(
|
||||
<RetainedPaneHost groupId="right" isVisible={false}>
|
||||
{content}
|
||||
</RetainedPaneHost>
|
||||
)
|
||||
expect(host.style.display).toBe('none')
|
||||
expect(host.hasAttribute('inert')).toBe(true)
|
||||
expect(host.contains(input)).toBe(true)
|
||||
view.rerender(
|
||||
<RetainedPaneHost groupId="right" isVisible>
|
||||
{content}
|
||||
</RetainedPaneHost>
|
||||
)
|
||||
expect(host.style.display).toBe('flex')
|
||||
expect(host.hasAttribute('inert')).toBe(false)
|
||||
expect(view.getByRole('textbox')).toBe(input)
|
||||
view.unmount()
|
||||
expect(disconnect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows hidden terminal startup measurement without exposing input or starting fit timers for chat', () => {
|
||||
const timeout = vi.spyOn(window, 'setTimeout')
|
||||
const view = render(
|
||||
<RetainedPaneHost groupId="left" isVisible={false} measureWhileHidden>
|
||||
<input />
|
||||
</RetainedPaneHost>
|
||||
)
|
||||
const host = view.container.firstElementChild as HTMLDivElement
|
||||
expect(host.style.display).toBe('flex')
|
||||
expect(host.style.opacity).toBe('0')
|
||||
expect(host.style.pointerEvents).toBe('none')
|
||||
expect(host.hasAttribute('inert')).toBe(true)
|
||||
view.rerender(
|
||||
<RetainedPaneHost groupId="left" isVisible>
|
||||
<input />
|
||||
</RetainedPaneHost>
|
||||
)
|
||||
expect(timeout).not.toHaveBeenCalled()
|
||||
timeout.mockRestore()
|
||||
})
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { SYNC_FIT_PANES_EVENT } from '@/constants/terminal'
|
||||
import { tabGroupBodyAnchorName } from './tab-group-body-anchor'
|
||||
|
||||
const HAS_CSS_ANCHOR_POSITIONING =
|
||||
typeof CSS !== 'undefined' &&
|
||||
CSS.supports('position-anchor', '--orca-terminal-overlay-probe') &&
|
||||
CSS.supports('top', 'anchor(--orca-terminal-overlay-probe top)') &&
|
||||
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 (
|
||||
HAS_CSS_ANCHOR_POSITIONING &&
|
||||
(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ !== true
|
||||
)
|
||||
}
|
||||
|
||||
type MeasuredFallbackRect = {
|
||||
top: number
|
||||
left: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
type RetainedPaneHostProps = {
|
||||
groupId: string | undefined
|
||||
isVisible: boolean
|
||||
measureWhileHidden?: boolean
|
||||
fitTerminal?: boolean
|
||||
onFocusOwningGroup?: (groupId: string) => void
|
||||
children: React.ReactNode
|
||||
'data-terminal-overlay-tab-id'?: string
|
||||
'data-structured-agent-session-overlay-tab-id'?: string
|
||||
}
|
||||
|
||||
export function RetainedPaneHost({
|
||||
groupId,
|
||||
isVisible,
|
||||
measureWhileHidden = false,
|
||||
fitTerminal = false,
|
||||
onFocusOwningGroup,
|
||||
children,
|
||||
...identity
|
||||
}: RetainedPaneHostProps): React.JSX.Element {
|
||||
const anchorName = groupId !== undefined ? tabGroupBodyAnchorName(groupId) : undefined
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null)
|
||||
const [measuredFallbackRect, setMeasuredFallbackRect] = useState<MeasuredFallbackRect | null>(
|
||||
null
|
||||
)
|
||||
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 (!fitTerminal || !isVisible || !anchorName) {
|
||||
return
|
||||
}
|
||||
const dispatchFitIfMeasurable = (): void => {
|
||||
const rect = overlayRef.current?.getBoundingClientRect()
|
||||
if (
|
||||
!rect ||
|
||||
rect.width < MIN_OVERLAY_FIT_WIDTH_PX ||
|
||||
rect.height < MIN_OVERLAY_FIT_HEIGHT_PX
|
||||
) {
|
||||
return
|
||||
}
|
||||
window.dispatchEvent(new Event(SYNC_FIT_PANES_EVENT))
|
||||
}
|
||||
|
||||
// Why: tab switches can resume visibility before anchor/fallback geometry
|
||||
// settles. Re-fit only after the overlay has real dimensions so the PTY
|
||||
// never stays pinned at a stale ~2-col width.
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
dispatchFitIfMeasurable()
|
||||
})
|
||||
const retryId = window.setTimeout(() => {
|
||||
dispatchFitIfMeasurable()
|
||||
}, 50)
|
||||
const settledRetryId = window.setTimeout(() => {
|
||||
dispatchFitIfMeasurable()
|
||||
}, 150)
|
||||
return () => {
|
||||
cancelAnimationFrame(frameId)
|
||||
window.clearTimeout(retryId)
|
||||
window.clearTimeout(settledRetryId)
|
||||
}
|
||||
}, [anchorName, fitTerminal, isVisible, measuredFallbackRect])
|
||||
|
||||
const style: React.CSSProperties = useMemo(
|
||||
() =>
|
||||
anchorName && shouldUseCssAnchorPositioning()
|
||||
? {
|
||||
position: 'absolute',
|
||||
positionAnchor: anchorName,
|
||||
top: `anchor(${anchorName} top)`,
|
||||
left: `anchor(${anchorName} left)`,
|
||||
width: `anchor-size(${anchorName} width)`,
|
||||
height: `anchor-size(${anchorName} height)`,
|
||||
display: isVisible || measureWhileHidden ? 'flex' : 'none',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
pointerEvents: isVisible ? 'auto' : 'none'
|
||||
}
|
||||
: 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.
|
||||
position: 'absolute',
|
||||
top: measuredFallbackRect?.top ?? 32,
|
||||
left: measuredFallbackRect?.left ?? 0,
|
||||
width: measuredFallbackRect?.width ?? '100%',
|
||||
height: measuredFallbackRect?.height ?? 'calc(100% - 32px)',
|
||||
display: isVisible || measureWhileHidden ? 'flex' : 'none',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
pointerEvents: isVisible ? 'auto' : 'none'
|
||||
}
|
||||
: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
display: 'none',
|
||||
pointerEvents: 'none'
|
||||
},
|
||||
[anchorName, isVisible, measuredFallbackRect, measureWhileHidden]
|
||||
)
|
||||
const focusGroup = useCallback(() => {
|
||||
if (groupId !== undefined && onFocusOwningGroup) {
|
||||
onFocusOwningGroup(groupId)
|
||||
}
|
||||
}, [groupId, onFocusOwningGroup])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
style={style}
|
||||
// Pane-local layers cannot compete with app notifications or escape their split rectangle.
|
||||
className="isolate z-10 min-h-0 min-w-0 overflow-hidden"
|
||||
data-retained-pane-host=""
|
||||
{...identity}
|
||||
inert={!isVisible}
|
||||
aria-hidden={!isVisible}
|
||||
onPointerDown={focusGroup}
|
||||
onFocusCapture={focusGroup}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +1,13 @@
|
||||
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useLayoutEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useAppStore } from '../../store'
|
||||
import { isProvenProcessExit } from '../../../../shared/terminal-exit-cause'
|
||||
import { SYNC_FIT_PANES_EVENT } from '@/constants/terminal'
|
||||
import { tabGroupBodyAnchorName } from '../tab-group/tab-group-body-anchor'
|
||||
import { RetainedPaneHost } from '../tab-group/RetainedPaneHost'
|
||||
import type { ActivityTerminalPortalTarget } from '../activity/activity-terminal-portal'
|
||||
import TerminalPane from './TerminalPane'
|
||||
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
|
||||
import { shouldDeferParkedPtyExitTabClose } from './terminal-parked-tab-watchers'
|
||||
|
||||
const HAS_CSS_ANCHOR_POSITIONING =
|
||||
typeof CSS !== 'undefined' &&
|
||||
CSS.supports('position-anchor', '--orca-terminal-overlay-probe') &&
|
||||
CSS.supports('top', 'anchor(--orca-terminal-overlay-probe top)') &&
|
||||
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 (
|
||||
HAS_CSS_ANCHOR_POSITIONING &&
|
||||
(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ !== true
|
||||
)
|
||||
}
|
||||
|
||||
type MeasuredFallbackRect = {
|
||||
top: number
|
||||
left: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
type TerminalOverlaySlotProps = {
|
||||
terminalTabId: string
|
||||
terminalGeneration: number | undefined
|
||||
@@ -63,11 +39,6 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
|
||||
consumeSuppressedPtyExit,
|
||||
leaveWorktreeIfEmpty
|
||||
}: 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
|
||||
)
|
||||
@@ -76,144 +47,6 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
|
||||
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) {
|
||||
return
|
||||
}
|
||||
const dispatchFitIfMeasurable = (): void => {
|
||||
const rect = overlayRef.current?.getBoundingClientRect()
|
||||
if (
|
||||
!rect ||
|
||||
rect.width < MIN_OVERLAY_FIT_WIDTH_PX ||
|
||||
rect.height < MIN_OVERLAY_FIT_HEIGHT_PX
|
||||
) {
|
||||
return
|
||||
}
|
||||
window.dispatchEvent(new Event(SYNC_FIT_PANES_EVENT))
|
||||
}
|
||||
|
||||
// Why: tab switches can resume visibility before anchor/fallback geometry
|
||||
// settles. Re-fit only after the overlay has real dimensions so the PTY
|
||||
// never stays pinned at a stale ~2-col width.
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
dispatchFitIfMeasurable()
|
||||
})
|
||||
const retryId = window.setTimeout(() => {
|
||||
dispatchFitIfMeasurable()
|
||||
}, 50)
|
||||
const settledRetryId = window.setTimeout(() => {
|
||||
dispatchFitIfMeasurable()
|
||||
}, 150)
|
||||
return () => {
|
||||
cancelAnimationFrame(frameId)
|
||||
window.clearTimeout(retryId)
|
||||
window.clearTimeout(settledRetryId)
|
||||
}
|
||||
}, [anchorName, isVisible, measuredFallbackRect])
|
||||
|
||||
const style: React.CSSProperties = useMemo(
|
||||
() =>
|
||||
anchorName && shouldUseCssAnchorPositioning()
|
||||
? {
|
||||
position: 'absolute',
|
||||
positionAnchor: anchorName,
|
||||
top: `anchor(${anchorName} top)`,
|
||||
left: `anchor(${anchorName} left)`,
|
||||
width: `anchor-size(${anchorName} width)`,
|
||||
height: `anchor-size(${anchorName} height)`,
|
||||
display: isVisible || shouldMeasureHiddenStartup ? 'flex' : 'none',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
pointerEvents: isVisible ? 'auto' : 'none'
|
||||
}
|
||||
: 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.
|
||||
position: 'absolute',
|
||||
top: measuredFallbackRect?.top ?? 32,
|
||||
left: measuredFallbackRect?.left ?? 0,
|
||||
width: measuredFallbackRect?.width ?? '100%',
|
||||
height: measuredFallbackRect?.height ?? 'calc(100% - 32px)',
|
||||
display: isVisible || shouldMeasureHiddenStartup ? 'flex' : 'none',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
pointerEvents: isVisible ? 'auto' : 'none'
|
||||
}
|
||||
: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
display: 'none',
|
||||
pointerEvents: 'none'
|
||||
},
|
||||
[anchorName, isVisible, measuredFallbackRect, shouldMeasureHiddenStartup]
|
||||
)
|
||||
const focusGroup = useCallback(() => {
|
||||
if (groupId !== undefined && onFocusOwningGroup) {
|
||||
onFocusOwningGroup(groupId)
|
||||
}
|
||||
}, [groupId, onFocusOwningGroup])
|
||||
|
||||
const terminalPane = (
|
||||
<TerminalPane
|
||||
@@ -267,17 +100,15 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
style={style}
|
||||
<RetainedPaneHost
|
||||
groupId={groupId}
|
||||
isVisible={isVisible}
|
||||
measureWhileHidden={shouldMeasureHiddenStartup}
|
||||
fitTerminal
|
||||
data-terminal-overlay-tab-id={terminalTabId}
|
||||
onPointerDown={focusGroup}
|
||||
onFocusCapture={focusGroup}
|
||||
onFocusOwningGroup={onFocusOwningGroup}
|
||||
>
|
||||
{terminalPane}
|
||||
{/* The chat/terminal toggle now lives in the pane header's action cluster
|
||||
(TerminalPaneHeaderOverlay), beside split/close — not as a separate
|
||||
floating overlay. */}
|
||||
</div>
|
||||
</RetainedPaneHost>
|
||||
)
|
||||
})
|
||||
|
||||
+2
-1
@@ -174,7 +174,8 @@ describe('TerminalPaneOverlayLayer fallback measure<->fit loop (React #185)', ()
|
||||
capturedResizeCallback?.()
|
||||
})
|
||||
|
||||
expect(terminalPaneRenderCount - rendersAfterMount).toBe(1)
|
||||
// Geometry updates belong to the host and do not rerender terminal content.
|
||||
expect(terminalPaneRenderCount - rendersAfterMount).toBe(0)
|
||||
expect(overlay?.style.top).toBe('34px')
|
||||
expect(overlay?.style.width).toBe('760px')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user