Merge remote-tracking branch 'origin/main' into brennanb2025/orch-structured-downgrade

This commit is contained in:
Merge Sim
2026-09-08 17:07:00 -07:00
14 changed files with 437 additions and 253 deletions
-5
View File
@@ -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;
@@ -15,6 +15,7 @@ import {
useClientHostedBrowserRows
} from '@/lib/pane-manager/client-hosted-browser-row-state'
import { ClientHostedBrowserHostRowPane } from '../client-hosted-browser-host-row-pane'
import { useAnyBrowserPageMountAdmission } from '../host-guest/browser-page-mount-admission'
// 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.
@@ -60,7 +61,8 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({
? browserTab.pageIds
: [browserTab.activePageId ?? browserTab.id]
const needsGuestPaint = useBrowserGuestPaintRetention(browserPageIds)
const isPaintable = isActive || needsGuestPaint
const isMountAdmitted = useAnyBrowserPageMountAdmission(browserPageIds)
const isPaintable = isActive || needsGuestPaint || isMountAdmitted
// 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 style: React.CSSProperties = useMemo(
@@ -22,6 +22,10 @@ import { WorkspaceDocPagePane } from '../workspace-doc/workspace-doc-page-pane'
import { DeferredBrowserContent } from './DeferredBrowserContent'
import { isBrowserPagePanePaintable } from '../host-guest/browser-page-paintability'
import { SshRoutedBrowserPageGate } from './ssh-routed-browser-page-gate'
import {
isBrowserPageMountAdmitted,
useAnyBrowserPageMountAdmission
} from '../host-guest/browser-page-mount-admission'
export default function BrowserPane({
browserTab,
@@ -71,6 +75,7 @@ export default function BrowserPane({
() => localBrowserPages.map((page) => page.id),
[localBrowserPages]
)
const hasAdmittedPage = useAnyBrowserPageMountAdmission(localBrowserPageIds)
const pageDriver = useBrowserDriverForPage(activeBrowserPageId)
// Why: a runtime-backed page is streamed, never locally driven, so its driver must read idle.
const activeBrowserDriver = runtimeEnvironmentActive ? IDLE_BROWSER_DRIVER : pageDriver
@@ -166,7 +171,9 @@ export default function BrowserPane({
key={page.id}
retainMounted={isWorktreeActive}
mountEligible={isBrowserPagePanePaintable({
isActive: isActive && page.id === activeBrowserPageId,
isActive:
(isActive && page.id === activeBrowserPageId) ||
(hasAdmittedPage && isBrowserPageMountAdmitted(page.id)),
isAutomationVisible: automationVisiblePageIds.has(page.id),
isMobileDriven: mobileDrivenPageIds.has(page.id),
hasRemoteViewer: remotelyViewedPageIds.has(page.id)
@@ -0,0 +1,63 @@
import { useSyncExternalStore } from 'react'
// Newly requested pages must start a guest even when opened in the background. Restored pages are
// deliberately absent so worktree restoration can remain lazy.
const admittedPageIds = new Set<string>()
const listeners = new Set<() => void>()
let version = 0
export function isBrowserPageMountAdmitted(pageId: string): boolean {
return admittedPageIds.has(pageId)
}
function emit(): void {
version += 1
for (const listener of listeners) {
listener()
}
}
export function admitBrowserPageMount(pageId: string): void {
if (admittedPageIds.has(pageId)) {
return
}
admittedPageIds.add(pageId)
emit()
}
export function releaseBrowserPageMount(pageId: string): void {
if (!admittedPageIds.delete(pageId)) {
return
}
emit()
}
export function useBrowserPageMountAdmission(pageId: string): boolean {
useSyncExternalStore(
(listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
() => {
void version
return isBrowserPageMountAdmitted(pageId)
},
() => false
)
return isBrowserPageMountAdmitted(pageId)
}
export function useAnyBrowserPageMountAdmission(pageIds: readonly string[]): boolean {
useSyncExternalStore(
(listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
() => {
void version
return pageIds.some(isBrowserPageMountAdmitted)
},
() => false
)
return pageIds.some(isBrowserPageMountAdmitted)
}
@@ -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>
)
})
@@ -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')
})
@@ -13,6 +13,7 @@ import { closeRemoteBrowserPageInOwningEnvironment } from './browser-remote-clos
import { releaseDocPreviewGrant } from '@/lib/doc-preview-grants'
import { destroyWorkspaceWebviews } from '../browser-webview-cleanup'
import { omitRecordKeys } from '../worktrees/teardown/record-key-omission'
import { releaseBrowserPageMount } from '@/components/browser-pane/host-guest/browser-page-mount-admission'
export function createBrowserCloseActions(
set: BrowserSliceSet,
@@ -28,6 +29,7 @@ export function createBrowserCloseActions(
// grant is the only authority the preview scheme honors — a closed document must stop being
// readable, and it must stop being readable even if the reducer bails out below.
let docPageIdsToRelease: string[] = []
let closedPagesForMountRelease: string[] = []
let activeBrowserWorktreeIdToNotify: string | null = null
set((s) => {
let owningWorktreeId: string | null = null
@@ -49,6 +51,7 @@ export function createBrowserCloseActions(
}
const closedPages = s.browserPagesByWorkspace[tabId] ?? []
closedPagesForMountRelease = closedPages.map((page) => page.id)
const nextBrowserPagesByWorkspace = { ...s.browserPagesByWorkspace }
delete nextBrowserPagesByWorkspace[tabId]
const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId }
@@ -179,6 +182,9 @@ export function createBrowserCloseActions(
for (const docPageId of docPageIdsToRelease) {
releaseDocPreviewGrant(docPageId)
}
for (const pageId of closedPagesForMountRelease) {
releaseBrowserPageMount(pageId)
}
for (const tabs of Object.values(get().unifiedTabsByWorktree)) {
const workspaceItem = tabs.find(
@@ -13,6 +13,10 @@ import {
import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/constants'
import { closeRemoteBrowserPageInOwningEnvironment } from './browser-remote-close'
import { releaseDocPreviewGrant } from '@/lib/doc-preview-grants'
import {
admitBrowserPageMount,
releaseBrowserPageMount
} from '@/components/browser-pane/host-guest/browser-page-mount-admission'
export function createBrowserPageCreateActions(
set: BrowserSliceSet,
@@ -33,6 +37,9 @@ export function createBrowserPageCreateActions(
undefined,
options?.docLocation
)
if (!options?.browserRuntimeEnvironmentId && !options?.docLocation) {
admitBrowserPageMount(page.id)
}
set((s) => {
const pages = s.browserPagesByWorkspace[workspaceId] ?? []
@@ -94,6 +101,7 @@ export function createBrowserPageCreateActions(
},
closeBrowserPage: (pageId) => {
releaseBrowserPageMount(pageId)
let closedWorkspaceIdForLabel: string | null = null
let docPageIdToRelease: string | null = null
const remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = []
@@ -17,6 +17,7 @@ import {
} from '../browser-page-records'
import { getBrowserSessionProfileHostId } from './browser-host-state'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { admitBrowserPageMount } from '@/components/browser-pane/host-guest/browser-page-mount-admission'
export function createBrowserTabActions(
set: BrowserSliceSet,
@@ -48,6 +49,9 @@ export function createBrowserTabActions(
browserPageId,
options?.docLocation
)
if (!options?.browserRuntimeEnvironmentId && !options?.docLocation) {
admitBrowserPageMount(page.id)
}
// Why: with no explicit profile, inherit the user's default so a Settings preference applies to new tabs.
const sessionProfileId =
options?.sessionProfileId !== undefined