fix(native-chat): keep paired launch mode authoritative

This commit is contained in:
Jinjing
2026-07-13 06:36:57 -07:00
parent c7cb6a2167
commit 8ba40d6c37
4 changed files with 111 additions and 12 deletions
+45 -3
View File
@@ -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 {
+35 -6
View File
@@ -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
@@ -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)
@@ -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 }