mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 08:04:05 +00:00
Fix web terminal fit on worktree switch and harden paired tab lifecycle.
Guard against sub-minimum xterm fits during overlay resume, route agent tab closes through unified/runtime helpers, and dedupe wake-time terminal respawns. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -394,8 +394,20 @@ export default function SortableTab({
|
||||
// the store — a store-only assertion would pass even if this
|
||||
// button had been accidentally unmounted.
|
||||
aria-label={`Close tab ${tabTitle}`}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
type="button"
|
||||
data-tab-close-button="true"
|
||||
onPointerDown={(e) => {
|
||||
if (e.button === 0) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 0) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClose(tab.id)
|
||||
}}
|
||||
|
||||
@@ -13,6 +13,7 @@ import TabBar from '../tab-bar/TabBar'
|
||||
import { TabBarQuickCommandsButton } from '../tab-bar/TabBarQuickCommandsButton'
|
||||
import { useTabGroupWorkspaceModel } from './useTabGroupWorkspaceModel'
|
||||
import TabGroupDropOverlay from './TabGroupDropOverlay'
|
||||
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
|
||||
import { resolveGroupTabFromVisibleId } from './tab-group-visible-id'
|
||||
import {
|
||||
getTabPaneBodyDroppableId,
|
||||
@@ -88,12 +89,15 @@ export default function TabGroupPanel({
|
||||
expandedPaneByTabId={model.expandedPaneByTabId}
|
||||
onActivate={commands.activateTerminal}
|
||||
onClose={(terminalId) => {
|
||||
const item = model.groupTabs.find(
|
||||
(candidate) => candidate.entityId === terminalId && candidate.contentType === 'terminal'
|
||||
)
|
||||
if (item) {
|
||||
const item = resolveGroupTabFromVisibleId(model.groupTabs, terminalId)
|
||||
if (item?.contentType === 'terminal') {
|
||||
commands.closeItem(item.id)
|
||||
return
|
||||
}
|
||||
// Why: agent quick-launch can briefly desync unified/runtime tab ids
|
||||
// before the host snapshot lands; still route close through the shared
|
||||
// terminal close helper instead of no-op'ing.
|
||||
closeTerminalTab(terminalId)
|
||||
}}
|
||||
onCloseOthers={(visibleId) => {
|
||||
// Why: TabBar emits this with the entityId for terminals/browsers and
|
||||
|
||||
@@ -1148,6 +1148,11 @@ export default function TerminalPane({
|
||||
if (getFitOverrideForPty(ptyId) || isPtyLocked(ptyId)) {
|
||||
continue
|
||||
}
|
||||
// Why: skip forwarding a stale near-zero fit to the host PTY while the
|
||||
// overlay is still settling after a worktree switch.
|
||||
if (pane.terminal.cols < 8 || pane.terminal.rows < 4) {
|
||||
continue
|
||||
}
|
||||
transport.resize(pane.terminal.cols, pane.terminal.rows)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,25 +124,24 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
|
||||
resizeObserver.disconnect()
|
||||
window.removeEventListener('resize', updateRect)
|
||||
}
|
||||
}, [anchorName, groupId])
|
||||
}, [anchorName, groupId, isVisible])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (
|
||||
!isVisible ||
|
||||
!anchorName ||
|
||||
shouldUseCssAnchorPositioning() ||
|
||||
measuredFallbackRect === null
|
||||
) {
|
||||
if (!isVisible || !anchorName || shouldUseCssAnchorPositioning()) {
|
||||
return
|
||||
}
|
||||
// Why: web fallback positioning is measured after the terminal pane resumes.
|
||||
// A restored PTY can otherwise fit at its stale pre-measure width and keep
|
||||
// TUIs such as Claude Code narrow until a later user-driven resize.
|
||||
// Why: worktree switches resume visibility before fallback positioning
|
||||
// settles. Re-fit on show and again after the measured rect lands so the
|
||||
// PTY never stays pinned at a stale ~2-col width.
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
window.dispatchEvent(new Event(SYNC_FIT_PANES_EVENT))
|
||||
})
|
||||
const retryId = window.setTimeout(() => {
|
||||
window.dispatchEvent(new Event(SYNC_FIT_PANES_EVENT))
|
||||
}, 50)
|
||||
return () => {
|
||||
cancelAnimationFrame(frameId)
|
||||
window.clearTimeout(retryId)
|
||||
}
|
||||
}, [anchorName, isVisible, measuredFallbackRect])
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ describe('closeTerminalTab', () => {
|
||||
})
|
||||
|
||||
it('delegates host-backed terminal closes to the paired runtime', () => {
|
||||
const closeTab = vi.fn()
|
||||
isWebRuntimeSessionActiveMock.mockReturnValue(true)
|
||||
resolveHostSessionTabIdForWebSessionTabMock.mockReturnValue('host-tab-1')
|
||||
getStateMock.mockReturnValue({
|
||||
@@ -116,12 +117,13 @@ describe('closeTerminalTab', () => {
|
||||
},
|
||||
activeWorktreeId: 'wt-1',
|
||||
activeTabId: 'local-tab-1',
|
||||
closeTab: vi.fn(),
|
||||
closeTab,
|
||||
setActiveTab: vi.fn()
|
||||
})
|
||||
|
||||
closeTerminalTab('local-tab-1')
|
||||
|
||||
expect(closeTab).toHaveBeenCalledWith('local-tab-1')
|
||||
expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'local-tab-1',
|
||||
@@ -129,6 +131,44 @@ describe('closeTerminalTab', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('closes unified-only terminal tabs when tabsByWorktree is missing the row', () => {
|
||||
const closeUnifiedTab = vi.fn()
|
||||
getStateMock.mockReturnValue({
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
tabsByWorktree: {},
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'unified-tab-1',
|
||||
entityId: 'terminal-entity-1',
|
||||
contentType: 'terminal',
|
||||
groupId: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
label: 'Claude',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0,
|
||||
isPreview: false,
|
||||
isPinned: false
|
||||
}
|
||||
]
|
||||
},
|
||||
activeWorktreeId: 'wt-1',
|
||||
activeTabId: 'terminal-entity-1',
|
||||
openFiles: [],
|
||||
browserTabsByWorktree: {},
|
||||
closeTab: vi.fn(),
|
||||
closeUnifiedTab,
|
||||
setActiveTab: vi.fn(),
|
||||
setActiveWorktree: vi.fn()
|
||||
})
|
||||
|
||||
closeTerminalTab('terminal-entity-1')
|
||||
|
||||
expect(closeUnifiedTab).toHaveBeenCalledWith('unified-tab-1')
|
||||
})
|
||||
|
||||
it('closes local-only agent tabs locally when they have no host session binding', () => {
|
||||
const closeTab = vi.fn()
|
||||
isWebRuntimeSessionActiveMock.mockReturnValue(true)
|
||||
|
||||
@@ -15,6 +15,55 @@ const EDITOR_TAB_CONTENT_TYPES = new Set<TabContentType>(['editor', 'diff', 'con
|
||||
|
||||
type TerminalTabActionState = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
type CloseTerminalTabTarget = {
|
||||
worktreeId: string
|
||||
terminalTabId: string
|
||||
}
|
||||
|
||||
function resolveCloseTerminalTabTarget(
|
||||
state: TerminalTabActionState,
|
||||
tabId: string
|
||||
): CloseTerminalTabTarget | null {
|
||||
for (const [worktreeId, worktreeTabs] of Object.entries(state.tabsByWorktree)) {
|
||||
if (worktreeTabs.some((tab) => tab.id === tabId)) {
|
||||
return { worktreeId, terminalTabId: tabId }
|
||||
}
|
||||
}
|
||||
|
||||
for (const [worktreeId, unifiedTabs] of Object.entries(state.unifiedTabsByWorktree ?? {})) {
|
||||
const unified = unifiedTabs.find(
|
||||
(tab) => tab.contentType === 'terminal' && (tab.entityId === tabId || tab.id === tabId)
|
||||
)
|
||||
if (unified) {
|
||||
return { worktreeId, terminalTabId: unified.entityId }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function closeLocalTerminalTabState(terminalTabId: string): void {
|
||||
const state = useAppStore.getState()
|
||||
if (
|
||||
Object.values(state.tabsByWorktree).some((tabs) => tabs.some((tab) => tab.id === terminalTabId))
|
||||
) {
|
||||
state.closeTab(terminalTabId)
|
||||
return
|
||||
}
|
||||
|
||||
for (const tabs of Object.values(state.unifiedTabsByWorktree ?? {})) {
|
||||
const unified = tabs.find(
|
||||
(tab) =>
|
||||
tab.contentType === 'terminal' &&
|
||||
(tab.entityId === terminalTabId || tab.id === terminalTabId)
|
||||
)
|
||||
if (unified) {
|
||||
state.closeUnifiedTab(unified.id)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isPinnedVisibleTab(
|
||||
state: TerminalTabActionState,
|
||||
worktreeId: string,
|
||||
@@ -71,15 +120,13 @@ export function createNewTerminalTab(
|
||||
|
||||
export function closeTerminalTab(tabId: string): void {
|
||||
const state = useAppStore.getState()
|
||||
const owningWorktreeEntry = Object.entries(state.tabsByWorktree).find(([, worktreeTabs]) =>
|
||||
worktreeTabs.some((tab) => tab.id === tabId)
|
||||
)
|
||||
const owningWorktreeId = owningWorktreeEntry?.[0] ?? null
|
||||
|
||||
if (!owningWorktreeId) {
|
||||
const target = resolveCloseTerminalTabTarget(state, tabId)
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
if (isPinnedVisibleTab(state, owningWorktreeId, tabId)) {
|
||||
const { worktreeId: owningWorktreeId, terminalTabId } = target
|
||||
|
||||
if (isPinnedVisibleTab(state, owningWorktreeId, terminalTabId)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -89,14 +136,15 @@ export function closeTerminalTab(tabId: string): void {
|
||||
resolveHostSessionTabIdForWebSessionTab(state, {
|
||||
environmentId: runtimeEnvironmentId,
|
||||
worktreeId: owningWorktreeId,
|
||||
tabId
|
||||
}) ?? (isWebTerminalSurfaceTabId(tabId) ? tabId : null)
|
||||
tabId: terminalTabId
|
||||
}) ?? (isWebTerminalSurfaceTabId(terminalTabId) ? terminalTabId : null)
|
||||
if (hostBackedTabId) {
|
||||
// Why: paired web tabs are host-owned. Closing locally leaves the host to
|
||||
// re-publish the same stale surface on the next session-tabs snapshot.
|
||||
// Why: prune local mirrors immediately so close feels responsive while the
|
||||
// host session snapshot catches up.
|
||||
closeLocalTerminalTabState(terminalTabId)
|
||||
void closeWebRuntimeSessionTab({
|
||||
worktreeId: owningWorktreeId,
|
||||
tabId,
|
||||
tabId: terminalTabId,
|
||||
environmentId: runtimeEnvironmentId
|
||||
})
|
||||
return
|
||||
@@ -107,7 +155,7 @@ export function closeTerminalTab(tabId: string): void {
|
||||
|
||||
const currentTabs = state.tabsByWorktree[owningWorktreeId] ?? []
|
||||
if (currentTabs.length <= 1) {
|
||||
state.closeTab(tabId)
|
||||
closeLocalTerminalTabState(terminalTabId)
|
||||
if (state.activeWorktreeId === owningWorktreeId) {
|
||||
// Why: only deactivate the worktree when no tabs of any kind remain.
|
||||
// Editor files are a separate tab type; closing the last terminal tab
|
||||
@@ -117,7 +165,7 @@ export function closeTerminalTab(tabId: string): void {
|
||||
state.setActiveFile(worktreeFile.id)
|
||||
state.setActiveTabType('editor')
|
||||
} else {
|
||||
const browserTab = (state.browserTabsByWorktree[owningWorktreeId] ?? [])[0]
|
||||
const browserTab = (state.browserTabsByWorktree?.[owningWorktreeId] ?? [])[0]
|
||||
if (browserTab) {
|
||||
state.setActiveBrowserTab(browserTab.id)
|
||||
state.setActiveTabType('browser')
|
||||
@@ -129,15 +177,15 @@ export function closeTerminalTab(tabId: string): void {
|
||||
return
|
||||
}
|
||||
|
||||
if (state.activeWorktreeId === owningWorktreeId && tabId === state.activeTabId) {
|
||||
const currentIndex = currentTabs.findIndex((tab) => tab.id === tabId)
|
||||
if (state.activeWorktreeId === owningWorktreeId && terminalTabId === state.activeTabId) {
|
||||
const currentIndex = currentTabs.findIndex((tab) => tab.id === terminalTabId)
|
||||
const nextTab = currentTabs[currentIndex + 1] ?? currentTabs[currentIndex - 1]
|
||||
if (nextTab) {
|
||||
state.setActiveTab(nextTab.id)
|
||||
}
|
||||
}
|
||||
|
||||
state.closeTab(tabId)
|
||||
closeLocalTerminalTabState(terminalTabId)
|
||||
}
|
||||
|
||||
export function closeOtherTerminalTabs(tabId: string, activeWorktreeId: string | null): void {
|
||||
|
||||
@@ -20,6 +20,7 @@ const store = {
|
||||
tabBarOrderByWorktree: {} as Record<string, string[]>,
|
||||
terminalLayoutsByTabId: {} as Record<string, { activeLeafId: string | null }>,
|
||||
createTab: mockCreateTab,
|
||||
closeTab: vi.fn(),
|
||||
queueTabStartupCommand: mockQueueTabStartupCommand,
|
||||
setActiveTabType: mockSetActiveTabType,
|
||||
setTabBarOrder: mockSetTabBarOrder,
|
||||
@@ -60,7 +61,8 @@ const mockIsWebRuntimeSessionActive = vi.fn(() => false)
|
||||
|
||||
vi.mock('@/runtime/web-runtime-session', () => ({
|
||||
createWebRuntimeSessionTerminal: mockCreateWebRuntimeSessionTerminal,
|
||||
isWebRuntimeSessionActive: mockIsWebRuntimeSessionActive
|
||||
isWebRuntimeSessionActive: mockIsWebRuntimeSessionActive,
|
||||
isWebTerminalSurfaceTabId: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
describe('launchAgentInNewTab', () => {
|
||||
@@ -94,6 +96,12 @@ describe('launchAgentInNewTab', () => {
|
||||
it('delegates agent quick launch to the host runtime in paired web clients', async () => {
|
||||
mockIsWebRuntimeSessionActive.mockReturnValue(true)
|
||||
store.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: 'web-runtime' }
|
||||
store.tabsByWorktree = {
|
||||
'wt-1': [
|
||||
{ id: 'tab-1' },
|
||||
{ id: 'stale-agent-tab', launchAgent: 'claude' } as { id: string; launchAgent: string }
|
||||
]
|
||||
}
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
const result = launchAgentInNewTab({
|
||||
@@ -118,6 +126,7 @@ describe('launchAgentInNewTab', () => {
|
||||
expect(mockCreateTab).not.toHaveBeenCalled()
|
||||
expect(mockQueueTabStartupCommand).not.toHaveBeenCalled()
|
||||
expect(mockSetActiveTabType).toHaveBeenCalledWith('terminal')
|
||||
expect(store.closeTab).toHaveBeenCalledWith('stale-agent-tab')
|
||||
})
|
||||
|
||||
it('queues initial working status for Command Code argv prompt launches', async () => {
|
||||
|
||||
@@ -12,7 +12,8 @@ import { track, tuiAgentToAgentKind } from '@/lib/telemetry'
|
||||
import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft'
|
||||
import {
|
||||
createWebRuntimeSessionTerminal,
|
||||
isWebRuntimeSessionActive
|
||||
isWebRuntimeSessionActive,
|
||||
isWebTerminalSurfaceTabId
|
||||
} from '@/runtime/web-runtime-session'
|
||||
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
|
||||
import { makePaneKey } from '../../../shared/stable-pane-id'
|
||||
@@ -43,6 +44,15 @@ export type LaunchAgentInNewTabArgs = {
|
||||
onPromptDelivered?: () => void
|
||||
}
|
||||
|
||||
function removeStaleLocalAgentTabsForWebHostLaunch(worktreeId: string): void {
|
||||
const state = useAppStore.getState()
|
||||
for (const tab of state.tabsByWorktree[worktreeId] ?? []) {
|
||||
if (tab.launchAgent && !isWebTerminalSurfaceTabId(tab.id)) {
|
||||
state.closeTab(tab.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type LaunchAgentInNewTabResult = {
|
||||
tabId: string | null
|
||||
startupPlan: AgentStartupPlan
|
||||
@@ -197,12 +207,15 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
|
||||
if (isWebRuntimeSessionActive(runtimeEnvironmentId) && pasteDraftAfterLaunch === null) {
|
||||
// Why: paired web tabs are host-owned. Local-only agent tabs cannot be
|
||||
// closed because close routes through session.tabs.close on the host.
|
||||
removeStaleLocalAgentTabsForWebHostLaunch(worktreeId)
|
||||
void createWebRuntimeSessionTerminal({
|
||||
worktreeId,
|
||||
environmentId: runtimeEnvironmentId,
|
||||
targetGroupId: groupId,
|
||||
activate: true,
|
||||
...(hasPrompt ? { command: startupPlan.launchCommand } : { agent })
|
||||
}).then(() => {
|
||||
removeStaleLocalAgentTabsForWebHostLaunch(worktreeId)
|
||||
})
|
||||
store.setActiveTabType('terminal')
|
||||
if (hasPrompt) {
|
||||
|
||||
@@ -28,13 +28,17 @@ function createPane({
|
||||
proposedRows,
|
||||
terminalCols,
|
||||
terminalRows,
|
||||
paneId = 1
|
||||
paneId = 1,
|
||||
containerWidth = 800,
|
||||
containerHeight = 400
|
||||
}: {
|
||||
proposedCols: number
|
||||
proposedRows: number
|
||||
terminalCols: number
|
||||
terminalRows: number
|
||||
paneId?: number
|
||||
containerWidth?: number
|
||||
containerHeight?: number
|
||||
}): ManagedPaneInternal {
|
||||
const leafId = '11111111-1111-4111-8111-111111111111' as never
|
||||
const fit = vi.fn()
|
||||
@@ -73,7 +77,18 @@ function createPane({
|
||||
leafId,
|
||||
stablePaneId: leafId,
|
||||
terminal: terminal as never,
|
||||
container: { dataset: {} } as never,
|
||||
container: {
|
||||
dataset: {},
|
||||
getBoundingClientRect: () =>
|
||||
({
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: containerWidth,
|
||||
bottom: containerHeight
|
||||
}) as DOMRect
|
||||
} as never,
|
||||
xtermContainer: {} as never,
|
||||
linkTooltip: {} as never,
|
||||
terminalGpuAcceleration: 'auto',
|
||||
@@ -133,6 +148,23 @@ describe('safeFit', () => {
|
||||
expect(activeBuffer.viewportY).toBe(42)
|
||||
})
|
||||
|
||||
it('skips refits while the pane container is still near-zero width', () => {
|
||||
const pane = createPane({
|
||||
proposedCols: 2,
|
||||
proposedRows: 24,
|
||||
terminalCols: 120,
|
||||
terminalRows: 32,
|
||||
containerWidth: 8,
|
||||
containerHeight: 400
|
||||
})
|
||||
|
||||
safeFit(pane)
|
||||
|
||||
expect(pane.fitAddon.fit).not.toHaveBeenCalled()
|
||||
expect(pane.terminal.resize).not.toHaveBeenCalled()
|
||||
expect(pane.terminal.cols).toBe(120)
|
||||
})
|
||||
|
||||
it('still refits when the proposed grid dimensions changed', () => {
|
||||
const pane = createPane({
|
||||
proposedCols: 100,
|
||||
|
||||
@@ -27,6 +27,11 @@ type TreeOpsCallbacks = {
|
||||
requestPaneReparentFrame?: (callback: FrameRequestCallback) => void
|
||||
}
|
||||
|
||||
const MIN_PANE_FIT_WIDTH_PX = 48
|
||||
const MIN_PANE_FIT_HEIGHT_PX = 24
|
||||
const MIN_PANE_FIT_COLS = 8
|
||||
const MIN_PANE_FIT_ROWS = 4
|
||||
|
||||
function getProposedDimensions(pane: ManagedPane): { cols: number; rows: number } | null {
|
||||
try {
|
||||
return pane.fitAddon.proposeDimensions() ?? null
|
||||
@@ -35,6 +40,24 @@ function getProposedDimensions(pane: ManagedPane): { cols: number; rows: number
|
||||
}
|
||||
}
|
||||
|
||||
function canMeasurePaneForFit(pane: ManagedPane): boolean {
|
||||
const measure = pane.container.getBoundingClientRect
|
||||
if (typeof measure === 'function') {
|
||||
const rect = measure.call(pane.container)
|
||||
if (rect.width < MIN_PANE_FIT_WIDTH_PX || rect.height < MIN_PANE_FIT_HEIGHT_PX) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const dims = getProposedDimensions(pane)
|
||||
if (!dims) {
|
||||
return false
|
||||
}
|
||||
// Why: worktree switches can briefly measure a near-zero overlay before
|
||||
// fallback positioning lands. Fitting there pins the PTY at ~2 cols until
|
||||
// the next user-driven resize.
|
||||
return dims.cols >= MIN_PANE_FIT_COLS && dims.rows >= MIN_PANE_FIT_ROWS
|
||||
}
|
||||
|
||||
function captureScrollStateForFit(pane: ManagedPane): ScrollState | null {
|
||||
// Why: split reparent has its own delayed restore; restoring here can fight that timer.
|
||||
return 'pendingSplitScrollState' in pane && (pane as ManagedPaneInternal).pendingSplitScrollState
|
||||
@@ -43,6 +66,9 @@ function captureScrollStateForFit(pane: ManagedPane): ScrollState | null {
|
||||
}
|
||||
|
||||
export function safeFit(pane: ManagedPane): void {
|
||||
if (!canMeasurePaneForFit(pane)) {
|
||||
return
|
||||
}
|
||||
let scrollState: ScrollState | null = null
|
||||
let shouldRestoreScroll = false
|
||||
try {
|
||||
|
||||
@@ -6,12 +6,16 @@ import {
|
||||
activateAndRevealWorktree,
|
||||
ensureWebRuntimeWorktreeTerminalAfterWake
|
||||
} from './worktree-activation'
|
||||
import { resetWebSessionTabsSnapshotFreshnessForTests } from '@/runtime/web-session-tabs-sync'
|
||||
import { resetWebRuntimeWakeTerminalRespawnForTests } from '@/runtime/web-runtime-wake-terminal-respawn'
|
||||
|
||||
const initialAppStoreState = useAppStore.getState()
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__
|
||||
vi.unstubAllGlobals()
|
||||
resetWebSessionTabsSnapshotFreshnessForTests()
|
||||
resetWebRuntimeWakeTerminalRespawnForTests()
|
||||
useAppStore.setState(initialAppStoreState, true)
|
||||
})
|
||||
|
||||
@@ -384,6 +388,78 @@ describe('activateAndRevealWorktree created agent reopen', () => {
|
||||
expect(callRuntimeEnvironment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not respawn when the host snapshot still has terminal tabs', async () => {
|
||||
const worktree = makeWorktree()
|
||||
const callRuntimeEnvironment = vi.fn()
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
|
||||
useAppStore.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { 'repo-1': [worktree] },
|
||||
tabsByWorktree: {
|
||||
[worktree.id]: [
|
||||
{
|
||||
id: 'tab-1',
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: worktree.id,
|
||||
title: 'Terminal 1',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': [] },
|
||||
settings: {
|
||||
...getDefaultSettings('/workspace/.orca-workspaces'),
|
||||
activeRuntimeEnvironmentId: 'web-runtime-1'
|
||||
},
|
||||
reconcileWorktreeTabModel: vi.fn(() => ({
|
||||
renderableTabCount: 1,
|
||||
activeRenderableTabId: 'tab-1'
|
||||
}))
|
||||
})
|
||||
|
||||
const { shouldApplyWebSessionTabsSnapshot } = await import('@/runtime/web-session-tabs-sync')
|
||||
shouldApplyWebSessionTabsSnapshot(
|
||||
{
|
||||
worktree: worktree.id,
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: 'host-tab-1',
|
||||
activeTabType: 'terminal',
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'host-tab-1::leaf',
|
||||
title: 'Terminal',
|
||||
parentTabId: 'host-tab-1',
|
||||
leafId: '11111111-1111-4111-8111-111111111111',
|
||||
isActive: true,
|
||||
status: 'ready',
|
||||
terminal: 'term_host'
|
||||
}
|
||||
]
|
||||
},
|
||||
'web-runtime-1'
|
||||
)
|
||||
|
||||
ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(callRuntimeEnvironment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('respawns a host terminal when waking a slept web workspace with dead local PTYs', async () => {
|
||||
const worktree = makeWorktree()
|
||||
const callRuntimeEnvironment = vi
|
||||
|
||||
@@ -19,8 +19,14 @@ import { tabHasLivePty } from '@/lib/tab-has-live-pty'
|
||||
import {
|
||||
activateWebRuntimeSessionWorktree,
|
||||
createWebRuntimeSessionTerminal,
|
||||
isWebRuntimeSessionActive
|
||||
isWebRuntimeSessionActive,
|
||||
isWebTerminalSurfaceTabId
|
||||
} from '@/runtime/web-runtime-session'
|
||||
import { getLastKnownHostTerminalTabCount } from '@/runtime/web-session-tabs-sync'
|
||||
import {
|
||||
beginWebRuntimeWakeTerminalRespawn,
|
||||
endWebRuntimeWakeTerminalRespawn
|
||||
} from '@/runtime/web-runtime-wake-terminal-respawn'
|
||||
import {
|
||||
setWorktreeNavActivator,
|
||||
setWorktreeNavViewActivator
|
||||
@@ -257,11 +263,26 @@ export function ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId: string): v
|
||||
return
|
||||
}
|
||||
|
||||
const hasMirroredHostTabs = tabs.some((tab) => isWebTerminalSurfaceTabId(tab.id))
|
||||
if (hasMirroredHostTabs) {
|
||||
// Why: the host session still owns these tabs — wait for the mirror to
|
||||
// repopulate PTY handles instead of creating a duplicate terminal.
|
||||
return
|
||||
}
|
||||
|
||||
if (getLastKnownHostTerminalTabCount(runtimeEnvironmentId, worktreeId) > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const { renderableTabCount } = state.reconcileWorktreeTabModel(worktreeId)
|
||||
if (renderableTabCount === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!beginWebRuntimeWakeTerminalRespawn(worktreeId)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: sleep keeps local tab rows but terminal.stop clears host PTYs, so
|
||||
// activation alone can leave a woke workspace with tab chrome but no surface.
|
||||
void createWebRuntimeSessionTerminal({
|
||||
@@ -269,6 +290,8 @@ export function ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId: string): v
|
||||
environmentId: runtimeEnvironmentId,
|
||||
activate: true,
|
||||
selectWorktree: false
|
||||
}).finally(() => {
|
||||
endWebRuntimeWakeTerminalRespawn(worktreeId)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -726,11 +726,23 @@ describe('web runtime session tab actions', () => {
|
||||
})
|
||||
|
||||
it('maps mirrored local browser unified ids for activate and close', async () => {
|
||||
const runtimeCall = vi.fn().mockResolvedValue({
|
||||
id: 'action',
|
||||
ok: true,
|
||||
result: {}
|
||||
})
|
||||
const runtimeCall = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: 'activate',
|
||||
ok: true,
|
||||
result: {}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'close',
|
||||
ok: true,
|
||||
result: {}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'list',
|
||||
ok: true,
|
||||
result: makeSnapshot()
|
||||
})
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
@@ -771,6 +783,15 @@ describe('web runtime session tab actions', () => {
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(runtimeCall).toHaveBeenNthCalledWith(3, {
|
||||
selector: ENVIRONMENT_ID,
|
||||
method: 'session.tabs.list',
|
||||
params: {
|
||||
worktree: `id:${WORKTREE_ID}`
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(mocks.applyFreshWebSessionTabsSnapshot).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -424,6 +424,9 @@ async function callWebRuntimeSessionTabMethod(
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
unwrapRuntimeRpcResult(response as RuntimeRpcResponse<unknown>)
|
||||
if (method === 'session.tabs.close') {
|
||||
await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
beginWebRuntimeWakeTerminalRespawn,
|
||||
endWebRuntimeWakeTerminalRespawn,
|
||||
resetWebRuntimeWakeTerminalRespawnForTests,
|
||||
shouldSkipWebRuntimeWakeTerminalRespawn
|
||||
} from './web-runtime-wake-terminal-respawn'
|
||||
|
||||
describe('web-runtime-wake-terminal-respawn', () => {
|
||||
beforeEach(() => {
|
||||
resetWebRuntimeWakeTerminalRespawnForTests()
|
||||
})
|
||||
|
||||
it('dedupes wake respawn requests for the same worktree', () => {
|
||||
expect(beginWebRuntimeWakeTerminalRespawn('wt-1')).toBe(true)
|
||||
expect(shouldSkipWebRuntimeWakeTerminalRespawn('wt-1')).toBe(true)
|
||||
expect(beginWebRuntimeWakeTerminalRespawn('wt-1')).toBe(false)
|
||||
endWebRuntimeWakeTerminalRespawn('wt-1')
|
||||
expect(shouldSkipWebRuntimeWakeTerminalRespawn('wt-1')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
const wakeTerminalRespawnRequestedByWorktree = new Set<string>()
|
||||
const wakeTerminalRespawnInFlightByWorktree = new Set<string>()
|
||||
|
||||
export function shouldSkipWebRuntimeWakeTerminalRespawn(worktreeId: string): boolean {
|
||||
return (
|
||||
wakeTerminalRespawnRequestedByWorktree.has(worktreeId) ||
|
||||
wakeTerminalRespawnInFlightByWorktree.has(worktreeId)
|
||||
)
|
||||
}
|
||||
|
||||
export function beginWebRuntimeWakeTerminalRespawn(worktreeId: string): boolean {
|
||||
if (shouldSkipWebRuntimeWakeTerminalRespawn(worktreeId)) {
|
||||
return false
|
||||
}
|
||||
wakeTerminalRespawnRequestedByWorktree.add(worktreeId)
|
||||
wakeTerminalRespawnInFlightByWorktree.add(worktreeId)
|
||||
return true
|
||||
}
|
||||
|
||||
export function endWebRuntimeWakeTerminalRespawn(worktreeId: string): void {
|
||||
wakeTerminalRespawnInFlightByWorktree.delete(worktreeId)
|
||||
}
|
||||
|
||||
export function resetWebRuntimeWakeTerminalRespawnForTests(): void {
|
||||
wakeTerminalRespawnRequestedByWorktree.clear()
|
||||
wakeTerminalRespawnInFlightByWorktree.clear()
|
||||
}
|
||||
@@ -152,6 +152,26 @@ describe('applyWebSessionTabsSnapshot', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not respawn after wake when activation already requested a respawn', () => {
|
||||
const freshEmpty = makeSnapshot([], {
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null
|
||||
})
|
||||
|
||||
expect(
|
||||
shouldRespawnWebRuntimeTerminalAfterWake({
|
||||
event: { type: 'snapshot', ...freshEmpty },
|
||||
activeWorktreeId: WT,
|
||||
requestedRespawnAfterWake: false,
|
||||
snapshotIsFresh: true,
|
||||
localTerminalCount: 1,
|
||||
hasLiveLocalPty: false,
|
||||
skipWakeRespawn: true
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('respawns a terminal after wake when local slept tabs exist but the host snapshot is empty', () => {
|
||||
const freshEmpty = makeSnapshot([], {
|
||||
activeGroupId: null,
|
||||
@@ -349,6 +369,76 @@ describe('applyWebSessionTabsSnapshot', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces stale local agent quick-launch tabs once host mirrors arrive', () => {
|
||||
const staleLocalAgentTab: TerminalTab = {
|
||||
id: 'local-agent-tab',
|
||||
ptyId: null,
|
||||
worktreeId: WT,
|
||||
title: 'Claude',
|
||||
defaultTitle: 'Claude',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: NOW,
|
||||
launchAgent: 'claude'
|
||||
}
|
||||
const staleUnifiedTab: Tab = {
|
||||
id: 'local-agent-tab',
|
||||
entityId: 'local-agent-tab',
|
||||
groupId: 'group-1',
|
||||
worktreeId: WT,
|
||||
contentType: 'terminal',
|
||||
label: 'Claude',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: NOW,
|
||||
isPreview: false,
|
||||
isPinned: false
|
||||
}
|
||||
|
||||
const patch = applyWebSessionTabsSnapshot(
|
||||
makeState({
|
||||
tabsByWorktree: { [WT]: [staleLocalAgentTab] },
|
||||
unifiedTabsByWorktree: { [WT]: [staleUnifiedTab] },
|
||||
groupsByWorktree: {
|
||||
[WT]: [
|
||||
{
|
||||
id: 'group-1',
|
||||
worktreeId: WT,
|
||||
activeTabId: 'local-agent-tab',
|
||||
tabOrder: ['local-agent-tab']
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
makeSnapshot([
|
||||
{
|
||||
type: 'terminal',
|
||||
id: HOST_SURFACE_ID,
|
||||
title: 'Claude',
|
||||
parentTabId: 'host-tab-1',
|
||||
leafId: LEAF_ID,
|
||||
isActive: true,
|
||||
launchAgent: 'claude',
|
||||
status: 'ready',
|
||||
terminal: 'terminal-1'
|
||||
}
|
||||
]),
|
||||
ENV,
|
||||
NOW
|
||||
) as Partial<WebSessionTabsSyncState>
|
||||
|
||||
const mirroredId = patch.tabsByWorktree?.[WT]?.[0]?.id
|
||||
expect(mirroredId).toBeTruthy()
|
||||
expect(patch.tabsByWorktree?.[WT]).toHaveLength(1)
|
||||
expect(patch.tabsByWorktree?.[WT]?.[0]?.id).not.toBe('local-agent-tab')
|
||||
expect(patch.unifiedTabsByWorktree?.[WT]?.some((tab) => tab.id === 'local-agent-tab')).toBe(
|
||||
false
|
||||
)
|
||||
expect(patch.groupsByWorktree?.[WT]?.[0]?.tabOrder).toEqual([mirroredId])
|
||||
})
|
||||
|
||||
it('hydrates ready host terminal surfaces as remote runtime terminal tabs', () => {
|
||||
const patch = applyWebSessionTabsSnapshot(
|
||||
makeState(),
|
||||
|
||||
@@ -39,6 +39,11 @@ import {
|
||||
WEB_TERMINAL_SURFACE_TAB_PREFIX
|
||||
} from './web-runtime-session'
|
||||
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
|
||||
import {
|
||||
beginWebRuntimeWakeTerminalRespawn,
|
||||
endWebRuntimeWakeTerminalRespawn,
|
||||
shouldSkipWebRuntimeWakeTerminalRespawn
|
||||
} from './web-runtime-wake-terminal-respawn'
|
||||
|
||||
const WEB_SESSION_GROUP_PREFIX = 'web-session-tabs:'
|
||||
|
||||
@@ -57,6 +62,7 @@ type SnapshotFreshness = {
|
||||
}
|
||||
|
||||
const latestSessionTabsSnapshotByWorktree = new Map<string, SnapshotFreshness>()
|
||||
const lastHostTerminalTabCountByWorktree = new Map<string, number>()
|
||||
const hostSessionTabIdByLocalKey = new Map<string, string>()
|
||||
|
||||
type TerminalSurface = RuntimeMobileSessionTerminalClientTab
|
||||
@@ -130,6 +136,24 @@ function sessionTabsFreshnessKey(environmentId: string, worktreeId: string): str
|
||||
return `${environmentId}:${worktreeId}`
|
||||
}
|
||||
|
||||
function rememberHostTerminalTabCount(
|
||||
environmentId: string,
|
||||
snapshot: RuntimeMobileSessionTabsResult
|
||||
): void {
|
||||
const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree)
|
||||
const terminalCount = snapshot.tabs.filter((tab) => tab.type === 'terminal').length
|
||||
lastHostTerminalTabCountByWorktree.set(key, terminalCount)
|
||||
}
|
||||
|
||||
export function getLastKnownHostTerminalTabCount(
|
||||
environmentId: string,
|
||||
worktreeId: string
|
||||
): number {
|
||||
return (
|
||||
lastHostTerminalTabCountByWorktree.get(sessionTabsFreshnessKey(environmentId, worktreeId)) ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldApplyWebSessionTabsSnapshot(
|
||||
snapshot: RuntimeMobileSessionTabsResult,
|
||||
environmentId: string
|
||||
@@ -142,6 +166,7 @@ export function shouldApplyWebSessionTabsSnapshot(
|
||||
clearWebSessionTabsTrackingForWorktree(environmentId, snapshot.worktree)
|
||||
return true
|
||||
}
|
||||
rememberHostTerminalTabCount(environmentId, snapshot)
|
||||
const current = latestSessionTabsSnapshotByWorktree.get(key)
|
||||
if (
|
||||
current &&
|
||||
@@ -181,10 +206,12 @@ export function shouldRespawnWebRuntimeTerminalAfterWake(args: {
|
||||
snapshotIsFresh: boolean
|
||||
localTerminalCount: number
|
||||
hasLiveLocalPty: boolean
|
||||
skipWakeRespawn?: boolean
|
||||
}): boolean {
|
||||
if (
|
||||
!args.snapshotIsFresh ||
|
||||
args.requestedRespawnAfterWake ||
|
||||
args.skipWakeRespawn === true ||
|
||||
args.localTerminalCount === 0 ||
|
||||
args.hasLiveLocalPty ||
|
||||
(args.event.type !== 'snapshot' && args.event.type !== 'updated')
|
||||
@@ -200,6 +227,7 @@ export function shouldRespawnWebRuntimeTerminalAfterWake(args: {
|
||||
|
||||
export function resetWebSessionTabsSnapshotFreshnessForTests(): void {
|
||||
latestSessionTabsSnapshotByWorktree.clear()
|
||||
lastHostTerminalTabCountByWorktree.clear()
|
||||
hostSessionTabIdByLocalKey.clear()
|
||||
}
|
||||
|
||||
@@ -214,7 +242,9 @@ export function _getWebSessionTabsTrackingCountsForTest(): {
|
||||
}
|
||||
|
||||
function clearWebSessionTabsTrackingForWorktree(environmentId: string, worktreeId: string): void {
|
||||
latestSessionTabsSnapshotByWorktree.delete(sessionTabsFreshnessKey(environmentId, worktreeId))
|
||||
const key = sessionTabsFreshnessKey(environmentId, worktreeId)
|
||||
latestSessionTabsSnapshotByWorktree.delete(key)
|
||||
lastHostTerminalTabCountByWorktree.delete(key)
|
||||
const keyPrefix = `${environmentId}:${worktreeId}:`
|
||||
for (const key of hostSessionTabIdByLocalKey.keys()) {
|
||||
if (key.startsWith(keyPrefix)) {
|
||||
@@ -234,6 +264,11 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string)
|
||||
latestSessionTabsSnapshotByWorktree.delete(key)
|
||||
}
|
||||
}
|
||||
for (const key of lastHostTerminalTabCountByWorktree.keys()) {
|
||||
if (key.startsWith(keyPrefix)) {
|
||||
lastHostTerminalTabCountByWorktree.delete(key)
|
||||
}
|
||||
}
|
||||
for (const key of hostSessionTabIdByLocalKey.keys()) {
|
||||
if (key.startsWith(keyPrefix)) {
|
||||
hostSessionTabIdByLocalKey.delete(key)
|
||||
@@ -386,6 +421,11 @@ function shouldReplaceTerminalTab(
|
||||
nextRemotePtyIds: ReadonlySet<string>,
|
||||
nextMirroredTerminalIds: ReadonlySet<string>
|
||||
): boolean {
|
||||
if (tab.launchAgent && !isMirroredTerminalSurfaceId(tab.id) && nextMirroredTerminalIds.size > 0) {
|
||||
// Why: paired web agent quick-launch used to create local-only tabs before
|
||||
// the host snapshot landed. Once host mirrors exist, retire the stale row.
|
||||
return true
|
||||
}
|
||||
if (isMirroredTerminalSurfaceId(tab.id)) {
|
||||
// Why: host session snapshots are authoritative for host-mirrored tabs.
|
||||
// Replace old mirrors even when the next surface is still waiting on a
|
||||
@@ -2239,7 +2279,8 @@ export function useWebSessionTabsSync(): void {
|
||||
requestedRespawnAfterWake,
|
||||
snapshotIsFresh: fresh,
|
||||
localTerminalCount,
|
||||
hasLiveLocalPty
|
||||
hasLiveLocalPty,
|
||||
skipWakeRespawn: shouldSkipWebRuntimeWakeTerminalRespawn(activeWorktreeId)
|
||||
})
|
||||
if (fresh) {
|
||||
useAppStore.setState((state) =>
|
||||
@@ -2253,13 +2294,19 @@ export function useWebSessionTabsSync(): void {
|
||||
environmentId,
|
||||
activate: true
|
||||
})
|
||||
} else if (!disposed && shouldRespawnAfterWake) {
|
||||
} else if (
|
||||
!disposed &&
|
||||
shouldRespawnAfterWake &&
|
||||
beginWebRuntimeWakeTerminalRespawn(activeWorktreeId)
|
||||
) {
|
||||
requestedRespawnAfterWake = true
|
||||
void createWebRuntimeSessionTerminal({
|
||||
worktreeId: activeWorktreeId,
|
||||
environmentId,
|
||||
activate: true,
|
||||
selectWorktree: false
|
||||
}).finally(() => {
|
||||
endWebRuntimeWakeTerminalRespawn(activeWorktreeId)
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user