From 7d31ff6c158d12e703db832e0fce4e94ca908979 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:14:38 -0700 Subject: [PATCH] fix(session-tabs): seed the first terminal when a warm empty worktree is selected again (#29893) * fix(session-tabs): seed the first terminal when a warm empty worktree is selected again A warm stream that got its only empty answer while its worktree was unselected correctly held the bootstrap, but selecting the worktree again reused the stream without re-deciding it, so the workspace stayed empty. Keep the held verdict and re-decide it when the warm entry is selected. Co-Authored-By: Claude * fix(session-tabs): drop a held first-terminal verdict superseded by newer host evidence Co-Authored-By: Claude * fix(session-tabs): hold only single-worktree frames as deferred seeds Co-Authored-By: Claude --------- Co-authored-by: Claude --- ...session-tabs-sync-worktree-switch.test.tsx | 53 +++++++++ .../active-session-subscription.ts | 59 +++++----- .../initial-terminal-seeder.ts | 101 ++++++++++++++++++ .../warm-worktree-subscriptions.ts | 5 +- 4 files changed, 187 insertions(+), 31 deletions(-) create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/initial-terminal-seeder.ts diff --git a/src/renderer/src/runtime/web-session-tabs-sync-worktree-switch.test.tsx b/src/renderer/src/runtime/web-session-tabs-sync-worktree-switch.test.tsx index 2f18e47f954..458d8c345a3 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-worktree-switch.test.tsx +++ b/src/renderer/src/runtime/web-session-tabs-sync-worktree-switch.test.tsx @@ -33,6 +33,7 @@ import { useAppStore } from '@/store' import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' import { clearHostLiveTerminalProbesForTests } from './host-live-terminal-probe' import { resetWebSessionTabsSnapshotFreshnessForTests } from './web-session-tabs-sync/tracking-lifecycle' +import { recordReceivedWebSessionTabsSnapshot } from './web-session-tabs-sync/tracking' import { useWebSessionTabsSync } from './web-session-tabs-sync/use-web-session-tabs-sync' import { clearRuntimeEnvironmentConnectionGenerationsForTests } from '@/store/slices/runtime-status' import { resetWebRuntimeInitialTerminalBootstrapForTests } from './web-runtime-initial-terminal-bootstrap' @@ -308,6 +309,58 @@ describe('session-tabs streams across worktree switches on one host', () => { expect(mocks.createTerminal).not.toHaveBeenCalled() }) + it('seeds the first terminal once the user returns to a warm empty worktree', async () => { + await connect() + const streamA = latestStream(WORKTREE_A) + await selectWorktree(WORKTREE_B) + // A's first answer lands while B is selected, so its bootstrap waits. + await publish(streamA, { + type: 'snapshot', + ...terminalSnapshot(WORKTREE_A, 1), + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + }) + expect(mocks.createTerminal).not.toHaveBeenCalled() + + await selectWorktree(WORKTREE_A) + + expect(latestStream(WORKTREE_A)).toBe(streamA) + expect(mocks.createTerminal).toHaveBeenCalledTimes(1) + expect(mocks.createTerminal).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: WORKTREE_A, environmentId: ENV }) + ) + await selectWorktree(WORKTREE_B) + await selectWorktree(WORKTREE_A) + expect(mocks.createTerminal).toHaveBeenCalledTimes(1) + }) + + it('drops a held first-terminal verdict once newer host evidence supersedes it', async () => { + await connect() + const streamA = latestStream(WORKTREE_A) + await selectWorktree(WORKTREE_B) + const empty = { + ...terminalSnapshot(WORKTREE_A, 1), + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + await publish(streamA, { type: 'snapshot', ...empty }) + // A newer answer (e.g. browser-only) reached the mirror through another stream. + recordReceivedWebSessionTabsSnapshot( + ENV, + { ...empty, snapshotVersion: 2 }, + undefined, + 'runtime-a' + ) + + await selectWorktree(WORKTREE_A) + + expect(mocks.createTerminal).not.toHaveBeenCalled() + }) + it('does not seed a terminal on the old host after the same worktree moves to another host', async () => { await connect() const streamA = latestStream(WORKTREE_A) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts b/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts index 7bc4d2c9341..1e8f9adc2cc 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts @@ -15,7 +15,6 @@ import { } from './tracking' import { decideWebSessionTabsSnapshot, - shouldBootstrapInitialWebRuntimeTerminal, shouldRespawnWebRuntimeTerminalAfterWake, shouldSyncRuntimeSessionTabs } from './tracking-decisions' @@ -39,11 +38,8 @@ import { shouldSkipWebRuntimeWakeTerminalRespawn } from '../web-runtime-wake-terminal-respawn' import { createWebRuntimeSessionTerminal } from '../web-runtime-terminal-creation' -import { - isWebRuntimeInitialTerminalBootstrapInFlight, - releaseWebRuntimeInitialTerminalBootstrapOnMirrorFrame -} from '../web-runtime-initial-terminal-bootstrap' -import { dispatchWebRuntimeInitialTerminalBootstrap } from '../web-runtime-initial-terminal-bootstrap-dispatch' +import { releaseWebRuntimeInitialTerminalBootstrapOnMirrorFrame } from '../web-runtime-initial-terminal-bootstrap' +import { createInitialTerminalSeeder } from './initial-terminal-seeder' import { toRuntimeWorktreeSelector } from '../runtime-worktree-selector' import { hostSnapshotAffirmsWorktreeContents } from '../host-session-snapshot-authority' import { isSessionTabsStreamEnd, type SessionTabsStreamEvent } from './state' @@ -63,6 +59,11 @@ export type ActiveSubscriptionArgs = { visibilitySnapshotAccepted: Ref> } +export type ActiveSessionTabsSubscription = WindowVisibilitySubscriptionParking & { + /** The worktree became selected again while its stream stayed warm. */ + reconsiderSelected: () => void +} + /** Install one worktree's stream, which resumes immediately on visibility changes. */ export function installActiveSessionTabsSubscription({ activeWorktreeId, @@ -73,7 +74,7 @@ export function installActiveSessionTabsSubscription({ visibilitySnapshotReceipt, visibilitySnapshotApply, visibilitySnapshotAccepted -}: ActiveSubscriptionArgs): WindowVisibilitySubscriptionParking | undefined { +}: ActiveSubscriptionArgs): ActiveSessionTabsSubscription | undefined { const environmentId = activeWorktreeRuntimeEnvironmentId?.trim() if ( !shouldSyncRuntimeSessionTabs({ @@ -96,7 +97,7 @@ export function installActiveSessionTabsSubscription({ getExplicitRuntimeEnvironmentIdForWorktree(state, activeWorktreeId)?.trim() === environmentId ) } - let requestedInitialTerminal = false + const seeder = createInitialTerminalSeeder(environmentId, activeWorktreeId) let requestedRespawnAfterWake = false let hostAffirmedWorktree = false @@ -140,22 +141,8 @@ export function installActiveSessionTabsSubscription({ const hasLiveLocalPty = localTabs.some( (tab) => (syncState.ptyIdsByTabId[tab.id] ?? []).length > 0 ) - const bootstrap = shouldBootstrapInitialWebRuntimeTerminal({ - event: recoveredEvent, - activeWorktreeId, - // Why both: the closure flag records that this subscription already owned a create, so the - // same closure never seeds twice even once the shared latch is released; a create that threw - // never set it, so a later frame may retry. The shared latch is what survives the effect - // re-runs a workspace switch triggers — without it a second closure seeds a second terminal - // while the first create is still in flight (STA-6173). - requestedInitialTerminal: - requestedInitialTerminal || - isWebRuntimeInitialTerminalBootstrapInFlight(environmentId, activeWorktreeId), - snapshotIsFresh: decision.apply, - localTerminalCount, - hasPersistedTerminalState: Object.hasOwn(syncState.tabsByWorktree, activeWorktreeId), - firstHostAnswer: !hostAffirmedWorktree - }) + const firstHostAnswer = !hostAffirmedWorktree + const bootstrap = decision.apply && seeder.decide(recoveredEvent, firstHostAnswer) const respawn = shouldRespawnWebRuntimeTerminalAfterWake({ event: recoveredEvent, activeWorktreeId, @@ -199,12 +186,16 @@ export function installActiveSessionTabsSubscription({ // the latch held, so this frame never seeds — the next one decides on real state. releaseWebRuntimeInitialTerminalBootstrapOnMirrorFrame(environmentId, recovered.worktree) } + if (decision.apply && isCurrent()) { + // Why: a warm stream may get its only empty answer while unselected; selection re-decides it. + seeder.noteAppliedFrame( + bootstrap ? { event: recoveredEvent, firstHostAnswer, receivedFrame, runtimeId } : null, + isSelected() + ) + } try { if (isCurrent() && isSelected() && bootstrap) { - // Latch claim, create, and row-conditional release live in the dispatch helper. - if (await dispatchWebRuntimeInitialTerminalBootstrap(environmentId, activeWorktreeId)) { - requestedInitialTerminal = true - } + await seeder.seed() } else if ( isCurrent() && isSelected() && @@ -227,7 +218,7 @@ export function installActiveSessionTabsSubscription({ return settle } - return installWindowVisibilitySubscriptionParking([ + const parking = installWindowVisibilitySubscriptionParking([ { subscribe: (isCurrent, { ended }) => subscribeRuntimeEnvironment( @@ -307,4 +298,14 @@ export function installActiveSessionTabsSubscription({ } } ]) + return Object.assign( + () => { + seeder.dispose() + parking() + }, + { + restartEnded: () => parking.restartEnded(), + reconsiderSelected: () => seeder.reconsiderSelected(isSelected()) + } + ) } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/initial-terminal-seeder.ts b/src/renderer/src/runtime/web-session-tabs-sync/initial-terminal-seeder.ts new file mode 100644 index 00000000000..7687fe47f9a --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/initial-terminal-seeder.ts @@ -0,0 +1,101 @@ +import { useAppStore } from '../../store' +import { isWebRuntimeInitialTerminalBootstrapInFlight } from '../web-runtime-initial-terminal-bootstrap' +import { dispatchWebRuntimeInitialTerminalBootstrap } from '../web-runtime-initial-terminal-bootstrap-dispatch' +import { shouldApplyRecoveredWebSessionTabsSnapshot } from './tracking' +import { shouldBootstrapInitialWebRuntimeTerminal } from './tracking-decisions' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { SessionTabsStreamEvent } from './state' + +type SessionTabsFrame = RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' } + +type DeferredSeed = { + event: SessionTabsFrame + firstHostAnswer: boolean + receivedFrame: number + runtimeId: string | undefined +} + +export type InitialTerminalSeeder = { + /** Whether an applied, fresh frame asks for the worktree's first terminal. */ + decide: (event: SessionTabsStreamEvent, firstHostAnswer: boolean) => boolean + /** Records the latest applied frame's verdict; only an unselected worktree keeps it pending. */ + noteAppliedFrame: (seed: DeferredSeed | null, selected: boolean) => void + seed: () => Promise + /** The worktree was selected again on a warm stream that never re-sends its answer. */ + reconsiderSelected: (selected: boolean) => void + dispose: () => void +} + +/** One worktree stream's first-terminal seeding, including a verdict that arrived while unselected. */ +export function createInitialTerminalSeeder( + environmentId: string, + worktreeId: string +): InitialTerminalSeeder { + let requestedInitialTerminal = false + let deferred: DeferredSeed | null = null + let disposed = false + + const decide = (event: SessionTabsStreamEvent, firstHostAnswer: boolean): boolean => { + const state = useAppStore.getState() + return shouldBootstrapInitialWebRuntimeTerminal({ + event, + activeWorktreeId: worktreeId, + // Why both: the closure flag records that this subscription already owned a create, so the + // same closure never seeds twice even once the shared latch is released; a create that threw + // never set it, so a later frame may retry. The shared latch is what survives the effect + // re-runs a workspace switch triggers — without it a second closure seeds a second terminal + // while the first create is still in flight (STA-6173). + requestedInitialTerminal: + requestedInitialTerminal || + isWebRuntimeInitialTerminalBootstrapInFlight(environmentId, worktreeId), + snapshotIsFresh: true, + localTerminalCount: (state.tabsByWorktree[worktreeId] ?? []).length, + hasPersistedTerminalState: Object.hasOwn(state.tabsByWorktree, worktreeId), + firstHostAnswer + }) + } + + const seed = async (): Promise => { + // Latch claim, create, and row-conditional release live in the dispatch helper. + if (await dispatchWebRuntimeInitialTerminalBootstrap(environmentId, worktreeId)) { + requestedInitialTerminal = true + } + } + + return { + decide, + noteAppliedFrame: (next, selected) => { + deferred = next && !selected ? next : null + }, + seed, + reconsiderSelected: (selected) => { + const pending = deferred + if (disposed || !pending || !selected) { + return + } + deferred = null + // Why: newer evidence (often via the all-worktrees stream) supersedes the held empty answer. + if ( + !shouldApplyRecoveredWebSessionTabsSnapshot( + environmentId, + pending.event, + pending.receivedFrame, + pending.runtimeId + ) + ) { + return + } + // Why re-decide: local tabs or another closure's create may have filled the worktree since. + if (!decide(pending.event, pending.firstHostAnswer)) { + return + } + void seed().catch((error) => { + console.warn('[web-session-tabs-sync] deferred initial terminal failed:', error) + }) + }, + dispose: () => { + disposed = true + deferred = null + } + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/warm-worktree-subscriptions.ts b/src/renderer/src/runtime/web-session-tabs-sync/warm-worktree-subscriptions.ts index f272eae5588..52ff278a86a 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/warm-worktree-subscriptions.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/warm-worktree-subscriptions.ts @@ -4,8 +4,8 @@ import { lastVerifiedRuntimeStatus } from '../../../../shared/runtime-host-statu import { useAppStore } from '../../store' import type { AppState } from '../../store/types' import { getRuntimeEnvironmentRevision } from '../runtime-environment-revision' -import type { WindowVisibilitySubscriptionParking } from '../window-visibility-subscription-parking' import type { + ActiveSessionTabsSubscription, ActiveSubscriptionArgs, installActiveSessionTabsSubscription } from './active-session-subscription' @@ -18,7 +18,7 @@ type WarmEntry = { environmentId: string identity: string pairingRevision: number | undefined - subscription: WindowVisibilitySubscriptionParking + subscription: ActiveSessionTabsSubscription } function streamIdentity( @@ -129,6 +129,7 @@ export function createWarmWorktreeSessionSubscriptions( if (warm) { entries.delete(key) entries.set(key, warm) + warm.subscription.reconsiderSelected() } else { const subscription = install(args) if (!subscription) {