mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() } },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2800,6 +2800,7 @@ export type PreloadApi = {
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
launchToken?: string
|
||||
launchAgent?: TuiAgent
|
||||
viewMode?: 'terminal' | 'chat'
|
||||
title?: string
|
||||
ptyId?: string
|
||||
activate?: boolean
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -56,6 +56,7 @@ export async function createWebRuntimeSessionTerminal(args: {
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
agent?: TuiAgent
|
||||
launchAgent?: TuiAgent
|
||||
viewMode?: 'terminal' | 'chat'
|
||||
activate?: boolean
|
||||
selectWorktree?: boolean
|
||||
}): Promise<boolean> {
|
||||
@@ -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
|
||||
|
||||
@@ -490,6 +490,7 @@ type RuntimeTerminalCreateBaseRequestPayload = {
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
launchToken?: string
|
||||
launchAgent?: TuiAgent
|
||||
viewMode?: 'terminal' | 'chat'
|
||||
startupCommandDelivery?: StartupCommandDelivery
|
||||
title?: string
|
||||
activate?: boolean
|
||||
|
||||
Reference in New Issue
Block a user