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 <noreply@anthropic.com>

* fix(session-tabs): drop a held first-terminal verdict superseded by newer host evidence

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(session-tabs): hold only single-worktree frames as deferred seeds

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-16 17:14:38 -07:00
committed by GitHub
co-authored by Claude
parent 0ed0caa663
commit 7d31ff6c15
4 changed files with 187 additions and 31 deletions
@@ -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)
@@ -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<SessionTabsSnapshotHandler<void>>
}
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())
}
)
}
@@ -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<void>
/** 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<void> => {
// 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
}
}
}
@@ -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) {