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:
Jinwoo-H
2026-06-07 13:40:27 -07:00
co-authored by Cursor
parent a7eaba4bbf
commit a6b3b603b2
18 changed files with 542 additions and 46 deletions
@@ -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
+24 -1
View File
@@ -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)
})
}
},