diff --git a/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts b/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts new file mode 100644 index 00000000000..8b941ca265c --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' +import { OrcaRuntimeService } from './orca-runtime' + +const readStructuredTuiProcessIdentity = vi.hoisted(() => vi.fn()) + +vi.mock('./structured-tui-process-identity', () => ({ readStructuredTuiProcessIdentity })) + +const WORKTREE_ID = 'repo-1::/tmp/structured-handoff' + +function notifier(revealTerminalSession: ReturnType) { + return { + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + } +} + +describe('structured TUI launch tab binding', () => { + it('proves the published launch tab before returning its revealed renderer binding', async () => { + const revealTerminalSession = vi.fn( + (_worktreeId: string, _options: { tabId?: string; leafId?: string; ptyId?: string }) => + Promise.resolve({ tabId: 'tab-renderer' }) + ) + const runtime = new OrcaRuntimeService({ + getSettings: () => ({ + disabledTuiAgents: [], + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {} + }) + } as never) + runtime.setNotifier(notifier(revealTerminalSession) as never) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-structured', pid: 4242 }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + const internal = runtime as unknown as { + createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport + resolveTerminalWorkspaceLaunchScope(): Promise<{ + id: string + path: string + connectionId: null + repo: null + folderWorkspace: null + }> + markLocalWorkspaceTrustedForAgent(): void + waitForTerminal(): Promise + waitForStructuredTuiProof(): Promise<{ transcriptPath?: string }> + } + internal.resolveTerminalWorkspaceLaunchScope = vi.fn(async () => ({ + id: WORKTREE_ID, + path: '/tmp/structured-handoff', + connectionId: null, + repo: null, + folderWorkspace: null + })) + internal.markLocalWorkspaceTrustedForAgent = vi.fn() + const waitForTerminal = vi.fn(async () => ({})) + internal.waitForTerminal = waitForTerminal + const waitForStructuredTuiProof = vi.fn(async () => { + const snapshot = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + expect(snapshot.tabs).toContainEqual( + expect.objectContaining({ + type: 'terminal', + parentTabId: expect.any(String), + leafId: expect.any(String), + ptyId: 'pty-structured', + terminal: expect.any(String) + }) + ) + expect(revealTerminalSession).not.toHaveBeenCalled() + return { transcriptPath: '/tmp/rollout.jsonl' } + }) + internal.waitForStructuredTuiProof = waitForStructuredTuiProof + readStructuredTuiProcessIdentity.mockResolvedValue({ + hostId: 'local', + pid: 4243, + processStartTimeMs: 10, + spawnToken: 'spawn-token' + }) + + const transport = internal.createStructuredAgentSessionHandoffTransport() + const owner = await transport.launchTui({ + record: { + sessionId: 'session-1', + location: { workspaceId: WORKTREE_ID, executionHostId: 'local' }, + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, + providerHandleChain: [ + { handle: { provider: 'codex', threadId: 'thread-1' }, observedAt: 1 } + ] + } as never, + fence: 3, + spawnToken: 'spawn-token' + }) + + const reveal = revealTerminalSession.mock.calls[0]?.[1] as { + tabId: string + leafId: string + } + expect(owner.terminal).toMatchObject({ + tabId: 'tab-renderer', + paneKey: `${reveal.tabId}:${reveal.leafId}` + }) + expect(waitForTerminal).toHaveBeenCalledWith( + owner.terminal.handle, + expect.objectContaining({ condition: 'tui-idle' }) + ) + expect(waitForStructuredTuiProof).toHaveBeenCalledOnce() + expect(waitForStructuredTuiProof.mock.invocationCallOrder[0]).toBeLessThan( + revealTerminalSession.mock.invocationCallOrder[0]! + ) + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 28a2d68b10f..2fafd5a8c08 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -9123,6 +9123,10 @@ export class OrcaRuntimeService { if (!terminal.processId || !terminal.paneKey || !terminal.tabId) { throw new Error('The resumed terminal did not publish a process identity.') } + await this.waitForTerminal(terminal.handle, { + condition: 'tui-idle', + timeoutMs: 30_000 + }) const proof = await this.waitForStructuredTuiProof({ handle: terminal.handle, paneKey: terminal.paneKey, @@ -9131,11 +9135,11 @@ export class OrcaRuntimeService { codexHome: record.accountHome.path, sessionId: record.sessionId }) - await this.focusTerminal(terminal.handle) + const revealed = await this.focusTerminal(terminal.handle) return { terminal: { handle: terminal.handle, - tabId: terminal.tabId, + tabId: revealed.tabId, paneKey: terminal.paneKey }, process: await readStructuredTuiProcessIdentity({ diff --git a/src/renderer/src/runtime/web-session-structured-tab-focus.test.ts b/src/renderer/src/runtime/web-session-structured-tab-focus.test.ts new file mode 100644 index 00000000000..a42a3fb9a90 --- /dev/null +++ b/src/renderer/src/runtime/web-session-structured-tab-focus.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import type { Tab } from '../../../shared/types' +import { applyWebSessionTabsSnapshot, type WebSessionTabsSyncState } from './web-session-tabs-sync' + +const WORKTREE_ID = 'repo-1::/worktree' +const GROUP_ID = 'group-1' + +function structuredTab(sessionId: string, sortOrder: number): Tab { + return { + id: `structured-agent-session-${sessionId}`, + entityId: sessionId, + groupId: GROUP_ID, + worktreeId: WORKTREE_ID, + contentType: 'agent-session', + agentSessionAgent: 'codex', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder, + createdAt: sortOrder + 1 + } +} + +describe('web session structured tab focus', () => { + it('keeps the exact active structured tab across a host snapshot', () => { + const first = structuredTab('session-1', 0) + const second = structuredTab('session-2', 1) + const state = { + activeBrowserTabId: null, + activeBrowserTabIdByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + activeGroupIdByWorktree: { [WORKTREE_ID]: GROUP_ID }, + activeTabId: null, + activeTabIdByWorktree: {}, + activeTabType: 'terminal', + activeTabTypeByWorktree: { [WORKTREE_ID]: 'terminal' }, + activeWorktreeId: WORKTREE_ID, + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + browserCertificateFailuresByPageId: {}, + browserPagesByWorkspace: {}, + browserTabsByWorktree: {}, + groupsByWorktree: { + [WORKTREE_ID]: [ + { + id: GROUP_ID, + worktreeId: WORKTREE_ID, + activeTabId: second.id, + tabOrder: [first.id, second.id] + } + ] + }, + layoutByWorktree: {}, + openFiles: [], + ptyIdsByTabId: {}, + remoteBrowserPageHandlesByPageId: {}, + tabBarOrderByWorktree: { [WORKTREE_ID]: [first.id, second.id] }, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabsByWorktree: { [WORKTREE_ID]: [first, second] }, + unreadTerminalTabs: {}, + sortEpoch: 0 + } as WebSessionTabsSyncState + const snapshot: RuntimeMobileSessionTabsResult = { + worktree: WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 2, + activeGroupId: GROUP_ID, + activeTabId: 'agent-session:session-1', + activeTabType: 'agent-session', + tabGroups: [ + { + id: GROUP_ID, + activeTabId: 'agent-session:session-1', + tabOrder: ['agent-session:session-1', 'agent-session:session-2'] + } + ], + tabs: [ + { + type: 'agent-session', + id: 'agent-session:session-1', + title: 'Codex Chat', + sessionId: 'session-1', + agent: 'codex', + isActive: true + }, + { + type: 'agent-session', + id: 'agent-session:session-2', + title: 'Codex Chat', + sessionId: 'session-2', + agent: 'codex', + isActive: false + } + ] + } + + const patch = applyWebSessionTabsSnapshot(state, snapshot, 'environment-1', 10) + + expect(patch.groupsByWorktree?.[WORKTREE_ID]?.[0]?.activeTabId).toBe(second.id) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 39cdbe4d4dc..60341eb4e20 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -2004,6 +2004,18 @@ function findCurrentVisibleUnifiedTabId(args: { if (!nextUnifiedTabs) { return null } + const activeGroupId = state.activeGroupIdByWorktree[worktreeId] + const activeGroupTabId = state.groupsByWorktree[worktreeId]?.find( + (group) => group.id === activeGroupId + )?.activeTabId + if ( + activeGroupTabId && + nextUnifiedTabs.some( + (tab) => tab.id === activeGroupTabId && tab.contentType === 'agent-session' + ) + ) { + return activeGroupTabId + } const currentVisibleType = state.activeTabTypeByWorktree[worktreeId] ?? (state.activeWorktreeId === worktreeId ? state.activeTabType : null)