From 476b6f97d056caf3e2efbaa62c4253f6245e893c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:06:59 -0700 Subject: [PATCH] fix(native-chat): preserve initial chat mode on paired-host launches (#8567) * fix(native-chat): preserve initial chat mode on paired-host launches * fix(native-chat): keep paired launch mode authoritative * fix(native-chat): preserve mode through PTY materialization --- src/main/runtime/orca-runtime.test.ts | 66 ++++++++++++++++--- src/main/runtime/orca-runtime.ts | 54 +++++++++++++-- .../rpc/methods/session-tabs-schemas.ts | 1 + .../runtime/rpc/methods/session-tabs.test.ts | 2 + src/main/runtime/rpc/methods/session-tabs.ts | 1 + .../attach-main-window-services.test.ts | 13 +++- .../window/attach-main-window-services.ts | 1 + src/preload/api-types.ts | 1 + src/preload/index.ts | 2 + src/renderer/src/hooks/useIpcEvents.test.ts | 41 +++++++++++- src/renderer/src/hooks/useIpcEvents.ts | 35 ++++++---- .../src/lib/launch-agent-in-new-tab.test.ts | 54 ++++++++++++++- .../src/lib/launch-agent-in-new-tab.ts | 25 ++++--- .../src/lib/launch-agent-web-host-tab.ts | 16 ++++- .../src/runtime/web-runtime-session.test.ts | 2 + .../src/runtime/web-runtime-session.ts | 2 + src/shared/runtime-types.ts | 1 + 17 files changed, 272 insertions(+), 45 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 9aa5c00a853..96ba22d5cea 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, @@ -17074,7 +17084,9 @@ describe('OrcaRuntimeService', () => { }) runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) - const result = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`) + const result = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + viewMode: 'chat' + }) expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ @@ -17090,8 +17102,12 @@ describe('OrcaRuntimeService', () => { type: 'terminal', status: 'ready', terminal: expect.stringMatching(/^term_/), + 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([ @@ -20138,7 +20154,8 @@ describe('OrcaRuntimeService', () => { }) const result = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { - activate: false + activate: false, + viewMode: 'chat' }) expect(send).toHaveBeenCalledWith( @@ -20146,7 +20163,8 @@ describe('OrcaRuntimeService', () => { expect.objectContaining({ worktreeId: TEST_WORKTREE_ID, activate: false, - source: 'runtime-session' + source: 'runtime-session', + viewMode: 'chat' }) ) expect(focusTerminal).not.toHaveBeenCalled() @@ -20504,7 +20522,8 @@ describe('OrcaRuntimeService', () => { }) const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { - activate: true + activate: true, + viewMode: 'terminal' }) let settled = false const settledCreate = create.finally(() => { @@ -20550,6 +20569,7 @@ describe('OrcaRuntimeService', () => { leafId: pendingLeafId, status: 'ready', terminal: expect.stringMatching(/^term_/), + viewMode: 'terminal', isActive: true }) expect(spawn).toHaveBeenCalledWith( @@ -20567,7 +20587,8 @@ describe('OrcaRuntimeService', () => { expect.objectContaining({ ptyId: 'pty-materialized', tabId: 'tab-pending', - leafId: pendingLeafId + leafId: pendingLeafId, + viewMode: 'terminal' }) ) expect(closeTerminal).not.toHaveBeenCalled() @@ -20761,7 +20782,8 @@ describe('OrcaRuntimeService', () => { }) const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { - activate: true + activate: true, + viewMode: 'chat' }) let settled = false const settledCreate = create.finally(() => { @@ -20769,8 +20791,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 @@ -20786,7 +20835,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 09185299c82..6f1ca862fbe 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1020,6 +1020,7 @@ type TerminalCreateOptions = { launchConfig?: WorktreeStartupLaunch['launchConfig'] launchToken?: string launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] telemetry?: WorktreeStartupLaunch['telemetry'] title?: string @@ -1318,6 +1319,7 @@ type RuntimeNotifier = { launchConfig?: SleepingAgentLaunchConfig launchToken?: string launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' activate?: boolean presentation?: RuntimeTerminalPresentation tabId?: string @@ -2139,7 +2141,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 +3669,7 @@ export class OrcaRuntimeService { activate: boolean selectIfNoActiveTab?: boolean startupCwd?: string + viewMode?: 'terminal' | 'chat' split?: { splitFromLeafId: string; direction: 'horizontal' | 'vertical' } } ): void { @@ -3694,6 +3701,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 +3721,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) @@ -17718,6 +17737,7 @@ export class OrcaRuntimeService { // Why: explicit background presentation may carry legacy activate // metadata from an already-owned renderer pane; don't select it on mobile. selectIfNoActiveTab: presentation !== 'background', + ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), ...(cwd !== workspace.path ? { startupCwd: cwd } : {}) }) } @@ -17736,6 +17756,7 @@ export class OrcaRuntimeService { ...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}), ...(launchToken ? { launchToken } : {}), ...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}), + ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), activate: presentation === 'focused', ...(presentation ? { presentation } : {}), tabId, @@ -17811,6 +17832,7 @@ export class OrcaRuntimeService { ...(launchOpts.launchConfig ? { launchConfig: launchOpts.launchConfig } : {}), ...(launchOpts.launchToken ? { launchToken: launchOpts.launchToken } : {}), ...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}), + ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), startupCommandDelivery: launchOpts.startupCommandDelivery, title: launchOpts.title, activate: presentation === 'focused', @@ -17869,6 +17891,7 @@ export class OrcaRuntimeService { agent?: TuiAgent launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' activate?: boolean clientMutationId?: string signal?: AbortSignal @@ -17913,6 +17936,7 @@ export class OrcaRuntimeService { agent?: TuiAgent launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' activate?: boolean clientMutationId?: string signal?: AbortSignal @@ -17946,6 +17970,7 @@ export class OrcaRuntimeService { env: startupCommand.env, startupCommandDelivery: startupCommand.startupCommandDelivery, launchAgent: startupCommand.launchAgent, + viewMode: opts.viewMode, targetGroupId: opts.targetGroupId, launchConfig: startupCommand.launchConfig } @@ -17994,6 +18019,7 @@ export class OrcaRuntimeService { ...(startupCommand.env ? { env: startupCommand.env } : {}), ...(startupCommand.launchConfig ? { launchConfig: startupCommand.launchConfig } : {}), ...(startupCommand.launchAgent ? { launchAgent: startupCommand.launchAgent } : {}), + ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), startupCommandDelivery: startupCommand.startupCommandDelivery, source: 'runtime-session', activate: opts.activate @@ -18012,7 +18038,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 @@ -18057,6 +18084,7 @@ export class OrcaRuntimeService { startupCommandDelivery: startupCommand.startupCommandDelivery, identity: { tabId: pendingSurface.tab.parentTabId, leafId: pendingSurface.tab.leafId }, launchAgent: startupCommand.launchAgent, + viewMode: opts.viewMode, targetGroupId: opts.targetGroupId, launchConfig: startupCommand.launchConfig } @@ -18180,6 +18208,7 @@ export class OrcaRuntimeService { startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] identity?: { tabId: string; leafId: string; sessionId?: string } launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' targetGroupId?: string launchConfig?: SleepingAgentLaunchConfig } = {} @@ -18197,6 +18226,7 @@ export class OrcaRuntimeService { env: opts.env, ...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}), ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), + ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), startupCommandDelivery: opts.startupCommandDelivery, ...(opts.identity ? { @@ -18218,6 +18248,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( @@ -18240,6 +18275,7 @@ export class OrcaRuntimeService { title: terminal.title ?? livePty.pty.title ?? 'Terminal', ...(cwd ? { startupCwd: cwd } : {}), ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), + ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), parentLayout, isActive: activate } @@ -18386,21 +18422,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/main/runtime/rpc/methods/session-tabs-schemas.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.ts index 0c21af7de72..1b8afbd2f46 100644 --- a/src/main/runtime/rpc/methods/session-tabs-schemas.ts +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.ts @@ -130,6 +130,7 @@ export const CreateTerminalTab = WorktreeTabSelector.extend({ message: 'Unknown launch agent' }) .optional(), + viewMode: z.enum(['terminal', 'chat']).optional(), activate: z.boolean().optional(), // Why: idempotency key so a retried create (double-tap, reconnect replay) // returns the in-flight operation instead of spawning a duplicate terminal. diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index 625959f6d64..d81faa169cd 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -148,6 +148,7 @@ describe('session tab RPC methods', () => { agentEnv: { CODEX_PROFILE: 'captured' } }, launchAgent: 'codex', + viewMode: 'chat', activate: true }) ) @@ -167,6 +168,7 @@ describe('session tab RPC methods', () => { agentEnv: { CODEX_PROFILE: 'captured' } }, launchAgent: 'codex', + viewMode: 'chat', activate: true }) }) diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index d27d771f321..55be4d0b21e 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -53,6 +53,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ ...(params.launchConfig ? { launchConfig: params.launchConfig } : {}), ...(params.launchToken ? { launchToken: params.launchToken } : {}), ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), + ...(params.viewMode ? { viewMode: params.viewMode } : {}), activate: params.activate, clientMutationId: params.clientMutationId, // Why: a dead client connection must cancel the surface wait instead diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 718711b629d..c2a730f9026 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -631,13 +631,20 @@ describe('attachMainWindowServices', () => { const notifier = runtime.setNotifier.mock.calls[0][0] as { revealTerminalSession: ( worktreeId: string, - opts: { ptyId: string; title?: string; cwd?: string; activate?: boolean } + opts: { + ptyId: string + title?: string + cwd?: string + viewMode?: 'terminal' | 'chat' + activate?: boolean + } ) => Promise<{ tabId: string; title?: string }> } const revealPromise = notifier.revealTerminalSession('wt-1', { ptyId: 'pty-1', title: 'SSH tmux', - cwd: '/repo/packages/web' + cwd: '/repo/packages/web', + viewMode: 'chat' }) const sentPayload = sendMock.mock.calls.find( ([channel]) => channel === 'ui:createTerminal' @@ -645,7 +652,7 @@ describe('attachMainWindowServices', () => { const handler = onMock.mock.calls.find( ([channel]) => channel === 'terminal:tabCreateReply' )?.[1] - expect(sentPayload.cwd).toBe('/repo/packages/web') + expect(sentPayload).toMatchObject({ cwd: '/repo/packages/web', viewMode: 'chat' }) handler?.( { sender: { send: vi.fn() } }, diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index cc03c40a904..0dd54c2c598 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -328,6 +328,7 @@ function registerRuntimeWindowLifecycle( ...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}), ...(opts.launchToken ? { launchToken: opts.launchToken } : {}), ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), + ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), activate: opts.activate !== false, ...(opts.presentation ? { presentation: opts.presentation } : {}), // Why: pre-minted tabId from main keeps the renderer's tab id aligned diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 599c1a8f81b..9fd010a8695 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2800,6 +2800,7 @@ export type PreloadApi = { launchConfig?: SleepingAgentLaunchConfig launchToken?: string launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' title?: string ptyId?: string activate?: boolean diff --git a/src/preload/index.ts b/src/preload/index.ts index 83466e7b962..9e5b7e419e1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3445,6 +3445,7 @@ const api = { launchConfig?: SleepingAgentLaunchConfig launchToken?: string launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' title?: string ptyId?: string activate?: boolean @@ -3467,6 +3468,7 @@ const api = { launchConfig?: SleepingAgentLaunchConfig launchToken?: string launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' title?: string ptyId?: string activate?: boolean diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index dee27778244..ec7764ec287 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -1732,6 +1732,7 @@ describe('useIpcEvents updater integration', () => { command?: string launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' title?: string ptyId?: string activate?: boolean @@ -1760,6 +1761,7 @@ describe('useIpcEvents updater integration', () => { cwd?: string launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' title?: string activate?: boolean presentation?: 'background' | 'focused' @@ -1877,6 +1879,7 @@ describe('useIpcEvents updater integration', () => { command?: string launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' title?: string ptyId?: string activate?: boolean @@ -1906,6 +1909,7 @@ describe('useIpcEvents updater integration', () => { cwd?: string launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' title?: string activate?: boolean presentation?: 'background' | 'focused' @@ -2223,11 +2227,16 @@ describe('useIpcEvents updater integration', () => { targetGroupId: 'group-left', title: 'Runtime Terminal', command: 'codex', + launchAgent: 'codex', + viewMode: 'terminal', activate: true, source: 'runtime-session' }) - expect(createTab).toHaveBeenCalledWith('wt-2', 'group-left', undefined, undefined) + expect(createTab).toHaveBeenCalledWith('wt-2', 'group-left', undefined, { + launchAgent: 'codex', + viewMode: 'terminal' + }) expect(replyTerminalCreate).toHaveBeenCalledWith({ requestId: 'req-runtime-session', tabId: 'tab-new', @@ -2364,6 +2373,36 @@ describe('useIpcEvents updater integration', () => { } ) + createTab.mockClear() + createTerminalListenerRef.current({ + worktreeId: 'wt-2', + ptyId: 'pty-explicit-terminal', + launchAgent: 'codex', + viewMode: 'terminal' + }) + expect(createTab).toHaveBeenCalledWith('wt-2', undefined, undefined, { + initialPtyId: 'pty-explicit-terminal', + activate: false, + launchAgent: 'codex', + viewMode: 'terminal' + }) + + createTab.mockClear() + storeState.settings.openAgentTabsInChatByDefault = false + createTerminalListenerRef.current({ + worktreeId: 'wt-2', + ptyId: 'pty-explicit-chat', + launchAgent: 'codex', + viewMode: 'chat' + }) + expect(createTab).toHaveBeenCalledWith('wt-2', undefined, undefined, { + initialPtyId: 'pty-explicit-chat', + activate: false, + launchAgent: 'codex', + viewMode: 'chat' + }) + storeState.settings.openAgentTabsInChatByDefault = true + createTab.mockClear() setActiveView.mockClear() setActiveWorktree.mockClear() diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index b93f3d1610b..e341ff8a185 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1434,6 +1434,7 @@ export function useIpcEvents(): void { launchConfig, launchToken, launchAgent, + viewMode, title, ptyId, activate, @@ -1502,13 +1503,17 @@ export function useIpcEvents(): void { ...(launchAgent ? { launchAgent, - ...initialAgentTabViewModeProps(store.settings, { - agent: launchAgent, - nativeChatTranscriptIsLocalReadable: - isNativeChatTranscriptLocalReadable( - getConnectionIdFromState(store, worktreeId) - ) - }) + // Why: a paired client resolved explicit mode before + // PTY materialization; only omitted mode uses host defaults. + ...(viewMode + ? { viewMode } + : initialAgentTabViewModeProps(store.settings, { + agent: launchAgent, + nativeChatTranscriptIsLocalReadable: + isNativeChatTranscriptLocalReadable( + getConnectionIdFromState(store, worktreeId) + ) + })) } : {}), ...(cwd ? { startupCwd: cwd } : {}), @@ -1684,16 +1689,20 @@ export function useIpcEvents(): void { if (shouldActivate) { activateTerminalInitiatedWorktree(store, worktreeId) } + // Why: the paired launch client already resolved the initial mode, so + // its explicit choice must win over this host renderer's local default. const tabOptions = data.launchAgent ? { ...(shouldActivate ? {} : { activate: false, recordInteraction: false }), launchAgent: data.launchAgent, - ...initialAgentTabViewModeProps(store.settings, { - agent: data.launchAgent, - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( - getConnectionIdFromState(store, worktreeId) - ) - }), + ...(data.viewMode + ? { viewMode: data.viewMode } + : initialAgentTabViewModeProps(store.settings, { + agent: data.launchAgent, + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + getConnectionIdFromState(store, worktreeId) + ) + })), ...(data.cwd ? { startupCwd: data.cwd } : {}) } : shouldActivate 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 fa7283725c4..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,12 +346,61 @@ 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() }) + it('propagates the default chat mode to paired web runtime launches', async () => { + mockIsWebRuntimeSessionActive.mockReturnValue(true) + store.settings = { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: 'web-runtime', + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true + } + 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: 'chat' + }) + ) + }) + + 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 52255ac16cf..292cc9e4626 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -201,6 +201,18 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI return null } + // Why: host-owned paired tabs must receive the same initial-view decision as + // local tabs; the remote host cannot infer this client's draft/default choice. + const viewModePromptDelivery = + hasPrompt && isFollowupPath && promptDelivery === 'auto-submit' ? 'draft' : promptDelivery + const initialViewModeProps = initialAgentTabViewModeProps(store.settings, { + agent, + promptDelivery: viewModePromptDelivery, + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + getConnectionIdFromState(store, worktreeId) + ) + }) + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, worktreeId) if (isWebRuntimeSessionActive(runtimeEnvironmentId) && pasteDraftAfterLaunch === null) { launchAgentInWebHostTab({ @@ -210,6 +222,9 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI groupId, hasPrompt, startupPlan, + // 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 } @@ -224,18 +239,10 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI // stays false), so gate the initial chat view like a `draft` launch — // otherwise a default `auto-submit` followup would open native chat with no // submitted turn to render. - const viewModePromptDelivery = - hasPrompt && isFollowupPath && promptDelivery === 'auto-submit' ? 'draft' : promptDelivery const tab = store.createTab(worktreeId, groupId, undefined, { launchAgent: agent, quickCommandLabel, - ...initialAgentTabViewModeProps(store.settings, { - agent, - promptDelivery: viewModePromptDelivery, - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( - getConnectionIdFromState(store, worktreeId) - ) - }) + ...initialViewModeProps }) store.queueTabStartupCommand(tab.id, { command: startupPlan.launchCommand, diff --git a/src/renderer/src/lib/launch-agent-web-host-tab.ts b/src/renderer/src/lib/launch-agent-web-host-tab.ts index b95ccd8aba1..c685dea9968 100644 --- a/src/renderer/src/lib/launch-agent-web-host-tab.ts +++ b/src/renderer/src/lib/launch-agent-web-host-tab.ts @@ -5,7 +5,7 @@ import { createWebRuntimeSessionTerminal, isWebTerminalSurfaceTabId } from '@/runtime/web-runtime-session' -import type { TuiAgent } from '../../../shared/types' +import type { Tab, TuiAgent } from '../../../shared/types' import { translate } from '@/i18n/i18n' function removeStaleLocalAgentTabsForWebHostLaunch(worktreeId: string): void { @@ -32,16 +32,26 @@ export function launchAgentInWebHostTab(args: { groupId?: string hasPrompt: boolean startupPlan: AgentStartupPlan + viewMode?: Tab['viewMode'] onPromptDelivered?: () => void }): void { - const { agent, worktreeId, environmentId, groupId, hasPrompt, startupPlan, onPromptDelivered } = - args + const { + agent, + worktreeId, + environmentId, + groupId, + hasPrompt, + startupPlan, + viewMode, + onPromptDelivered + } = args removeStaleLocalAgentTabsForWebHostLaunch(worktreeId) void createWebRuntimeSessionTerminal({ worktreeId, environmentId, targetGroupId: groupId, activate: true, + ...(viewMode ? { viewMode } : {}), ...(hasPrompt ? { command: startupPlan.launchCommand, diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index 85025022fea..af47e5a0350 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -490,6 +490,7 @@ describe('createWebRuntimeSessionTerminal', () => { agentEnv: { CODEX_PROFILE: 'captured' } }, launchAgent: 'codex', + viewMode: 'chat', activate: true }) ).resolves.toBe(true) @@ -510,6 +511,7 @@ describe('createWebRuntimeSessionTerminal', () => { agentEnv: { CODEX_PROFILE: 'captured' } }, launchAgent: 'codex', + viewMode: 'chat', activate: true }, timeoutMs: 15_000 diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index 338e92e8656..8cfc23eb19c 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -56,6 +56,7 @@ export async function createWebRuntimeSessionTerminal(args: { launchConfig?: SleepingAgentLaunchConfig agent?: TuiAgent launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' activate?: boolean selectWorktree?: boolean }): Promise { @@ -85,6 +86,7 @@ export async function createWebRuntimeSessionTerminal(args: { ...(args.launchConfig ? { launchConfig: args.launchConfig } : {}), agent: args.agent, ...(args.launchAgent ? { launchAgent: args.launchAgent } : {}), + ...(args.viewMode ? { viewMode: args.viewMode } : {}), activate: args.activate !== false }, timeoutMs: 15_000 diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 9f91e7dcc8e..028327eb226 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -490,6 +490,7 @@ type RuntimeTerminalCreateBaseRequestPayload = { launchConfig?: SleepingAgentLaunchConfig launchToken?: string launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' startupCommandDelivery?: StartupCommandDelivery title?: string activate?: boolean