diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index c1b38c67775..89fd74f71f6 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -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) }} diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index 91503aa2cd8..35f70b30df6 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -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 diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 8ae1adfe552..012c8b11931 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -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) } } diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index 50af6b21ef0..5bf9a8d7b7a 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -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]) diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts index 34b0caea979..23c5351a12c 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts @@ -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) diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts index f74b6626dda..14b5830323c 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts @@ -15,6 +15,55 @@ const EDITOR_TAB_CONTENT_TYPES = new Set(['editor', 'diff', 'con type TerminalTabActionState = ReturnType +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 { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index bebe6c714e8..a42a900a662 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -20,6 +20,7 @@ const store = { tabBarOrderByWorktree: {} as Record, terminalLayoutsByTabId: {} as Record, 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 () => { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 9e48eceef0e..ea925ec215c 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -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) { diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts index dfb84f1b530..c5951458020 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts @@ -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, diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts index 7ffefb130db..2aed9fcaac5 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -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 { diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts index 3e723801aa8..541c6d7af4b 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent.test.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts @@ -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 diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 2bba0b482b2..bfbd370f335 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -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) }) } diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index 6e8bba6986e..e25a157bbbc 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -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() }) }) diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index 30e499b2edf..6661303a3e5 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -424,6 +424,9 @@ async function callWebRuntimeSessionTabMethod( timeoutMs: 15_000 }) unwrapRuntimeRpcResult(response as RuntimeRpcResponse) + if (method === 'session.tabs.close') { + await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId) + } return true } catch (error) { console.warn( diff --git a/src/renderer/src/runtime/web-runtime-wake-terminal-respawn.test.ts b/src/renderer/src/runtime/web-runtime-wake-terminal-respawn.test.ts new file mode 100644 index 00000000000..bd17392a607 --- /dev/null +++ b/src/renderer/src/runtime/web-runtime-wake-terminal-respawn.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/runtime/web-runtime-wake-terminal-respawn.ts b/src/renderer/src/runtime/web-runtime-wake-terminal-respawn.ts new file mode 100644 index 00000000000..55a419d12d5 --- /dev/null +++ b/src/renderer/src/runtime/web-runtime-wake-terminal-respawn.ts @@ -0,0 +1,27 @@ +const wakeTerminalRespawnRequestedByWorktree = new Set() +const wakeTerminalRespawnInFlightByWorktree = new Set() + +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() +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index 3f6994394c8..494137333e2 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -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 + + 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(), diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 3840ee805e9..4d570bfbaab 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -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() +const lastHostTerminalTabCountByWorktree = new Map() const hostSessionTabIdByLocalKey = new Map() 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, nextMirroredTerminalIds: ReadonlySet ): 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) }) } },