mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user