mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 16:02:55 +00:00
fix(lint): split overlay slot and eviction-exempt tabs under max-lines
Static analysis failed because TerminalPaneOverlayLayer (401) and terminal-parked-tab-watchers (304) exceeded oxlint max-lines. Extract the slot component and eviction-exempt helpers into dedicated modules.
This commit is contained in:
@@ -101,11 +101,11 @@ import { captureForceParkedWorktreeBuffers } from './terminal-pane/force-park-bu
|
||||
import { warnTerminalLifecycleAnomaly } from './terminal-pane/terminal-lifecycle-diagnostics'
|
||||
import { getTerminalParkingPolicyOverrides } from './terminal-pane/terminal-parking-e2e-overrides'
|
||||
import { useColdParkedTerminalPresentation } from './terminal-pane/use-cold-parked-terminal-presentation'
|
||||
import { selectEvictionExemptTerminalTabIds } from './terminal-pane/terminal-eviction-exempt-tabs'
|
||||
import {
|
||||
canWatcherCoverParkedTerminalTab,
|
||||
disposeAllParkedTerminalWatchers,
|
||||
pruneParkedTerminalWatchers,
|
||||
selectEvictionExemptTerminalTabIds,
|
||||
shouldDeferParkedPtyExitTabClose,
|
||||
syncParkedTerminalTabWatchers
|
||||
} from './terminal-pane/terminal-parked-tab-watchers'
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useAppStore } from '../../store'
|
||||
import { SYNC_FIT_PANES_EVENT } from '@/constants/terminal'
|
||||
import { tabGroupBodyAnchorName } from '../tab-group/tab-group-body-anchor'
|
||||
import 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'
|
||||
import {
|
||||
FALLBACK_RECT_MIN_CHANGE_PX,
|
||||
MIN_OVERLAY_FIT_HEIGHT_PX,
|
||||
MIN_OVERLAY_FIT_WIDTH_PX,
|
||||
shouldUseCssAnchorPositioning
|
||||
} from './terminal-overlay-positioning'
|
||||
|
||||
type MeasuredFallbackRect = {
|
||||
top: number
|
||||
left: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
type TerminalOverlaySlotProps = {
|
||||
terminalTabId: string
|
||||
terminalGeneration: number | undefined
|
||||
worktreeId: string
|
||||
worktreePath: string
|
||||
startupCwd: string | undefined
|
||||
groupId: string | undefined
|
||||
isWorktreeActive: boolean
|
||||
isWorktreePresented: boolean
|
||||
isVisible: boolean
|
||||
isPresented: boolean
|
||||
isActive: boolean
|
||||
activityTerminalPortal: ActivityTerminalPortalTarget | null
|
||||
onFocusOwningGroup: ((groupId: string) => void) | undefined
|
||||
consumeSuppressedPtyExit: (ptyId: string) => boolean
|
||||
leaveWorktreeIfEmpty: () => void
|
||||
onInitialRenderSettled?: () => void
|
||||
}
|
||||
|
||||
export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
|
||||
terminalTabId,
|
||||
terminalGeneration,
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
startupCwd,
|
||||
groupId,
|
||||
isWorktreeActive,
|
||||
isWorktreePresented,
|
||||
isVisible,
|
||||
isPresented,
|
||||
isActive,
|
||||
activityTerminalPortal,
|
||||
onFocusOwningGroup,
|
||||
consumeSuppressedPtyExit,
|
||||
leaveWorktreeIfEmpty,
|
||||
onInitialRenderSettled
|
||||
}: 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
|
||||
)
|
||||
useLayoutEffect(() => {
|
||||
if (isVisible && shouldMeasureHiddenStartup) {
|
||||
setShouldMeasureHiddenStartup(false)
|
||||
}
|
||||
}, [isVisible, shouldMeasureHiddenStartup])
|
||||
useLayoutEffect(() => {
|
||||
if (!anchorName || shouldUseCssAnchorPositioning() || !groupId) {
|
||||
return
|
||||
}
|
||||
|
||||
const findBody = (): HTMLElement | null => {
|
||||
for (const candidate of document.querySelectorAll<HTMLElement>('[data-tab-group-body-id]')) {
|
||||
if (candidate.dataset.tabGroupBodyId === groupId) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const updateRect = (): void => {
|
||||
const overlay = overlayRef.current
|
||||
const parent = overlay?.parentElement
|
||||
const body = findBody()
|
||||
if (!parent || !body) {
|
||||
setMeasuredFallbackRect(null)
|
||||
return
|
||||
}
|
||||
const parentRect = parent.getBoundingClientRect()
|
||||
const bodyRect = body.getBoundingClientRect()
|
||||
const next: MeasuredFallbackRect = {
|
||||
top: bodyRect.top - parentRect.top,
|
||||
left: bodyRect.left - parentRect.left,
|
||||
width: bodyRect.width,
|
||||
height: bodyRect.height
|
||||
}
|
||||
// Why: ResizeObserver and xterm fit can otherwise amplify sub-pixel jitter forever.
|
||||
setMeasuredFallbackRect((prev) =>
|
||||
prev &&
|
||||
Math.abs(prev.top - next.top) < FALLBACK_RECT_MIN_CHANGE_PX &&
|
||||
Math.abs(prev.left - next.left) < FALLBACK_RECT_MIN_CHANGE_PX &&
|
||||
Math.abs(prev.width - next.width) < FALLBACK_RECT_MIN_CHANGE_PX &&
|
||||
Math.abs(prev.height - next.height) < FALLBACK_RECT_MIN_CHANGE_PX
|
||||
? prev
|
||||
: next
|
||||
)
|
||||
}
|
||||
|
||||
updateRect()
|
||||
const body = findBody()
|
||||
const parent = overlayRef.current?.parentElement
|
||||
const resizeObserver = new ResizeObserver(updateRect)
|
||||
if (body) {
|
||||
resizeObserver.observe(body)
|
||||
}
|
||||
if (parent) {
|
||||
resizeObserver.observe(parent)
|
||||
}
|
||||
window.addEventListener('resize', updateRect)
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
window.removeEventListener('resize', updateRect)
|
||||
}
|
||||
}, [anchorName, groupId, isVisible])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isVisible || !anchorName) {
|
||||
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 showPresentedTerminal = isPresented && (isWorktreeActive || isWorktreePresented)
|
||||
const isInteractive = isVisible && isPresented && isWorktreePresented
|
||||
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 || showPresentedTerminal || shouldMeasureHiddenStartup ? 'flex' : 'none',
|
||||
opacity: showPresentedTerminal ? 1 : 0,
|
||||
pointerEvents: isInteractive ? '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 || showPresentedTerminal || shouldMeasureHiddenStartup ? 'flex' : 'none',
|
||||
opacity: showPresentedTerminal ? 1 : 0,
|
||||
pointerEvents: isInteractive ? 'auto' : 'none'
|
||||
}
|
||||
: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
display: 'none',
|
||||
pointerEvents: 'none'
|
||||
},
|
||||
[
|
||||
anchorName,
|
||||
isInteractive,
|
||||
isVisible,
|
||||
measuredFallbackRect,
|
||||
shouldMeasureHiddenStartup,
|
||||
showPresentedTerminal
|
||||
]
|
||||
)
|
||||
const focusGroup = useCallback(() => {
|
||||
if (groupId !== undefined && onFocusOwningGroup) {
|
||||
onFocusOwningGroup(groupId)
|
||||
}
|
||||
}, [groupId, onFocusOwningGroup])
|
||||
|
||||
const terminalPane = (
|
||||
<TerminalPane
|
||||
key={`${terminalTabId}-${terminalGeneration ?? 0}`}
|
||||
tabId={terminalTabId}
|
||||
worktreeId={worktreeId}
|
||||
cwd={startupCwd ?? worktreePath}
|
||||
isActive={
|
||||
(isActive && isPresented && isWorktreePresented) || activityTerminalPortal?.active === true
|
||||
}
|
||||
// Why: split-group changes reparent TabGroupPanel subtrees. Keeping the
|
||||
// TerminalPane mounted here preserves alt-screen TUI state while this
|
||||
// flag still lets hidden tabs throttle rendering.
|
||||
isVisible={isVisible || showPresentedTerminal || activityTerminalPortal !== null}
|
||||
isWorktreeActive={isWorktreeActive || isWorktreePresented || activityTerminalPortal !== null}
|
||||
isolatedPaneKey={activityTerminalPortal?.paneKey ?? null}
|
||||
onPtyExit={(ptyId) => {
|
||||
if (consumeSuppressedPtyExit(ptyId)) {
|
||||
return
|
||||
}
|
||||
// Why: a parked multi-leaf tab has no PaneManager to promote split
|
||||
// siblings, so closing the tab here would kill them; the reveal
|
||||
// remount handles dead PTYs per leaf instead.
|
||||
if (shouldDeferParkedPtyExitTabClose(terminalTabId, ptyId)) {
|
||||
return
|
||||
}
|
||||
closeTerminalTab(terminalTabId, {
|
||||
reason: 'pty-exit',
|
||||
lifecyclePtyId: ptyId,
|
||||
onClosed: leaveWorktreeIfEmpty
|
||||
})
|
||||
}}
|
||||
onCloseTab={() => {
|
||||
// Why: route through closeTerminalTab (not the raw store closeTab) so a
|
||||
// pinned tab hits the confirmation guard. The overlay's direct
|
||||
// store.closeTab was the path that closed pinned terminals silently.
|
||||
closeTerminalTab(terminalTabId, { onClosed: leaveWorktreeIfEmpty })
|
||||
}}
|
||||
onInitialRenderSettled={onInitialRenderSettled}
|
||||
/>
|
||||
)
|
||||
|
||||
if (activityTerminalPortal) {
|
||||
return createPortal(
|
||||
terminalPane,
|
||||
activityTerminalPortal.target,
|
||||
`activity-terminal-${terminalTabId}`
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
style={style}
|
||||
data-terminal-overlay-tab-id={terminalTabId}
|
||||
data-terminal-overlay-presented={showPresentedTerminal ? 'true' : 'false'}
|
||||
inert={!isInteractive}
|
||||
aria-hidden={!isInteractive}
|
||||
onPointerDown={focusGroup}
|
||||
onFocusCapture={focusGroup}
|
||||
>
|
||||
{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>
|
||||
)
|
||||
})
|
||||
+1
-1
@@ -19,7 +19,7 @@ vi.mock('../../store', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
import { TerminalOverlaySlot } from './TerminalPaneOverlayLayer'
|
||||
import { TerminalOverlaySlot } from './TerminalOverlaySlot'
|
||||
|
||||
const GROUP_ID = 'group-react185'
|
||||
const TAB_ID = 'tab-react185'
|
||||
|
||||
@@ -1,309 +1,23 @@
|
||||
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { memo, useCallback, useMemo } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import type { Tab, TabGroup, TerminalTab } from '../../../../shared/types'
|
||||
import { useAppStore } from '../../store'
|
||||
import { SYNC_FIT_PANES_EVENT } from '@/constants/terminal'
|
||||
import { tabGroupBodyAnchorName } from '../tab-group/tab-group-body-anchor'
|
||||
import {
|
||||
findActivityTerminalPortal,
|
||||
type ActivityTerminalPortalTarget
|
||||
} from '../activity/activity-terminal-portal'
|
||||
import TerminalPane from './TerminalPane'
|
||||
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
|
||||
import { shouldMountBackgroundWorktreeTab } from '../terminal/background-terminal-worktree-mount'
|
||||
import { useNativeChatToggleShortcut } from '../native-chat/use-native-chat-toggle-shortcut'
|
||||
import { shouldDeferParkedPtyExitTabClose } from './terminal-parked-tab-watchers'
|
||||
import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking'
|
||||
import { useTerminalOverlayPresentation } from './use-terminal-overlay-presentation'
|
||||
import { buildTerminalOverlayAssignments } from './terminal-overlay-assignments'
|
||||
import {
|
||||
FALLBACK_RECT_MIN_CHANGE_PX,
|
||||
MIN_OVERLAY_FIT_HEIGHT_PX,
|
||||
MIN_OVERLAY_FIT_WIDTH_PX,
|
||||
shouldUseCssAnchorPositioning
|
||||
} from './terminal-overlay-positioning'
|
||||
import { TerminalOverlaySlot } from './TerminalOverlaySlot'
|
||||
|
||||
const EMPTY_TERMINAL_TABS: readonly TerminalTab[] = []
|
||||
const EMPTY_UNIFIED_TABS: readonly Tab[] = []
|
||||
const EMPTY_GROUPS: readonly TabGroup[] = []
|
||||
const EMPTY_ACTIVITY_PORTALS: ActivityTerminalPortalTarget[] = []
|
||||
|
||||
type MeasuredFallbackRect = {
|
||||
top: number
|
||||
left: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
type TerminalOverlaySlotProps = {
|
||||
terminalTabId: string
|
||||
terminalGeneration: number | undefined
|
||||
worktreeId: string
|
||||
worktreePath: string
|
||||
startupCwd: string | undefined
|
||||
groupId: string | undefined
|
||||
isWorktreeActive: boolean
|
||||
isWorktreePresented: boolean
|
||||
isVisible: boolean
|
||||
isPresented: boolean
|
||||
isActive: boolean
|
||||
activityTerminalPortal: ActivityTerminalPortalTarget | null
|
||||
onFocusOwningGroup: ((groupId: string) => void) | undefined
|
||||
consumeSuppressedPtyExit: (ptyId: string) => boolean
|
||||
leaveWorktreeIfEmpty: () => void
|
||||
onInitialRenderSettled?: () => void
|
||||
}
|
||||
|
||||
export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
|
||||
terminalTabId,
|
||||
terminalGeneration,
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
startupCwd,
|
||||
groupId,
|
||||
isWorktreeActive,
|
||||
isWorktreePresented,
|
||||
isVisible,
|
||||
isPresented,
|
||||
isActive,
|
||||
activityTerminalPortal,
|
||||
onFocusOwningGroup,
|
||||
consumeSuppressedPtyExit,
|
||||
leaveWorktreeIfEmpty,
|
||||
onInitialRenderSettled
|
||||
}: 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
|
||||
)
|
||||
useLayoutEffect(() => {
|
||||
if (isVisible && shouldMeasureHiddenStartup) {
|
||||
setShouldMeasureHiddenStartup(false)
|
||||
}
|
||||
}, [isVisible, shouldMeasureHiddenStartup])
|
||||
useLayoutEffect(() => {
|
||||
if (!anchorName || shouldUseCssAnchorPositioning() || !groupId) {
|
||||
return
|
||||
}
|
||||
|
||||
const findBody = (): HTMLElement | null => {
|
||||
for (const candidate of document.querySelectorAll<HTMLElement>('[data-tab-group-body-id]')) {
|
||||
if (candidate.dataset.tabGroupBodyId === groupId) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const updateRect = (): void => {
|
||||
const overlay = overlayRef.current
|
||||
const parent = overlay?.parentElement
|
||||
const body = findBody()
|
||||
if (!parent || !body) {
|
||||
setMeasuredFallbackRect(null)
|
||||
return
|
||||
}
|
||||
const parentRect = parent.getBoundingClientRect()
|
||||
const bodyRect = body.getBoundingClientRect()
|
||||
const next: MeasuredFallbackRect = {
|
||||
top: bodyRect.top - parentRect.top,
|
||||
left: bodyRect.left - parentRect.left,
|
||||
width: bodyRect.width,
|
||||
height: bodyRect.height
|
||||
}
|
||||
// Why: ResizeObserver and xterm fit can otherwise amplify sub-pixel jitter forever.
|
||||
setMeasuredFallbackRect((prev) =>
|
||||
prev &&
|
||||
Math.abs(prev.top - next.top) < FALLBACK_RECT_MIN_CHANGE_PX &&
|
||||
Math.abs(prev.left - next.left) < FALLBACK_RECT_MIN_CHANGE_PX &&
|
||||
Math.abs(prev.width - next.width) < FALLBACK_RECT_MIN_CHANGE_PX &&
|
||||
Math.abs(prev.height - next.height) < FALLBACK_RECT_MIN_CHANGE_PX
|
||||
? prev
|
||||
: next
|
||||
)
|
||||
}
|
||||
|
||||
updateRect()
|
||||
const body = findBody()
|
||||
const parent = overlayRef.current?.parentElement
|
||||
const resizeObserver = new ResizeObserver(updateRect)
|
||||
if (body) {
|
||||
resizeObserver.observe(body)
|
||||
}
|
||||
if (parent) {
|
||||
resizeObserver.observe(parent)
|
||||
}
|
||||
window.addEventListener('resize', updateRect)
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
window.removeEventListener('resize', updateRect)
|
||||
}
|
||||
}, [anchorName, groupId, isVisible])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isVisible || !anchorName) {
|
||||
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 showPresentedTerminal = isPresented && (isWorktreeActive || isWorktreePresented)
|
||||
const isInteractive = isVisible && isPresented && isWorktreePresented
|
||||
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 || showPresentedTerminal || shouldMeasureHiddenStartup ? 'flex' : 'none',
|
||||
opacity: showPresentedTerminal ? 1 : 0,
|
||||
pointerEvents: isInteractive ? '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 || showPresentedTerminal || shouldMeasureHiddenStartup ? 'flex' : 'none',
|
||||
opacity: showPresentedTerminal ? 1 : 0,
|
||||
pointerEvents: isInteractive ? 'auto' : 'none'
|
||||
}
|
||||
: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
display: 'none',
|
||||
pointerEvents: 'none'
|
||||
},
|
||||
[
|
||||
anchorName,
|
||||
isInteractive,
|
||||
isVisible,
|
||||
measuredFallbackRect,
|
||||
shouldMeasureHiddenStartup,
|
||||
showPresentedTerminal
|
||||
]
|
||||
)
|
||||
const focusGroup = useCallback(() => {
|
||||
if (groupId !== undefined && onFocusOwningGroup) {
|
||||
onFocusOwningGroup(groupId)
|
||||
}
|
||||
}, [groupId, onFocusOwningGroup])
|
||||
|
||||
const terminalPane = (
|
||||
<TerminalPane
|
||||
key={`${terminalTabId}-${terminalGeneration ?? 0}`}
|
||||
tabId={terminalTabId}
|
||||
worktreeId={worktreeId}
|
||||
cwd={startupCwd ?? worktreePath}
|
||||
isActive={
|
||||
(isActive && isPresented && isWorktreePresented) || activityTerminalPortal?.active === true
|
||||
}
|
||||
// Why: split-group changes reparent TabGroupPanel subtrees. Keeping the
|
||||
// TerminalPane mounted here preserves alt-screen TUI state while this
|
||||
// flag still lets hidden tabs throttle rendering.
|
||||
isVisible={isVisible || showPresentedTerminal || activityTerminalPortal !== null}
|
||||
isWorktreeActive={isWorktreeActive || isWorktreePresented || activityTerminalPortal !== null}
|
||||
isolatedPaneKey={activityTerminalPortal?.paneKey ?? null}
|
||||
onPtyExit={(ptyId) => {
|
||||
if (consumeSuppressedPtyExit(ptyId)) {
|
||||
return
|
||||
}
|
||||
// Why: a parked multi-leaf tab has no PaneManager to promote split
|
||||
// siblings, so closing the tab here would kill them; the reveal
|
||||
// remount handles dead PTYs per leaf instead.
|
||||
if (shouldDeferParkedPtyExitTabClose(terminalTabId, ptyId)) {
|
||||
return
|
||||
}
|
||||
closeTerminalTab(terminalTabId, {
|
||||
reason: 'pty-exit',
|
||||
lifecyclePtyId: ptyId,
|
||||
onClosed: leaveWorktreeIfEmpty
|
||||
})
|
||||
}}
|
||||
onCloseTab={() => {
|
||||
// Why: route through closeTerminalTab (not the raw store closeTab) so a
|
||||
// pinned tab hits the confirmation guard. The overlay's direct
|
||||
// store.closeTab was the path that closed pinned terminals silently.
|
||||
closeTerminalTab(terminalTabId, { onClosed: leaveWorktreeIfEmpty })
|
||||
}}
|
||||
onInitialRenderSettled={onInitialRenderSettled}
|
||||
/>
|
||||
)
|
||||
|
||||
if (activityTerminalPortal) {
|
||||
return createPortal(
|
||||
terminalPane,
|
||||
activityTerminalPortal.target,
|
||||
`activity-terminal-${terminalTabId}`
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
style={style}
|
||||
data-terminal-overlay-tab-id={terminalTabId}
|
||||
data-terminal-overlay-presented={showPresentedTerminal ? 'true' : 'false'}
|
||||
inert={!isInteractive}
|
||||
aria-hidden={!isInteractive}
|
||||
onPointerDown={focusGroup}
|
||||
onFocusCapture={focusGroup}
|
||||
>
|
||||
{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>
|
||||
)
|
||||
})
|
||||
|
||||
const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Eviction-exempt parked terminal tabs.
|
||||
*
|
||||
* Why: force-park unmounts restorable tabs under the hidden-worktree retention
|
||||
* budget, but some live local PTYs cannot reattach on remount. Those tabs must
|
||||
* keep their panes mounted so eviction does not orphan a live shell.
|
||||
*/
|
||||
import { useAppStore } from '@/store'
|
||||
import { isEvictionExemptTerminalPty } from './terminal-hidden-worktree-retention'
|
||||
import {
|
||||
resolveParkedTerminalPaneCandidates,
|
||||
type ParkableTerminalTabModel
|
||||
} from './terminal-parked-tab-watchers'
|
||||
|
||||
/**
|
||||
* Whether force-park must keep this tab's panes mounted: ANY of its pane PTYs is
|
||||
* one a remount could not reattach. Resolved from the same pane candidates as
|
||||
* canWatcherCoverParkedTerminalTab — tab.ptyId is only the first leaf's PTY, so
|
||||
* a split tab whose SECOND leaf holds the unrestorable PTY fails coverage (and
|
||||
* so becomes a retention candidate) yet would otherwise look exempt-free and
|
||||
* unmount, orphaning that live shell. tab.ptyId stays in the union in case pane
|
||||
* resolution misses it (no layout, no capture).
|
||||
*
|
||||
* Accepted residual: detection is per pane but retention is per TAB — the whole
|
||||
* PaneManager stays mounted, so one exempt leaf also pins its restorable split
|
||||
* siblings, and the retention budget cannot bound exempt tabs. Capping them is
|
||||
* not an option (eviction orphans the live shell); the real fix is making those
|
||||
* pty classes reattachable. Sibling TABS are unaffected — the worktree-level
|
||||
* veto was removed (selectRetentionForceParkedTerminalWorktrees never consults
|
||||
* tab exemptions).
|
||||
*/
|
||||
export function isEvictionExemptTerminalTab(
|
||||
tab: ParkableTerminalTabModel,
|
||||
worktreeId: string
|
||||
): boolean {
|
||||
if (isEvictionExemptTerminalPty(tab.ptyId, worktreeId)) {
|
||||
return true
|
||||
}
|
||||
return resolveParkedTerminalPaneCandidates(tab, useAppStore.getState()).some((pane) =>
|
||||
isEvictionExemptTerminalPty(pane.ptyId, worktreeId)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Eviction-exempt tab ids for one worktree's tabs, resolved in a single pass.
|
||||
*
|
||||
* Why: each isEvictionExemptTerminalTab call re-reads the store and can walk the
|
||||
* layout tree, so render and watcher-sync consume a memoized set instead of
|
||||
* re-asking per tab on every unrelated re-render.
|
||||
*/
|
||||
export function selectEvictionExemptTerminalTabIds(
|
||||
worktreeId: string,
|
||||
tabs: readonly ParkableTerminalTabModel[]
|
||||
): ReadonlySet<string> {
|
||||
const exemptTabIds = new Set<string>()
|
||||
for (const tab of tabs) {
|
||||
if (isEvictionExemptTerminalTab(tab, worktreeId)) {
|
||||
exemptTabIds.add(tab.id)
|
||||
}
|
||||
}
|
||||
return exemptTabIds
|
||||
}
|
||||
@@ -92,6 +92,10 @@ vi.mock('@/store', () => ({
|
||||
useAppStore: { getState: () => mockStoreState }
|
||||
}))
|
||||
|
||||
import {
|
||||
isEvictionExemptTerminalTab,
|
||||
selectEvictionExemptTerminalTabIds
|
||||
} from './terminal-eviction-exempt-tabs'
|
||||
import {
|
||||
canWatcherCoverParkedTerminalTab,
|
||||
captureParkedTerminalPaneCandidates,
|
||||
@@ -99,9 +103,7 @@ import {
|
||||
disposeParkedTerminalWatchersForWorktree,
|
||||
fallbackParkedPaneCandidates,
|
||||
getParkedTerminalWatcherTabIds,
|
||||
isEvictionExemptTerminalTab,
|
||||
pruneParkedTerminalWatchers,
|
||||
selectEvictionExemptTerminalTabIds,
|
||||
shouldDeferParkedPtyExitTabClose,
|
||||
syncParkedTerminalTabWatchers
|
||||
} from './terminal-parked-tab-watchers'
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
isParkRestorableTerminalPty,
|
||||
type TerminalParkRestorePolicy
|
||||
} from './terminal-hidden-view-parking'
|
||||
import { isEvictionExemptTerminalPty } from './terminal-hidden-worktree-retention'
|
||||
import {
|
||||
resolveTabTitleAfterPaneClose,
|
||||
shouldClearLaunchAgentForClosedPane
|
||||
@@ -85,7 +84,7 @@ export function fallbackParkedPaneCandidates(
|
||||
}
|
||||
|
||||
// Why: start path and eligibility check must resolve identical candidates, or a tab passes the check then starts uncoverable.
|
||||
function resolveParkedTerminalPaneCandidates(
|
||||
export function resolveParkedTerminalPaneCandidates(
|
||||
tab: ParkableTerminalTabModel,
|
||||
state: ParkedPaneFallbackState
|
||||
): ParkedTerminalPaneCapture[] {
|
||||
@@ -107,55 +106,6 @@ function parkRestorePolicyFromState(state: {
|
||||
return { sshParkingEnabled: state.settings?.terminalSshViewParking !== false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether force-park must keep this tab's panes mounted: ANY of its pane PTYs is
|
||||
* one a remount could not reattach. Resolved from the same pane candidates as
|
||||
* canWatcherCoverParkedTerminalTab — tab.ptyId is only the first leaf's PTY, so
|
||||
* a split tab whose SECOND leaf holds the unrestorable PTY fails coverage (and
|
||||
* so becomes a retention candidate) yet would otherwise look exempt-free and
|
||||
* unmount, orphaning that live shell. tab.ptyId stays in the union in case pane
|
||||
* resolution misses it (no layout, no capture).
|
||||
*
|
||||
* Accepted residual: detection is per pane but retention is per TAB — the whole
|
||||
* PaneManager stays mounted, so one exempt leaf also pins its restorable split
|
||||
* siblings, and the retention budget cannot bound exempt tabs. Capping them is
|
||||
* not an option (eviction orphans the live shell); the real fix is making those
|
||||
* pty classes reattachable. Sibling TABS are unaffected — the worktree-level
|
||||
* veto was removed (selectRetentionForceParkedTerminalWorktrees never consults
|
||||
* tab exemptions).
|
||||
*/
|
||||
export function isEvictionExemptTerminalTab(
|
||||
tab: ParkableTerminalTabModel,
|
||||
worktreeId: string
|
||||
): boolean {
|
||||
if (isEvictionExemptTerminalPty(tab.ptyId, worktreeId)) {
|
||||
return true
|
||||
}
|
||||
return resolveParkedTerminalPaneCandidates(tab, useAppStore.getState()).some((pane) =>
|
||||
isEvictionExemptTerminalPty(pane.ptyId, worktreeId)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Eviction-exempt tab ids for one worktree's tabs, resolved in a single pass.
|
||||
*
|
||||
* Why: each isEvictionExemptTerminalTab call re-reads the store and can walk the
|
||||
* layout tree, so render and watcher-sync consume a memoized set instead of
|
||||
* re-asking per tab on every unrelated re-render.
|
||||
*/
|
||||
export function selectEvictionExemptTerminalTabIds(
|
||||
worktreeId: string,
|
||||
tabs: readonly ParkableTerminalTabModel[]
|
||||
): ReadonlySet<string> {
|
||||
const exemptTabIds = new Set<string>()
|
||||
for (const tab of tabs) {
|
||||
if (isEvictionExemptTerminalTab(tab, worktreeId)) {
|
||||
exemptTabIds.add(tab.id)
|
||||
}
|
||||
}
|
||||
return exemptTabIds
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether parked byte watchers can fully cover this tab's PTYs (every candidate
|
||||
* has a park-restorable PTY on a valid leaf). Hosts must refuse to park a tab
|
||||
|
||||
@@ -16,13 +16,16 @@ vi.mock('../../store', () => ({
|
||||
useAppStore: (selector: (state: unknown) => unknown) => selector(mocks.storeState)
|
||||
}))
|
||||
|
||||
vi.mock('./terminal-parked-tab-watchers', () => ({
|
||||
canWatcherCoverParkedTerminalTab: () => true,
|
||||
disposeParkedTerminalWatchersForWorktree: vi.fn(),
|
||||
vi.mock('./terminal-eviction-exempt-tabs', () => ({
|
||||
selectEvictionExemptTerminalTabIds: (_worktreeId: string, tabs: readonly { id: string }[]) => {
|
||||
mocks.exemptSelectCalls += 1
|
||||
return new Set(tabs.filter((tab) => mocks.exemptTabIds.has(tab.id)).map((tab) => tab.id))
|
||||
},
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./terminal-parked-tab-watchers', () => ({
|
||||
canWatcherCoverParkedTerminalTab: () => true,
|
||||
disposeParkedTerminalWatchersForWorktree: vi.fn(),
|
||||
syncParkedTerminalTabWatchers: vi.fn()
|
||||
}))
|
||||
|
||||
|
||||
@@ -24,10 +24,10 @@ import {
|
||||
type ParkVerdictFlipRecord
|
||||
} from './terminal-park-verdict-flip-telemetry'
|
||||
import { getTerminalParkingPolicyOverrides } from './terminal-parking-e2e-overrides'
|
||||
import { selectEvictionExemptTerminalTabIds } from './terminal-eviction-exempt-tabs'
|
||||
import {
|
||||
canWatcherCoverParkedTerminalTab,
|
||||
disposeParkedTerminalWatchersForWorktree,
|
||||
selectEvictionExemptTerminalTabIds,
|
||||
syncParkedTerminalTabWatchers
|
||||
} from './terminal-parked-tab-watchers'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user