mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/orch-structured-downgrade
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;
|
||||
|
||||
+3
-1
@@ -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)
|
||||
}
|
||||
+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')
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user