From 96cd21bbc4e18de362decccd5eaec78c0bad176f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:19:22 -0700 Subject: [PATCH] Keep remote agent status in sync after completion and approvals (#34729) * fix(remote): reconcile stale paired agent status and pane titles Adapt the remote completion repair from #25205 while preserving sparse runtime pane slots through existing PTY bindings. Co-authored-by: Manan Chataut * fix(remote): preserve pane identity when reconciling moved titles Use mounted manager identities and retained PTY bindings ahead of replay order; keep fresh byte-stream ownership on the correct moved pane. * fix(remote): release byte status fence after host approval * fix(remote): tighten paired title removal and test the timing fence - Share the byte-write timing decision (resolveRendererOwnedAgentStatusTiming) between pty-connection and the flap test so reverting it fails a test. - Only delete a saved pane title when its durable leaf is absent from both the host layout and local panes; unresolved or surface-less slots are kept. - Share the client-fence predicate (clientAgentStatusFencesHost) between status and title reconciliation. - Cover removed-leaf spinner fallback in worktree status and a client split absent from a pre-split host snapshot. * fix(remote): correct fence predicate and title test types --------- Co-authored-by: Manan Chataut --- .../pane-host-capability-flags.ts | 10 +- .../renderer-owned-agent-status-registry.ts | 11 + .../lib/worktree-status-sparse-pane.test.ts | 43 ++ src/renderer/src/lib/worktree-status.ts | 17 +- ...on-tabs-sync-completion-on-reveal.test.tsx | 148 ++++++ ...tabs-sync-remote-status-title-flap.test.ts | 120 ++++- ...eb-session-tabs-sync-stale-spinner.test.ts | 434 ++++++++++++++++++ .../agent-status-patch.ts | 13 +- .../agent-status-primitives.ts | 21 +- .../apply-final-patch.ts | 12 + .../runtime-pane-title-patch.ts | 138 ++++++ .../runtime/web-session-tabs-sync/state.ts | 2 + src/shared/runtime-pane-title-leaf-id.test.ts | 14 + src/shared/runtime-pane-title-leaf-id.ts | 8 + 14 files changed, 975 insertions(+), 16 deletions(-) create mode 100644 src/renderer/src/lib/worktree-status-sparse-pane.test.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync-completion-on-reveal.test.tsx create mode 100644 src/renderer/src/runtime/web-session-tabs-sync-stale-spinner.test.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/runtime-pane-title-patch.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-host-capability-flags.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-host-capability-flags.ts index e74823cfdc7..9e0d66fcb35 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-host-capability-flags.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-host-capability-flags.ts @@ -26,7 +26,8 @@ import { isMainTerminalSideEffectAuthorityForPty } from '../terminal-side-effect import { isRendererHiddenPtyDeliveryGateEnabled } from '../terminal-hidden-delivery-gate' import { markRendererOwnedAgentStatusWrite, - registerRendererOwnedAgentStatusPane + registerRendererOwnedAgentStatusPane, + resolveRendererOwnedAgentStatusTiming } from '../renderer-owned-agent-status-registry' import { resolveLatestAgentDoneStartedAt } from './agent-done-started-at' @@ -138,12 +139,15 @@ export function installPaneHostCapabilityFlags(session: ConnectPanePtySession): : resolvedStatusTitle // Only actual byte-derived status proves authority to fence out host mirroring. markRendererOwnedAgentStatusWrite(session.cacheKey) + const timing = resolveRendererOwnedAgentStatusTiming( + currentState.agentStatusByPaneKey[session.cacheKey] + ) if (session.launchToken) { currentState.setAgentStatus( session.cacheKey, observedStatusPayload, statusTitle, - undefined, + timing, routing, { launchToken: session.launchToken @@ -154,7 +158,7 @@ export function installPaneHostCapabilityFlags(session: ConnectPanePtySession): session.cacheKey, observedStatusPayload, statusTitle, - undefined, + timing, routing ) } diff --git a/src/renderer/src/components/terminal-pane/renderer-owned-agent-status-registry.ts b/src/renderer/src/components/terminal-pane/renderer-owned-agent-status-registry.ts index c03ca865636..73e99001d67 100644 --- a/src/renderer/src/components/terminal-pane/renderer-owned-agent-status-registry.ts +++ b/src/renderer/src/components/terminal-pane/renderer-owned-agent-status-registry.ts @@ -1,3 +1,5 @@ +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' + /** Fence host mirroring only after renderer byte-derived status proves its claim; otherwise two writers flap the same pane row between working/generated titles and host defaults. */ type RendererOwnedAgentStatusPane = { environmentId: string @@ -39,6 +41,15 @@ export function isClientAuthoritativeAgentStatusPane(paneKey: string): boolean { return panesByPaneKey.get(paneKey)?.hasClientWrite === true } +/** A byte write replacing an adopted host row cannot be ordered against the host's clock. */ +export function resolveRendererOwnedAgentStatusTiming( + existing: Pick | undefined +): { allowOlderTimestamp: true } | undefined { + return existing?.mirroredEvidenceReceivedAt !== undefined + ? { allowOlderTimestamp: true } + : undefined +} + export function _getRendererOwnedAgentStatusPaneCountForTest(): number { return panesByPaneKey.size } diff --git a/src/renderer/src/lib/worktree-status-sparse-pane.test.ts b/src/renderer/src/lib/worktree-status-sparse-pane.test.ts new file mode 100644 index 00000000000..71c6816fb57 --- /dev/null +++ b/src/renderer/src/lib/worktree-status-sparse-pane.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { getWorktreeStatus } from './worktree-status' + +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_C = '33333333-3333-4333-8333-333333333333' + +describe('live numeric slots after closing an earlier split pane', () => { + it('keeps a working background pane whose runtime id exceeds the remaining leaf count', () => { + expect( + getWorktreeStatus( + [{ id: 'tab', title: 'zsh', launchAgent: 'codex' }], + [], + { tab: ['pty-a', 'pty-c'] }, + { tab: { 1: 'zsh', 3: '⠋ Codex' } }, + { + terminalLayoutRootsByTabId: { + tab: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEAF_A }, + second: { type: 'leaf', leafId: LEAF_C } + } + } + } + ) + ).toBe('working') + }) +}) + +describe('pane titles from removed panes', () => { + it('ignores a removed durable leaf spinner and falls back to the idle tab title', () => { + const removedLeafTitles = { [LEAF_C]: '⠋ Codex' } + expect( + getWorktreeStatus( + [{ id: 'tab', title: 'zsh', launchAgent: 'codex' }], + [], + { tab: ['pty-a'] }, + { tab: removedLeafTitles }, + { terminalLayoutRootsByTabId: { tab: { type: 'leaf', leafId: LEAF_A } } } + ) + ).not.toBe('working') + }) +}) diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index f205cddc4d9..fae24e07b11 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -1,7 +1,11 @@ import { resolveAgentTypeFromTerminalTitle } from '@/components/sidebar/worktree-title-derived-agent-rows' import { classifyTitleActivity } from '@/lib/pane-agent-evidence' import { tabHasLivePty } from '../../../shared/tab-has-live-pty' -import { resolveRuntimePaneTitleLeafIdFromRoot } from '../../../shared/runtime-pane-title-leaf-id' +import { + collectRuntimePaneLeafIds, + isRuntimePaneTitleOrphan, + resolveRuntimePaneTitleLeafIdFromRoot +} from '../../../shared/runtime-pane-title-leaf-id' import { containsAgentSpinnerGlyph } from '../../../shared/agent-title-core' import { isSyntheticAgentPermissionTitle } from '../../../shared/synthetic-agent-title' import type { @@ -81,12 +85,19 @@ function tabHasStatus( const tabLayoutRoot = options.terminalLayoutRootsByTabId?.[tab.id] ?? options.terminalLayoutsByTabId?.[tab.id]?.root const paneTitleEntries = Object.entries(paneTitles) + const layoutLeafIds = tabLayoutRoot ? new Set(collectRuntimePaneLeafIds(tabLayoutRoot)) : null + let hasCurrentPaneTitle = false for (const [runtimePaneId, title] of paneTitleEntries) { const agentStatusPaneIds = status === 'permission' && isSyntheticAgentPermissionTitle(title) ? permissionPaneIds : freshPaneIds const leafId = resolveRuntimePaneTitleLeafIdFromRoot(tabLayoutRoot, runtimePaneId) + // A removed pane's last title is no longer evidence for this tab. + if (layoutLeafIds && isRuntimePaneTitleOrphan(runtimePaneId, layoutLeafIds)) { + continue + } + hasCurrentPaneTitle = true // Why: runtime titles can precede layout hydration (SSH/replay); with one title and one agent row, prefer that row over a stale spinner. const hasSingleUnmappedAgentStatusPane = leafId === null && agentStatusPaneIds?.size === 1 && paneTitleEntries.length === 1 @@ -104,7 +115,9 @@ function tabHasStatus( return true } } - return false + if (hasCurrentPaneTitle) { + return false + } } // Why: a tab title can't identify its pane; once an agent row owns one, prefer the row over a completed pane's stale "working" title. const agentStatusPaneIds = diff --git a/src/renderer/src/runtime/web-session-tabs-sync-completion-on-reveal.test.tsx b/src/renderer/src/runtime/web-session-tabs-sync-completion-on-reveal.test.tsx new file mode 100644 index 00000000000..8e52b7b9bbf --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync-completion-on-reveal.test.tsx @@ -0,0 +1,148 @@ +// @vitest-environment happy-dom +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import type * as WebRuntimeTerminalCreationModule from './web-runtime-terminal-creation' +import type * as WebSessionTerminalHandleEventsModule from './web-session-terminal-handle-events' +vi.mock('./web-session-terminal-handle-events', async (importOriginal) => { + const actual = await importOriginal() + const { frameOrderingMocks } = await import('./host-session-mirror-frame-fixtures') + return { + ...actual, + queueAcceptedWebSessionTerminalSnapshot: frameOrderingMocks.queueAcceptedSnapshot + } +}) + +vi.mock('./use-runtime-session-mirror-environment-key', async () => { + const { frameOrderingMocks } = await import('./host-session-mirror-frame-fixtures') + return { + useRuntimeSessionMirrorEnvironmentKeys: () => ({ + environmentKey: frameOrderingMocks.runtimeSessionMirrorEnvironmentKey(), + resubscribeSignal: '' + }) + } +}) + +vi.mock('./web-session-terminal-orphan-recovery', async () => { + const { frameOrderingMocks } = await import('./host-session-mirror-frame-fixtures') + return { recoverWebSessionTerminalOrphansBeforeApply: frameOrderingMocks.recoverSnapshot } +}) + +vi.mock('./web-runtime-terminal-creation', async (importOriginal) => { + const actual = await importOriginal() + const { frameOrderingMocks } = await import('./host-session-mirror-frame-fixtures') + return { ...actual, createWebRuntimeSessionTerminal: frameOrderingMocks.createTerminal } +}) + +import { useAppStore } from '@/store' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { resolveTerminalTabActivityStatus } from '../components/tab-bar/terminal-tab-activity-status' +import { + BG_MIRROR_TAB_ID, + BG_WT, + HOST_PARENT_TAB_ID, + HOST_SURFACE_ID, + LEAF_ID, + MIRROR_TAB_ID, + makeHostSnapshot, + WT +} from './host-session-mirror-frame-fixtures' +import { + findSubscription, + installFrameOrderingHarness, + publish, + settle, + setDocumentVisibility +} from './host-session-mirror-frame-ordering-test-harness' +import { useWebSessionTabsSync } from './web-session-tabs-sync/use-web-session-tabs-sync' +import { WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS } from './window-visibility-subscription-parking' + +const NOW = 1_000_000 + +function agentSnapshot( + worktree: string, + parentTabId: string, + surfaceId: string, + state: 'working' | 'done' +) { + const snapshot = makeHostSnapshot(worktree, surfaceId, parentTabId) + const tab = snapshot.tabs[0] + if (tab?.type !== 'terminal') { + throw new Error('Missing terminal fixture') + } + tab.title = state === 'done' ? 'Pi ready' : '⠋ Pi' + tab.launchAgent = 'pi' + tab.agentStatus = { + paneKey: makePaneKey(parentTabId, LEAF_ID), + state, + agentType: 'pi', + worktreeId: worktree, + updatedAt: NOW, + stateStartedAt: NOW, + stateHistory: [], + prompt: '' + } + snapshot.snapshotVersion = state === 'done' ? 2 : 1 + return snapshot +} + +function tabActivity(tabId: string, worktreeId: string) { + const state = useAppStore.getState() + const tab = state.tabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId) + if (!tab) { + throw new Error('Missing mirrored terminal') + } + return resolveTerminalTabActivityStatus({ + tab, + agentStatusByPaneKey: state.agentStatusByPaneKey, + agentStatusEpoch: state.agentStatusEpoch, + runtimePaneTitlesByTabId: state.runtimePaneTitlesByTabId, + ptyIdsByTabId: state.ptyIdsByTabId, + terminalLayout: state.terminalLayoutsByTabId[tabId] + }) +} + +describe('remote completion after the client window is hidden', () => { + installFrameOrderingHarness({ fakeTimers: true }) + + it('resubscribes immediately on reveal and repairs completion in active and inactive workspaces', async () => { + vi.setSystemTime(NOW) + renderHook(() => useWebSessionTabsSync()) + await act(settle) + await publish(findSubscription('session.tabs.subscribeAll'), { + type: 'snapshots', + authoritative: true, + snapshots: [ + agentSnapshot(WT, HOST_PARENT_TAB_ID, HOST_SURFACE_ID, 'working'), + agentSnapshot(BG_WT, 'host-tab-2', `host-tab-2::${LEAF_ID}`, 'working') + ] + }) + useAppStore.setState({ + runtimePaneTitlesByTabId: { + [MIRROR_TAB_ID]: { 1: '⠋ Pi' }, + [BG_MIRROR_TAB_ID]: { 1: '⠋ Pi' } + } + }) + expect(tabActivity(MIRROR_TAB_ID, WT)).toBe('working') + expect(tabActivity(BG_MIRROR_TAB_ID, BG_WT)).toBe('working') + act(() => { + setDocumentVisibility('hidden') + vi.advanceTimersByTime(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS) + setDocumentVisibility('visible') + }) + await act(settle) + await publish(findSubscription('session.tabs.subscribeAll', 1), { + type: 'snapshots', + authoritative: true, + snapshots: [ + agentSnapshot(WT, HOST_PARENT_TAB_ID, HOST_SURFACE_ID, 'done'), + agentSnapshot(BG_WT, 'host-tab-2', `host-tab-2::${LEAF_ID}`, 'done') + ] + }) + expect(tabActivity(MIRROR_TAB_ID, WT)).toBe('done') + expect(tabActivity(BG_MIRROR_TAB_ID, BG_WT)).toBe('done') + expect(useAppStore.getState().runtimePaneTitlesByTabId).toMatchObject({ + [MIRROR_TAB_ID]: { 1: 'Pi ready' }, + [BG_MIRROR_TAB_ID]: { 1: 'Pi ready' } + }) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts index 9fff1d8ad01..155e6b181e2 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts @@ -15,7 +15,8 @@ import { createTestStore, makeWorktree, seedStore } from '../store/slices/store- import { markRendererOwnedAgentStatusWrite, registerRendererOwnedAgentStatusPane, - resetRendererOwnedAgentStatusPanesForTests + resetRendererOwnedAgentStatusPanesForTests, + resolveRendererOwnedAgentStatusTiming } from '../components/terminal-pane/renderer-owned-agent-status-registry' import { applyWebSessionTabsSnapshot } from './web-session-tabs-sync/snapshot-api' import { resetWebSessionTabsSnapshotFreshnessForTests } from './web-session-tabs-sync/tracking-lifecycle' @@ -105,8 +106,7 @@ function applyHostSnapshot( store.setState(patch as Partial) } -/** Replay paired pty-connection OSC handling with client Date.now(); return ownership release held until dispose. */ -/** Returns the pane's release, as pty-connection holds it for dispose(). */ +/** Replay paired pty-connection OSC handling with client Date.now(); returns the release pty-connection holds for dispose(). */ function replayClientOscWorking(store: TestStore, clientNow: number): () => void { vi.setSystemTime(clientNow) // Claim on transport creation, then verify ownership on each byte-derived status. @@ -118,7 +118,7 @@ function replayClientOscWorking(store: TestStore, clientNow: number): () => void MIRROR_PANE_KEY, { state: 'working', prompt: CLIENT_PROMPT, agentType: 'claude' }, 'claude', - undefined, + resolveRendererOwnedAgentStatusTiming(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]), { tabId: MIRROR_TAB_ID, worktreeId: WT } ) return release @@ -432,6 +432,118 @@ describe('remote-paired pane: host snapshot mirror vs client byte-derived status expect(entry?.interactivePrompt).toBe('Allow Bash(rm -rf)?') }) + it('follows behind-clock host continuation and completion after an HTTP approval', () => { + const store = seedPairedClientStore() + replayClientOscWorking(store, T0 + 500) + + applyHostSnapshot( + store, + makeHostSnapshot({ + snapshotVersion: 2, + hostNow: T0 - 60_000, + includeAgentStatus: true, + agentStatusOverrides: { + state: 'waiting', + interactivePrompt: 'Allow exec_command?' + } + }), + T0 + 1_000 + ) + expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]).toMatchObject({ + state: 'waiting', + mirroredEvidenceReceivedAt: T0 + 1_000 + }) + + applyHostSnapshot( + store, + makeHostSnapshot({ + snapshotVersion: 3, + hostNow: T0 - 59_000, + includeAgentStatus: true, + agentStatusOverrides: { state: 'working' } + }), + T0 + 2_000 + ) + const continued = store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY] + expect(continued?.state).toBe('working') + expect(continued?.interactivePrompt).toBeUndefined() + + applyHostSnapshot( + store, + makeHostSnapshot({ + snapshotVersion: 4, + hostNow: T0 - 58_000, + includeAgentStatus: true + }), + T0 + 3_000 + ) + expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]?.state).toBe('done') + + applyHostSnapshot( + store, + makeHostSnapshot({ + snapshotVersion: 5, + hostNow: T0 - 57_000, + includeAgentStatus: false + }), + T0 + 4_000 + ) + expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]).toBeUndefined() + }) + + it('reclaims byte ownership after approval despite an ahead-clock host', () => { + const store = seedPairedClientStore() + replayClientOscWorking(store, T0 + 500) + + applyHostSnapshot( + store, + makeHostSnapshot({ + snapshotVersion: 2, + hostNow: T0 + 60_000, + includeAgentStatus: true, + agentStatusOverrides: { + state: 'waiting', + interactivePrompt: 'Allow exec_command?' + } + }), + T0 + 1_000 + ) + expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]?.state).toBe('waiting') + + applyHostSnapshot( + store, + makeHostSnapshot({ + snapshotVersion: 3, + hostNow: T0 + 61_000, + includeAgentStatus: true, + agentStatusOverrides: { state: 'working' } + }), + T0 + 1_250 + ) + expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]?.state).toBe('working') + + replayClientOscWorking(store, T0 + 1_500) + const byteEntry = store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY] + expect(byteEntry?.state).toBe('working') + expect(byteEntry?.mirroredEvidenceReceivedAt).toBeUndefined() + expect(byteEntry?.interactivePrompt).toBeUndefined() + + applyHostSnapshot( + store, + makeHostSnapshot({ + snapshotVersion: 4, + hostNow: T0 + 120_000, + includeAgentStatus: true + }), + T0 + 2_000 + ) + expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]).toMatchObject({ + state: 'working', + updatedAt: T0 + 1_500, + prompt: CLIENT_PROMPT + }) + }) + it('keeps live client status over a restored, unconfirmed host block', () => { const store = seedPairedClientStore() replayClientOscWorking(store, T0 + 500) diff --git a/src/renderer/src/runtime/web-session-tabs-sync-stale-spinner.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-stale-spinner.test.ts new file mode 100644 index 00000000000..e6549d5d89f --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync-stale-spinner.test.ts @@ -0,0 +1,434 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeMobileSessionTerminalClientTab } from '../../../shared/runtime-types' +import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { resolveTerminalTabActivityStatus } from '../components/tab-bar/terminal-tab-activity-status' +import { + markRendererOwnedAgentStatusWrite, + registerRendererOwnedAgentStatusPane, + resetRendererOwnedAgentStatusPanesForTests +} from '../components/terminal-pane/renderer-owned-agent-status-registry' +import { + applyWebSessionTabsSnapshot, + applyWebSessionTabsSnapshots +} from './web-session-tabs-sync/snapshot-api' +import type { WebSessionTabsSyncState } from './web-session-tabs-sync/state' +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { registerRuntimeTerminalTab } from './mounted-terminal-tab-registry' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import { + ENV, + HOST_SURFACE_ID, + LEAF_ID, + NOW, + SECOND_LEAF_ID, + THIRD_LEAF_ID, + WT, + makeSnapshot, + makeState, + resetWebSessionTabsSyncTestState +} from './web-session-tabs-sync-test-harness' + +vi.mock('../store', () => ({ useAppStore: { setState: vi.fn() } })) + +const TAB_ID = toWebTerminalSurfaceTabId('host-tab-1') +const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID) + +function hostPane(state: 'done' | 'working' = 'done'): RuntimeMobileSessionTerminalClientTab & { + status: 'ready' + terminal: string +} { + return { + type: 'terminal', + id: HOST_SURFACE_ID, + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + title: state === 'done' ? 'Pi ready' : '⠋ Pi', + launchAgent: 'pi', + isActive: true, + status: 'ready', + terminal: 'terminal-1', + agentStatus: { + state, + agentType: 'pi', + paneKey: makePaneKey('host-tab-1', LEAF_ID), + worktreeId: WT, + prompt: '', + updatedAt: state === 'working' ? NOW - 1_000 : NOW, + stateStartedAt: state === 'working' ? NOW - 1_000 : NOW, + stateHistory: [] + } + } +} + +function restoredState(): WebSessionTabsSyncState & { + runtimePaneTitlesByTabId: NonNullable +} { + const initial = makeState() + const state = { + ...initial, + ...applyWebSessionTabsSnapshot(initial, makeSnapshot([hostPane('working')]), ENV, NOW) + } + const row = state.agentStatusByPaneKey[PANE_KEY] + if (!row) { + throw new Error('Missing mirrored status') + } + return { + ...state, + activeWorktreeId: 'another-workspace', + runtimePaneTitlesByTabId: { [TAB_ID]: { 1: '⠋ Pi - saved session' } }, + agentStatusByPaneKey: { + [PANE_KEY]: { ...row, mirroredEvidenceReceivedAt: NOW - 55 * 60 * 60 * 1000 } + } + } +} + +beforeEach(() => { + resetWebSessionTabsSyncTestState() + resetRendererOwnedAgentStatusPanesForTests() + vi.useFakeTimers() + vi.setSystemTime(NOW) +}) +afterEach(() => { + resetRendererOwnedAgentStatusPanesForTests() + vi.useRealTimers() +}) + +describe('remote completion repairs saved spinner titles', () => { + it('refreshes numeric runtime slots in an inactive workspace and removes the false spinner', () => { + const state = restoredState() + const updated = { + ...state, + ...applyWebSessionTabsSnapshot( + state, + makeSnapshot([hostPane()], { snapshotVersion: 2 }), + ENV, + NOW + ) + } + expect(updated.runtimePaneTitlesByTabId[TAB_ID]).toEqual({ 1: 'Pi ready' }) + expect(state.runtimePaneTitlesByTabId[TAB_ID]).toEqual({ 1: '⠋ Pi - saved session' }) + expect(updated.agentStatusByPaneKey[PANE_KEY]?.state).toBe('done') + const tab = updated.tabsByWorktree[WT]?.[0] + if (!tab) { + throw new Error('Missing mirrored tab') + } + expect( + resolveTerminalTabActivityStatus({ + tab, + runtimePaneTitlesByTabId: updated.runtimePaneTitlesByTabId, + agentStatusByPaneKey: updated.agentStatusByPaneKey, + agentStatusEpoch: updated.agentStatusEpoch, + ptyIdsByTabId: updated.ptyIdsByTabId, + terminalLayout: updated.terminalLayoutsByTabId[TAB_ID] + }) + ).toBe('done') + }) + + it('drops absent durable leaves while refreshing a sparse numeric slot', () => { + const state = restoredState() + const orphanTitles = { 2: '⠋ Pi', [SECOND_LEAF_ID]: '⠋ Pi' } + state.runtimePaneTitlesByTabId[TAB_ID] = orphanTitles + const patch = applyWebSessionTabsSnapshot( + state, + makeSnapshot([hostPane()], { snapshotVersion: 2 }), + ENV, + NOW + ) + expect(patch.runtimePaneTitlesByTabId?.[TAB_ID]).toEqual({ 2: 'Pi ready' }) + }) + + it('accepts host done over an expired row even when its clock is behind the client row', () => { + const state = restoredState() + const row = state.agentStatusByPaneKey[PANE_KEY] + if (!row) { + throw new Error('Missing mirrored status') + } + state.agentStatusByPaneKey[PANE_KEY] = { ...row, updatedAt: NOW + 60_000 } + registerRendererOwnedAgentStatusPane(PANE_KEY, ENV) + markRendererOwnedAgentStatusWrite(PANE_KEY) + const patch = applyWebSessionTabsSnapshot( + state, + makeSnapshot([hostPane()], { snapshotVersion: 2 }), + ENV, + NOW + ) + expect(patch.agentStatusByPaneKey?.[PANE_KEY]).toMatchObject({ state: 'done', updatedAt: NOW }) + expect(patch.runtimePaneTitlesByTabId?.[TAB_ID]).toEqual({ 1: 'Pi ready' }) + }) + + it('keeps a fresh byte-stream writer and its title during a lagging host publication', () => { + const state = restoredState() + const row = state.agentStatusByPaneKey[PANE_KEY] + if (!row) { + throw new Error('Missing mirrored status') + } + state.agentStatusByPaneKey[PANE_KEY] = { + ...row, + updatedAt: NOW, + mirroredEvidenceReceivedAt: undefined + } + registerRendererOwnedAgentStatusPane(PANE_KEY, ENV) + markRendererOwnedAgentStatusWrite(PANE_KEY) + const patch = applyWebSessionTabsSnapshot( + state, + makeSnapshot([hostPane()], { snapshotVersion: 2 }), + ENV, + NOW + ) + expect(patch.runtimePaneTitlesByTabId).toBeUndefined() + const nextStatuses = patch.agentStatusByPaneKey ?? state.agentStatusByPaneKey + expect(nextStatuses[PANE_KEY]?.state).toBe('working') + }) + + it('preserves title repairs across a batched all-workspace inventory', () => { + const state = restoredState() + const patch = applyWebSessionTabsSnapshots( + state, + [ + makeSnapshot([hostPane()], { snapshotVersion: 2 }), + makeSnapshot([], { worktree: 'another-workspace' }) + ], + ENV, + NOW + ) + expect(patch.runtimePaneTitlesByTabId?.[TAB_ID]).toEqual({ 1: 'Pi ready' }) + expect(patch.agentStatusByPaneKey?.[PANE_KEY]?.state).toBe('done') + }) + + it('keeps a genuinely working sibling spinning when another split pane completes', () => { + const state = restoredState() + const sibling = { + ...hostPane('working'), + id: `host-tab-1::${SECOND_LEAF_ID}`, + leafId: SECOND_LEAF_ID, + isActive: false, + terminal: 'terminal-2' + } + state.runtimePaneTitlesByTabId[TAB_ID] = { 1: '⠋ Pi', 2: '⠋ Pi' } + const updated = { + ...state, + ...applyWebSessionTabsSnapshot( + state, + makeSnapshot([hostPane(), sibling], { snapshotVersion: 2 }), + ENV, + NOW + ) + } + const tab = updated.tabsByWorktree[WT]?.[0] + if (!tab) { + throw new Error('Missing mirrored tab') + } + expect(updated.runtimePaneTitlesByTabId[TAB_ID]).toEqual({ 1: 'Pi ready', 2: '⠋ Pi' }) + expect( + resolveTerminalTabActivityStatus({ + tab, + agentStatusByPaneKey: updated.agentStatusByPaneKey, + agentStatusEpoch: updated.agentStatusEpoch, + runtimePaneTitlesByTabId: updated.runtimePaneTitlesByTabId, + ptyIdsByTabId: updated.ptyIdsByTabId, + terminalLayout: updated.terminalLayoutsByTabId[TAB_ID] + }) + ).toBe('working') + }) + + it('refreshes sparse runtime slots through their retained PTY bindings after a pane closes', () => { + const sibling = { + ...hostPane('working'), + id: `host-tab-1::${SECOND_LEAF_ID}`, + leafId: SECOND_LEAF_ID, + isActive: false, + terminal: 'terminal-2' + } + const initial = makeState() + const state = { + ...initial, + ...applyWebSessionTabsSnapshot( + initial, + makeSnapshot([hostPane('working'), sibling]), + ENV, + NOW + ), + runtimePaneTitlesByTabId: { [TAB_ID]: { 1: '⠋ Pi - first', 3: '⠋ Pi - sibling' } } + } + const patch = applyWebSessionTabsSnapshot( + state, + makeSnapshot([hostPane(), sibling], { snapshotVersion: 2 }), + ENV, + NOW + ) + expect(patch.runtimePaneTitlesByTabId?.[TAB_ID]).toEqual({ 1: 'Pi ready', 3: '⠋ Pi' }) + }) + + it.each([false, true])( + 'keeps moved pane titles attached to their PTY with a fresh writer: %s', + (freshFirstWriter) => { + const sibling = { + ...hostPane(), + id: `host-tab-1::${SECOND_LEAF_ID}`, + leafId: SECOND_LEAF_ID, + isActive: false, + terminal: 'terminal-2', + title: 'Pi second ready' + } + const initial = makeState() + const mirrored = { + ...initial, + ...applyWebSessionTabsSnapshot( + initial, + makeSnapshot([hostPane('working'), sibling]), + ENV, + NOW + ) + } + const layout: TerminalLayoutSnapshot = { + ...mirrored.terminalLayoutsByTabId[TAB_ID], + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: SECOND_LEAF_ID }, + second: { type: 'leaf', leafId: LEAF_ID } + } + } + const state = { + ...mirrored, + terminalLayoutsByTabId: { [TAB_ID]: layout }, + runtimePaneTitlesByTabId: { [TAB_ID]: { 1: '⠋ Pi - first', 2: '⠋ Pi - second' } } + } + if (freshFirstWriter) { + const row = state.agentStatusByPaneKey[PANE_KEY] + if (!row) { + throw new Error('Missing mirrored status') + } + state.agentStatusByPaneKey[PANE_KEY] = { + ...row, + mirroredEvidenceReceivedAt: undefined + } + registerRendererOwnedAgentStatusPane(PANE_KEY, ENV) + markRendererOwnedAgentStatusWrite(PANE_KEY) + } + const patch = applyWebSessionTabsSnapshot( + state, + makeSnapshot([hostPane(), sibling], { snapshotVersion: 2 }), + ENV, + NOW + ) + expect(patch.runtimePaneTitlesByTabId?.[TAB_ID]).toEqual({ + 1: freshFirstWriter ? '⠋ Pi - first' : 'Pi ready', + 2: 'Pi second ready' + }) + } + ) + + it('retains an unmapped numeric title until its pane ownership is known', () => { + const state = restoredState() + state.runtimePaneTitlesByTabId[TAB_ID] = { 3: '⠋ Pi', 7: '⠋ Pi' } + const patch = applyWebSessionTabsSnapshot( + state, + makeSnapshot([hostPane()], { snapshotVersion: 2 }), + ENV, + NOW + ) + expect(patch.runtimePaneTitlesByTabId).toBeUndefined() + expect(state.runtimePaneTitlesByTabId[TAB_ID]).toEqual({ 3: '⠋ Pi', 7: '⠋ Pi' }) + }) + + it('keeps titles of a client split the pre-split host snapshot does not have yet', () => { + const leafIdsBySlot = new Map([ + [1, LEAF_ID], + [2, THIRD_LEAF_ID] + ]) + const manager = { + getLeafId: (paneId: number) => leafIdsBySlot.get(paneId) ?? null, + getNumericIdForLeaf: (leafId: string) => + [...leafIdsBySlot].find(([, id]) => id === leafId)?.[0] ?? null + } + const unregister = registerRuntimeTerminalTab({ + tabId: TAB_ID, + worktreeId: WT, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the title patch reads only getLeafId/getNumericIdForLeaf. + getManager: () => manager as unknown as PaneManager, + getContainer: () => null, + getPtyIdForPane: () => null, + getTabWideAgentHintLeafId: () => null + }) + try { + const state = restoredState() + const clientSplitTitles = { + 1: '⠋ Pi - saved session', + 2: '⠋ Pi', + [THIRD_LEAF_ID]: '⠋ Pi' + } + state.runtimePaneTitlesByTabId[TAB_ID] = clientSplitTitles + const patch = applyWebSessionTabsSnapshot( + state, + makeSnapshot([hostPane()], { snapshotVersion: 2 }), + ENV, + NOW + ) + expect(patch.runtimePaneTitlesByTabId?.[TAB_ID]).toEqual({ + 1: 'Pi ready', + 2: '⠋ Pi', + [THIRD_LEAF_ID]: '⠋ Pi' + }) + } finally { + unregister() + } + }) + + it('does not interpret a pending host placeholder as new title evidence', () => { + const state = restoredState() + const pending = { + ...hostPane(), + title: 'Terminal', + status: 'pending-handle' as const, + terminal: null, + agentStatus: undefined + } + const patch = applyWebSessionTabsSnapshot( + state, + makeSnapshot([pending], { snapshotVersion: 2 }), + ENV, + NOW + ) + expect(patch.runtimePaneTitlesByTabId).toBeUndefined() + }) + + it('does not rewrite local terminal runtime titles from a local session mirror', () => { + const state = restoredState() + const patch = applyWebSessionTabsSnapshot( + state, + makeSnapshot([hostPane()], { snapshotVersion: 2 }), + ENV, + NOW, + { terminalPtyMode: 'local' } + ) + expect(patch.runtimePaneTitlesByTabId).toBeUndefined() + }) + + it('ignores orphan titles before host sync and falls back to the current tab title', () => { + const state = restoredState() + const tab = state.tabsByWorktree[WT]?.[0] + if (!tab) { + throw new Error('Missing mirrored tab') + } + const orphanTitles = { [SECOND_LEAF_ID]: '⠋ Pi' } + const readyOrphanTitles = { [SECOND_LEAF_ID]: 'Pi ready' } + expect( + resolveTerminalTabActivityStatus({ + tab: { ...tab, title: 'Pi ready' }, + runtimePaneTitlesByTabId: { [TAB_ID]: orphanTitles }, + ptyIdsByTabId: state.ptyIdsByTabId, + terminalLayout: state.terminalLayoutsByTabId[TAB_ID] + }) + ).toBe('active') + expect( + resolveTerminalTabActivityStatus({ + tab: { ...tab, title: '⠋ Pi' }, + runtimePaneTitlesByTabId: { [TAB_ID]: readyOrphanTitles }, + ptyIdsByTabId: state.ptyIdsByTabId, + terminalLayout: state.terminalLayoutsByTabId[TAB_ID] + }) + ).toBe('working') + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts index 4a240f56584..c53de6c4ee2 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts @@ -17,7 +17,7 @@ import type { import { isClientOwnedAgentStatus, isMirroredAgentStatusOwnedBy, - isFencedClientAgentStatus, + clientAgentStatusFencesHost, hostAgentStatusPiercesClientAuthority, isMirroredAgentPaneKeyForTabs, batchAgentPaneKeysForTabs, @@ -93,11 +93,14 @@ export function buildMirroredAgentStatusPatch( existing.state !== 'done' && existing.stateStartedAt > entry.stateStartedAt // Cross-machine clocks cannot arbitrate live byte ownership; keep client state except host-only classes, adopting host identity below. - const clientOwnsEntry = - isFencedClientAgentStatus(entry.paneKey, existing, now) && - !hostAgentStatusPiercesClientAuthority(entry) + const hostPiercesAuthority = hostAgentStatusPiercesClientAuthority(entry) + const clientOwnsEntry = clientAgentStatusFencesHost(entry.paneKey, existing, entry, now) const nextEntry = - existing && (clientOwnsEntry || existing.updatedAt > entry.updatedAt) + existing && + (clientOwnsEntry || + (!hostPiercesAuthority && + isAgentStatusFresh(existing, now) && + existing.updatedAt > entry.updatedAt)) ? { ...normalizeCompatibleAgentStatusEntryForOwner(existing, entry.agentType), ...(clientOwnsEntry && existing.state === 'working' && entry.state === 'working' diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts index cdcaa5e5b01..4df5ac3ba58 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts @@ -72,12 +72,16 @@ export function hostAgentStatusPiercesClientAuthority(entry: AgentStatusEntry): ) } -/** Byte-derived ownership requires transport claim plus a write; teardown releases it back to host. */ +/** Only the current byte-derived row can fence host snapshots; adopting a host row yields authority. */ export function isClientOwnedAgentStatus( paneKey: string, existing: AgentStatusEntry | undefined ): existing is AgentStatusEntry { - return existing !== undefined && isClientAuthoritativeAgentStatusPane(paneKey) + return ( + existing !== undefined && + existing.mirroredEvidenceReceivedAt === undefined && + isClientAuthoritativeAgentStatusPane(paneKey) + ) } /** When host and client compete, stale client status yields to host; ownership alone still protects against host omission. */ @@ -89,6 +93,19 @@ export function isFencedClientAgentStatus( return isClientOwnedAgentStatus(paneKey, existing) && isAgentStatusFresh(existing, now) } +/** A fresh client byte writer keeps the pane unless the host carries a host-only state class. */ +export function clientAgentStatusFencesHost( + paneKey: string, + existing: AgentStatusEntry | undefined, + hostEntry: AgentStatusEntry | null | undefined, + now: number +): boolean { + return ( + isFencedClientAgentStatus(paneKey, existing, now) && + !(hostEntry && hostAgentStatusPiercesClientAuthority(hostEntry)) + ) +} + export function batchAgentPaneKeysForTabs( state: WebSessionTabsSyncState, tabIds: ReadonlySet, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts index cb78b0bc9a2..86a4d879815 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts @@ -2,6 +2,7 @@ import { collectCollidingRetractionPaneKeys } from './retraction-pane-ownership' import type { WebSessionTabsSyncState } from './state' import type { applyActiveStateUpdates } from './apply-active-state' import { buildMirroredAgentStatusPatch } from './agent-status-patch' +import { buildMirroredRuntimePaneTitlePatch } from './runtime-pane-title-patch' import { buildRemirroredClosedTabMarkerLiftPatch, buildRetractedMirroredTabSweepPatch @@ -65,6 +66,16 @@ export function buildWebSessionTabsFinalPatch( worktreeId, batchContext ) + const runtimePaneTitlePatch = + context.options?.terminalPtyMode === 'local' + ? null + : buildMirroredRuntimePaneTitlePatch( + state, + mirroredTerminalTabs, + terminalSurfaceTabs, + now, + batchContext + ) const agentStatusPatch = buildMirroredAgentStatusPatch( state, currentTerminalTabs, @@ -107,6 +118,7 @@ export function buildWebSessionTabsFinalPatch( ) const patch: Partial = { + ...runtimePaneTitlePatch, ...agentStatusPatch, ...retractedTabSweepPatch, ...remirroredClosedTabLiftPatch, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/runtime-pane-title-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/runtime-pane-title-patch.ts new file mode 100644 index 00000000000..9c1e60e98e3 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/runtime-pane-title-patch.ts @@ -0,0 +1,138 @@ +import { + collectRuntimePaneLeafIds, + isRuntimePaneTitleOrphan, + resolveRuntimePaneTitleLeafId, + resolveRuntimePaneTitleLeafIdFromSparseSlots +} from '../../../../shared/runtime-pane-title-leaf-id' +import { FIRST_PANE_ID } from '../../../../shared/pane-key' +import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' +import { normalizeCompatibleAgentTitleForOwner } from '../../../../shared/agent-title-owner' +import { resolvePaneAgentOwnerRecord } from '../../../../shared/pane-agent-owner' +import { findRegisteredTerminalTab } from '../mounted-terminal-tab-registry' +import { clientAgentStatusFencesHost } from './agent-status-primitives' +import { writableWebSessionTabsRecord } from './state-equality-core' +import type { + MirroredTerminalTab, + TerminalSurface, + WebSessionTabsBatchContext, + WebSessionTabsSyncState +} from './state' + +/** Reconcile saved pane titles with the accepted host inventory, including inactive tabs. */ +export function buildMirroredRuntimePaneTitlePatch( + state: WebSessionTabsSyncState, + tabs: readonly MirroredTerminalTab[], + surfaces: readonly TerminalSurface[], + now: number, + batchContext?: WebSessionTabsBatchContext +): Partial | null { + const titles = state.runtimePaneTitlesByTabId + if (!titles) { + return null + } + const titledTabs = tabs.flatMap((entry) => { + const paneTitles = titles[entry.tab.id] + return paneTitles ? [{ ...entry, paneTitles }] : [] + }) + if (titledTabs.length === 0) { + return null + } + const surfacesByTab = new Map>() + for (const surface of surfaces) { + const byLeaf = surfacesByTab.get(surface.parentTabId) ?? new Map() + byLeaf.set(surface.leafId, surface) + surfacesByTab.set(surface.parentTabId, byLeaf) + } + let nextTitles = titles + for (const { tab, hostTabId, layout, retainedSurfaceByPrunedLeafId, paneTitles } of titledTabs) { + const leaves = new Set(collectRuntimePaneLeafIds(layout.root)) + const liveSlotIds = Object.keys(paneTitles) + .map(Number) + .filter((id) => Number.isInteger(id) && id >= FIRST_PANE_ID) + .sort((a, b) => a - b) + const liveSlotsAreDense = + liveSlotIds.length === leaves.size && + liveSlotIds.every((id, index) => id === FIRST_PANE_ID + index) + const manager = findRegisteredTerminalTab(tab.id, tab.worktreeId)?.tab.getManager() + const hostSurfacesByLeaf = new Map(surfacesByTab.get(hostTabId)) + for (const [hostLeafId, retainedSurface] of retainedSurfaceByPrunedLeafId ?? []) { + const surface = hostSurfacesByLeaf.get(hostLeafId) + if (surface) { + hostSurfacesByLeaf.set(retainedSurface.leafId, surface) + } + } + let nextPaneTitles: Record = paneTitles + for (const [runtimePaneId, title] of Object.entries(paneTitles)) { + const leafId = isTerminalLeafId(runtimePaneId) + ? runtimePaneId + : manager + ? manager.getLeafId(Number(runtimePaneId)) + : (resolveRuntimePaneTitleLeafIdFromSparseSlots({ + layout: state.terminalLayoutsByTabId[tab.id], + paneId: Number(runtimePaneId), + liveSlotIds, + ptyIds: state.ptyIdsByTabId[tab.id] ?? [] + }) ?? (liveSlotsAreDense ? resolveRuntimePaneTitleLeafId(layout, runtimePaneId) : null)) + // Why: a slot the layout cannot place is not proof the pane was removed. + if (leafId === null) { + continue + } + const hostSurface = leaves.has(leafId) ? hostSurfacesByLeaf.get(leafId) : undefined + let hostTitle: string | undefined + if (hostSurface) { + // A pending handle's placeholder carries no new title evidence. + if ( + !hostSurface.title.trim() || + (hostSurface.status === 'pending-handle' && hostSurface.title === 'Terminal') + ) { + continue + } + const paneKey = makePaneKey(tab.id, leafId) + // Keep the existing byte-stream ownership fence while that writer is fresh. + if ( + clientAgentStatusFencesHost( + paneKey, + state.agentStatusByPaneKey[paneKey], + hostSurface.agentStatus, + now + ) + ) { + continue + } + const owner = resolvePaneAgentOwnerRecord({ + launchAgent: hostSurface.launchAgent ?? tab.launchAgent, + hookAgent: hostSurface.agentStatus?.agentType + }) + hostTitle = normalizeCompatibleAgentTitleForOwner(hostSurface.title, owner?.agent, { + ownerIsLaunch: owner?.ownerIsLaunch === true + }) + if (title === hostTitle) { + continue + } + } else if ( + !isRuntimePaneTitleOrphan(runtimePaneId, leaves) || + manager?.getNumericIdForLeaf(runtimePaneId) != null + ) { + // Only a durable leaf absent from both the host layout and local panes proves removal. + continue + } + if (nextPaneTitles === paneTitles) { + nextPaneTitles = { ...paneTitles } + } + // Runtime slots remain numeric until the pane manager migrates; refresh their values in place. + if (hostTitle !== undefined) { + nextPaneTitles[runtimePaneId] = hostTitle + } else { + delete nextPaneTitles[runtimePaneId] + } + } + if (nextPaneTitles === paneTitles) { + continue + } + if (nextTitles === titles) { + nextTitles = writableWebSessionTabsRecord(state, 'runtimePaneTitlesByTabId', batchContext) + } + nextTitles[tab.id] = nextPaneTitles + } + return nextTitles === titles ? null : { runtimePaneTitlesByTabId: nextTitles } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state.ts b/src/renderer/src/runtime/web-session-tabs-sync/state.ts index 8aabb25f732..96e44482406 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state.ts @@ -245,6 +245,7 @@ export type WebSessionTabsSyncState = Pick< | 'recentlyRetiredAgentStatusPaneKeys' | 'retainedAgentsByPaneKey' | 'retentionSuppressedPaneKeys' + | 'runtimePaneTitlesByTabId' > > @@ -265,6 +266,7 @@ export type WebSessionTabsBatchRecordKey = | 'pendingStartupByTabId' | 'ptyIdsByTabId' | 'remoteBrowserPageHandlesByPageId' + | 'runtimePaneTitlesByTabId' | 'tabBarOrderByWorktree' | 'tabsByWorktree' | 'terminalLayoutsByTabId' diff --git a/src/shared/runtime-pane-title-leaf-id.test.ts b/src/shared/runtime-pane-title-leaf-id.test.ts index 2557cfb915b..1327cc16797 100644 --- a/src/shared/runtime-pane-title-leaf-id.test.ts +++ b/src/shared/runtime-pane-title-leaf-id.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { TerminalLayoutSnapshot } from './terminal-tab-types' import { + isRuntimePaneTitleOrphan, resolveRuntimePaneTitleForLeaf, resolveRuntimePaneTitleLeafResolution } from './runtime-pane-title-leaf-id' @@ -60,3 +61,16 @@ describe('resolveRuntimePaneTitleLeafResolution', () => { }) }) }) + +describe('isRuntimePaneTitleOrphan', () => { + it('never treats numeric slots as removed panes', () => { + for (const runtimePaneId of ['0', '-1', '2', '3']) { + expect(isRuntimePaneTitleOrphan(runtimePaneId, new Set([LEAF_A]))).toBe(false) + } + }) + + it('flags only a durable leaf id the layout no longer has', () => { + expect(isRuntimePaneTitleOrphan(LEAF_B, new Set([LEAF_A, LEAF_B]))).toBe(false) + expect(isRuntimePaneTitleOrphan(LEAF_B, new Set([LEAF_A]))).toBe(true) + }) +}) diff --git a/src/shared/runtime-pane-title-leaf-id.ts b/src/shared/runtime-pane-title-leaf-id.ts index 7a592121252..f6cc8b12c64 100644 --- a/src/shared/runtime-pane-title-leaf-id.ts +++ b/src/shared/runtime-pane-title-leaf-id.ts @@ -125,6 +125,14 @@ export function resolveRuntimePaneTitleLeafIdFromRoot( return leafIds[numericPaneId - FIRST_PANE_ID] ?? null } +/** Numeric slots survive pane closes with gaps, so only absent durable leaf ids prove removal. */ +export function isRuntimePaneTitleOrphan( + runtimePaneId: string, + layoutLeafIds: ReadonlySet +): boolean { + return isTerminalLeafId(runtimePaneId) && !layoutLeafIds.has(runtimePaneId) +} + /** * Resolve a sparse live runtime pane slot through the tab's current PTY bindings. * PaneManager ids survive closes with gaps, while the live PTY list retains order.