diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 208e4530a92..6f301b2ad5d 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -17066,6 +17066,16 @@ describe('OrcaRuntimeService', () => { it('creates mobile session terminals in a headless runtime server', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-headless' }) const runtime = new OrcaRuntimeService(store) + const persistViewMode = vi.spyOn( + runtime as unknown as { + persistHeadlessSessionTabProps: ( + worktreeId: string, + tabId: string, + props: { viewMode: 'terminal' | 'chat' } + ) => void + }, + 'persistHeadlessSessionTabProps' + ) runtime.setPtyController({ spawn, write: () => true, @@ -17095,6 +17105,9 @@ describe('OrcaRuntimeService', () => { viewMode: 'chat', isActive: true }) + expect(persistViewMode).toHaveBeenCalledWith(TEST_WORKTREE_ID, result.tab.parentTabId, { + viewMode: 'chat' + }) const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) expect(listed.tabs).toEqual([ @@ -20766,7 +20779,8 @@ describe('OrcaRuntimeService', () => { }) const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { - activate: true + activate: true, + viewMode: 'chat' }) let settled = false const settledCreate = create.finally(() => { @@ -20774,8 +20788,35 @@ describe('OrcaRuntimeService', () => { }) await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1)) + // A shell-only renderer snapshot can win the first race but still omit + // launch props. The later PTY rescue must fill the explicit mode. + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-shell', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: `tab-alive::${leafId}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `tab-alive::${leafId}`, + parentTabId: 'tab-alive', + leafId, + title: 'Terminal', + isActive: true + } + ] + } + ] + }) + // The renderer's own PTY spawn registers with the tab binding — the same - // call the pty IPC layer now makes — without any mobileSessionTabs sync. + // call the pty IPC layer now makes — after the shell-only snapshot. runtime.registerPty('pty-alive', TEST_WORKTREE_ID, null, { tabId: 'tab-alive', leafId @@ -20791,7 +20832,8 @@ describe('OrcaRuntimeService', () => { parentTabId: 'tab-alive', leafId, status: 'ready', - terminal: expect.stringMatching(/^term_/) + terminal: expect.stringMatching(/^term_/), + viewMode: 'chat' }) expect(closeTerminal).not.toHaveBeenCalled() } finally { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index a4ad656b4c4..cf0b92078bd 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2139,7 +2139,11 @@ export class OrcaRuntimeService { // creates so ordinary renderer spawns never publish here. private pendingMobileTerminalCreatesByKey = new Map< string, - { activate: boolean; selectIfNoActiveTab: boolean } + { + activate: boolean + selectIfNoActiveTab: boolean + viewMode?: 'terminal' | 'chat' + } >() private mobileSessionTabListeners = new Set<(snapshot: RuntimeMobileSessionTabsResult) => void>() // Why: coalesces title/status-driven session.tabs emits so spinner churn @@ -3663,6 +3667,7 @@ export class OrcaRuntimeService { activate: boolean selectIfNoActiveTab?: boolean startupCwd?: string + viewMode?: 'terminal' | 'chat' split?: { splitFromLeafId: string; direction: 'horizontal' | 'vertical' } } ): void { @@ -3694,6 +3699,17 @@ export class OrcaRuntimeService { baseLayout, args.split ) + // Why: a main-side PTY rescue or split publication must not erase the + // host's explicit tab mode before the renderer graph catches up. + const viewMode = + args.viewMode ?? + existingTab?.viewMode ?? + existing?.tabs.find( + (candidate): candidate is RuntimeMobileSessionTerminalTab => + candidate.type === 'terminal' && + candidate.parentTabId === args.tabId && + candidate.viewMode !== undefined + )?.viewMode const tab: RuntimeMobileSessionTerminalTab = { type: 'terminal', id: `${args.tabId}::${args.leafId}`, @@ -3703,6 +3719,7 @@ export class OrcaRuntimeService { title, ...(pty.launchAgent ? { launchAgent: pty.launchAgent } : {}), ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), + ...(viewMode ? { viewMode } : {}), parentLayout, isActive: args.activate || (args.selectIfNoActiveTab !== false && existing?.activeTabId == null) @@ -18016,7 +18033,8 @@ export class OrcaRuntimeService { // requested group, so any wrong-group placement is cosmetic and stall-window-only. this.pendingMobileTerminalCreatesByKey.set(pendingCreateKey, { activate: opts.activate !== false, - selectIfNoActiveTab: true + selectIfNoActiveTab: true, + ...(opts.viewMode ? { viewMode: opts.viewMode } : {}) }) try { // Why: the PTY spawn and the tabCreate reply race on independent IPC @@ -18224,6 +18242,11 @@ export class OrcaRuntimeService { } const parentTabId = livePty.pty.tabId ?? `pty:${livePty.pty.ptyId}` const leafId = parsePaneKey(livePty.pty.paneKey ?? '')?.leafId ?? randomUUID() + if (opts.viewMode) { + // Why: the runtime-owned binding must survive a serve restart with the + // same initial mode, not fall back to a later client's local default. + this.persistHeadlessSessionTabProps(worktreeId, parentTabId, { viewMode: opts.viewMode }) + } const existing = this.mobileSessionTabsByWorktree.get(worktreeId) const existingSurface = existing?.tabs.find( @@ -18393,21 +18416,27 @@ export class OrcaRuntimeService { return null } const existing = this.findMobileTerminalSurface(worktreeId, tabId) - if (existing) { - // Why: the renderer's own publication already landed; stay idempotent. + if ( + existing && + this.isReadyMobileTerminalSurface(existing) && + (pending.viewMode === undefined || existing.tab.viewMode === pending.viewMode) + ) { + // Why: the renderer's ready publication already landed with the intended + // mode; only a pending shell still needs the main-side PTY rescue. return existing } const pty = this.findLiveRegisteredPtyForRendererTab(worktreeId, tabId) const leafId = pty ? parsePaneKey(pty.paneKey ?? '')?.leafId : undefined if (!pty || !leafId) { - return null + return existing } this.publishPtyBackedMobileSessionTerminal(worktreeId, pty, { tabId, leafId, title: null, activate: pending.activate, - selectIfNoActiveTab: pending.selectIfNoActiveTab + selectIfNoActiveTab: pending.selectIfNoActiveTab, + ...(pending.viewMode ? { viewMode: pending.viewMode } : {}) }) // Why: waitForMobileTerminalSurface's check closures are drained only inside // syncWindowGraph; a main-side publish must drain them too or the pending diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 055ee8b3d19..467ca7d07fe 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -300,7 +300,8 @@ describe('launchAgentInNewTab', () => { environmentId: 'web-runtime', targetGroupId: 'group-1', activate: true, - agent: 'claude' + agent: 'claude', + viewMode: 'terminal' }) expect(mockCreateTab).not.toHaveBeenCalled() expect(mockQueueTabStartupCommand).not.toHaveBeenCalled() @@ -345,7 +346,8 @@ describe('launchAgentInNewTab', () => { agentArgs: '--model gpt-5 --reasoning-effort high', agentEnv: { CODEX_PROFILE: 'captured' } }, - launchAgent: 'codex' + launchAgent: 'codex', + viewMode: 'terminal' }) expect(mockCreateTab).not.toHaveBeenCalled() expect(mockQueueTabStartupCommand).not.toHaveBeenCalled() @@ -375,6 +377,30 @@ describe('launchAgentInNewTab', () => { ) }) + it('propagates the resolved terminal mode to paired web runtime launches', async () => { + mockIsWebRuntimeSessionActive.mockReturnValue(true) + store.settings = { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: 'web-runtime', + experimentalNativeChat: true, + openAgentTabsInChatByDefault: false + } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(mockCreateWebRuntimeSessionTerminal).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId: 'wt-1', + environmentId: 'web-runtime', + agent: 'codex', + viewMode: 'terminal' + }) + ) + }) + it('surfaces a toast when host agent launch fails in paired web clients', async () => { mockIsWebRuntimeSessionActive.mockReturnValue(true) mockCreateWebRuntimeSessionTerminal.mockResolvedValue(false) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 6b8a0e5ae4e..292cc9e4626 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -222,7 +222,9 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI groupId, hasPrompt, startupPlan, - ...initialViewModeProps, + // Why: omission means terminal locally, but would let a paired host apply + // its own default; send the client's resolved terminal choice explicitly. + viewMode: initialViewModeProps.viewMode ?? 'terminal', onPromptDelivered }) return { tabId: null, startupPlan, pasteDraftAfterLaunch: false }