diff --git a/src/renderer/src/lib/workspace/creation/structured-create-first-surface-claim.ts b/src/renderer/src/lib/workspace/creation/structured-create-first-surface-claim.ts new file mode 100644 index 00000000000..c8c197e00bd --- /dev/null +++ b/src/renderer/src/lib/workspace/creation/structured-create-first-surface-claim.ts @@ -0,0 +1,35 @@ +import { useAppStore } from '@/store/index' +import { getExecutionHostIdForWorktree } from '@/lib/workspace/ownership/worktree-runtime-owner' +import { parseExecutionHostId } from '../../../../../shared/execution-host' +import { + beginWebRuntimeInitialTerminalBootstrap, + endWebRuntimeInitialTerminalBootstrap +} from '@/runtime/web-session-tabs-sync/initial-terminal/web-runtime-initial-terminal-bootstrap' + +/** + * CLOUD-CHAT-FIRST-TAB: a create on a paired host (an Orca Cloud VM) whose first agent opens as a + * chat claims the workspace's initial-terminal bootstrap while that chat launches, like the + * after-wake first agent does. Without it the mirror's first empty frame seeds a plain shell, + * activated, in front of the chat. Released once the launch has answered: by then the chat tab + * counts as the workspace's first surface, and a launch that opened nothing gets its shell. + */ +export function claimFirstSurfaceForStructuredCreate(worktreeId: string): () => void { + const host = parseExecutionHostId( + getExecutionHostIdForWorktree(useAppStore.getState(), worktreeId) + ) + if (host?.kind !== 'runtime') { + return () => undefined + } + const { environmentId } = host + if (!beginWebRuntimeInitialTerminalBootstrap(environmentId, worktreeId)) { + // Someone else's create is already in flight; it owns the release. + return () => undefined + } + let released = false + return () => { + if (!released) { + released = true + endWebRuntimeInitialTerminalBootstrap(environmentId, worktreeId) + } + } +} diff --git a/src/renderer/src/lib/workspace/creation/worktree-creation-structured-session.ts b/src/renderer/src/lib/workspace/creation/worktree-creation-structured-session.ts index a15690b0f93..a78869ec227 100644 --- a/src/renderer/src/lib/workspace/creation/worktree-creation-structured-session.ts +++ b/src/renderer/src/lib/workspace/creation/worktree-creation-structured-session.ts @@ -1,4 +1,5 @@ import { useAppStore } from '@/store/index' +import { claimFirstSurfaceForStructuredCreate } from './structured-create-first-surface-claim' import { translate } from '@/i18n/i18n' import { activateAndRevealWorktree } from '@/lib/workspace/activation/worktree-activation' import type { ActivateAndRevealResult } from '@/lib/workspace/activation/worktree-activation-outcome' @@ -49,6 +50,18 @@ type LaunchStructuredWorktreeSessionArgs = { export async function launchStructuredWorktreeSession( args: LaunchStructuredWorktreeSessionArgs +): Promise { + // CLOUD-CHAT-FIRST-TAB: no seeded shell in front of the chat while it opens. + const releaseFirstSurface = claimFirstSurfaceForStructuredCreate(args.worktreeId) + try { + return await launchClaimedStructuredWorktreeSession(args) + } finally { + releaseFirstSurface() + } +} + +async function launchClaimedStructuredWorktreeSession( + args: LaunchStructuredWorktreeSessionArgs ): Promise { let { activation, primaryTabId } = args const settled = { accepted: true, cancelled: false } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/initial-terminal-seeder-chat-first.test.ts b/src/renderer/src/runtime/web-session-tabs-sync/initial-terminal-seeder-chat-first.test.ts new file mode 100644 index 00000000000..b240cba499b --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/initial-terminal-seeder-chat-first.test.ts @@ -0,0 +1,90 @@ +// CLOUD-CHAT-FIRST-TAB (END-PASS n51): an Orca Cloud create whose first agent opens as a chat got a +// second plain "Terminal" tab, in front: the mirror's first empty frame saw no terminal tabs (a chat +// is not one) and seeded a shell. A chat now counts as the workspace's first surface, and the create +// holds the seeder off while its chat launches. +import { afterEach, describe, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => ({ + tabsByWorktree: {} as Record, + unifiedTabsByWorktree: {} as Record, + owner: 'runtime:env-vm' as string +})) +vi.mock('../../store', () => ({ useAppStore: { getState: () => state } })) +vi.mock('@/store/index', () => ({ useAppStore: { getState: () => state } })) +vi.mock('@/lib/workspace/ownership/worktree-runtime-owner', () => ({ + getExecutionHostIdForWorktree: () => state.owner +})) +vi.mock('./frame-drawn-server-workspace-set', () => ({ isFrameDrawnServerWorkspace: () => false })) +vi.mock('./initial-terminal/web-runtime-initial-terminal-bootstrap-dispatch', () => ({ + dispatchWebRuntimeInitialTerminalBootstrap: async () => true +})) + +const { createInitialTerminalSeeder } = await import('./initial-terminal-seeder') +const { claimFirstSurfaceForStructuredCreate } = + await import('@/lib/workspace/creation/structured-create-first-surface-claim') +const { resetWebRuntimeInitialTerminalBootstrapForTests } = + await import('./initial-terminal/web-runtime-initial-terminal-bootstrap') + +const WT = 'wt-cloud' +// The host's first published answer: the workspace has no tabs yet. +const emptyFrame = { + type: 'snapshot', + worktree: WT, + tabs: [], + publicationEpoch: 'epoch-1' +} as const +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the decision reads only type, worktree, tabs and publicationEpoch +const frame = emptyFrame as unknown as Parameters< + ReturnType['decide'] +>[0] + +afterEach(() => { + state.tabsByWorktree = {} + state.unifiedTabsByWorktree = {} + state.owner = 'runtime:env-vm' + resetWebRuntimeInitialTerminalBootstrapForTests() +}) + +describe('the initial-terminal seeder beside a first chat', () => { + it('an empty new workspace still gets its shell', () => { + expect(createInitialTerminalSeeder('env-vm', WT).decide(frame, true)).toBe(true) + }) + + it('a workspace whose first tab is a chat gets no shell beside it', () => { + state.unifiedTabsByWorktree[WT] = [{ id: 'agent-session:s1', contentType: 'agent-session' }] + expect(createInitialTerminalSeeder('env-vm', WT).decide(frame, true)).toBe(false) + }) + + it('a non-chat unified tab (an editor) does not count as the first surface', () => { + state.unifiedTabsByWorktree[WT] = [{ id: 'editor:1', contentType: 'editor' }] + expect(createInitialTerminalSeeder('env-vm', WT).decide(frame, true)).toBe(true) + }) +}) + +describe('claimFirstSurfaceForStructuredCreate', () => { + it('holds the seeder off while the chat launches, and lets go once it has answered', () => { + const release = claimFirstSurfaceForStructuredCreate(WT) + // The chat tab is not there yet: the empty frame must not seed a shell. + expect(createInitialTerminalSeeder('env-vm', WT).decide(frame, true)).toBe(false) + release() + // A launch that opened nothing gets its shell after all. + expect(createInitialTerminalSeeder('env-vm', WT).decide(frame, true)).toBe(true) + // Releasing twice is harmless. + release() + }) + + it('a create on this machine claims nothing', () => { + state.owner = 'local' + const release = claimFirstSurfaceForStructuredCreate(WT) + expect(createInitialTerminalSeeder('env-vm', WT).decide(frame, true)).toBe(true) + release() + }) + + it('a claim someone else holds is left to them', () => { + const first = claimFirstSurfaceForStructuredCreate(WT) + const second = claimFirstSurfaceForStructuredCreate(WT) + second() + expect(createInitialTerminalSeeder('env-vm', WT).decide(frame, true)).toBe(false) + first() + }) +}) 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 index 420feb807ed..3c279112181 100644 --- 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 @@ -52,7 +52,12 @@ export function createInitialTerminalSeeder( requestedInitialTerminal || isWebRuntimeInitialTerminalBootstrapInFlight(environmentId, worktreeId), snapshotIsFresh: true, - localTerminalCount: (state.tabsByWorktree[worktreeId] ?? []).length, + // CLOUD-CHAT-FIRST-TAB: a chat is the workspace's first surface too; no shell is seeded beside it. + localTerminalCount: + (state.tabsByWorktree[worktreeId] ?? []).length + + (state.unifiedTabsByWorktree?.[worktreeId] ?? []).filter( + (tab) => tab.contentType === 'agent-session' + ).length, hasPersistedTerminalState: Object.hasOwn(state.tabsByWorktree, worktreeId), firstHostAnswer })