From 93580a2bc4e7c06f9e3619d41605e745cc5202d9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:20:50 -0700 Subject: [PATCH] fix(native-chat): reconcile backend draft launch tabs --- src/main/ipc/worktree-remote.ts | 1 + src/main/ipc/worktrees.test.ts | 2 + src/main/runtime/orca-runtime.test.ts | 34 +++++- src/main/runtime/orca-runtime.ts | 3 + ...ve-chat-launch-draft-mirrorability.test.ts | 8 +- .../native-chat-launch-draft-mirrorability.ts | 2 +- .../lib/worktree-creation-agent-seeds.test.ts | 115 +++++++++++++++++- .../src/lib/worktree-creation-agent-seeds.ts | 84 +++++++++++-- .../src/lib/worktree-creation-flow.test.ts | 45 ++++++- .../src/lib/worktree-creation-flow.ts | 4 +- .../lib/worktree-draft-startup-view-mode.ts | 29 +++++ src/shared/types.ts | 1 + 12 files changed, 310 insertions(+), 18 deletions(-) create mode 100644 src/renderer/src/lib/worktree-draft-startup-view-mode.ts diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index c10533acb7a..2bb42abf4cd 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -297,6 +297,7 @@ async function spawnLocalStartupAndSetupTerminals(args: { env: sequencedStartup.env, ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), ...(isTuiAgent(createdWithAgent) ? { launchAgent: createdWithAgent } : {}), + ...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}), startupCommandDelivery: sequencedStartup.startupCommandDelivery, telemetry: sequencedStartup.telemetry, activate: true diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 72cad299822..1f6b1b31f9e 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -1134,6 +1134,7 @@ describe('registerWorktreeHandlers', () => { startup: { command: 'claude --prefill test', env: { ORCA_AGENT_MODE: 'direct' }, + viewMode: 'chat', telemetry: { agent_kind: 'claude', launch_source: 'new_workspace_composer', @@ -1154,6 +1155,7 @@ describe('registerWorktreeHandlers', () => { command: 'claude --prefill test', env: { ORCA_AGENT_MODE: 'direct' }, launchAgent: 'claude', + viewMode: 'chat', startupCommandDelivery: undefined, telemetry: { agent_kind: 'claude', diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index e61b64df170..321bda326b0 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -3752,16 +3752,34 @@ describe('OrcaRuntimeService', () => { const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { getLocalProvider: () => localProvider as never }) + runtime.setPtyController({ + spawn: vi.fn(), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ + handle: 'term_folder_startup', + tabId: 'tab-folder-startup', + worktreeId: '', + title: null, + surface: 'background' + }) const notifier = { worktreesChanged: vi.fn() } runtime.setNotifier(notifier as never) const result = await runtime.createManagedWorktree({ repoSelector: 'id:folder-repo', name: 'folder-session', - createdWithAgent: 'codex' + createdWithAgent: 'codex', + startup: { command: 'codex', viewMode: 'chat' } }) expect(addWorktreeMock).not.toHaveBeenCalled() + expect(createTerminal).toHaveBeenCalledWith( + `id:${result.worktree.id}`, + expect.objectContaining({ command: 'codex', viewMode: 'chat' }) + ) expect(result.worktree).toEqual( expect.objectContaining({ id: expect.stringMatching(/^folder-repo::\/workspace\/folder::workspace:[0-9a-f-]{36}$/), @@ -5411,16 +5429,21 @@ describe('OrcaRuntimeService', () => { }) runtime.attachWindow(1) + const createTerminal = vi.spyOn(runtime, 'createTerminal') try { const result = await runtime.createManagedWorktree({ repoSelector: TEST_REPO_ID, name: 'mobile-setup', setupDecision: 'run', - startup: { command: 'claude' } + startup: { command: 'claude', viewMode: 'chat' } }) // Why: runtime provisions setup itself (fire-and-forget) and omits it from the RPC result so the caller doesn't double-spawn. expect(result.setup).toBeUndefined() + expect(createTerminal).toHaveBeenCalledWith( + `path:${result.worktree.path}`, + expect.objectContaining({ viewMode: 'chat' }) + ) await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) expect(spawn).toHaveBeenNthCalledWith( 1, @@ -33053,6 +33076,7 @@ describe('OrcaRuntimeService', () => { getRepo: (id: string) => (id === 'repo-1' ? waitRepo : undefined) } const runtime = new OrcaRuntimeService(runtimeStore as never) + const createTerminal = vi.spyOn(runtime, 'createTerminal') const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-headless-startup' }) const spawn = vi .fn() @@ -33109,11 +33133,15 @@ describe('OrcaRuntimeService', () => { repoSelector: 'id:repo-1', name: 'runtime-headless-startup-setup', setupDecision: 'run', - startup: { command: 'claude' } + startup: { command: 'claude', viewMode: 'chat' } }) expect(createSetupRunnerScript).toHaveBeenCalled() expect(runHook).not.toHaveBeenCalled() + expect(createTerminal).toHaveBeenCalledWith( + `id:${result.worktree.id}`, + expect.objectContaining({ viewMode: 'chat' }) + ) // Why: setup is provisioned fire-and-forget; the wait-for-setup guarantee comes from the shell nonce/marker, not JS spawn ordering. await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) const startupCommand = (spawn.mock.calls[0]![0] as { command: string }).command diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 9278dcf7d03..298f11a8d3d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -18821,6 +18821,7 @@ export class OrcaRuntimeService { ? { launchConfig: effectiveStartup.launchConfig } : {}), ...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}), + ...(effectiveStartup.viewMode ? { viewMode: effectiveStartup.viewMode } : {}), startupCommandDelivery: effectiveStartup.startupCommandDelivery, telemetry: effectiveStartup.telemetry }) @@ -19546,6 +19547,7 @@ export class OrcaRuntimeService { env: sequencedStartup.env, ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), ...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}), + ...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}), startupCommandDelivery: sequencedStartup.startupCommandDelivery, telemetry: sequencedStartup.telemetry }) @@ -19888,6 +19890,7 @@ export class OrcaRuntimeService { env: sequencedStartup.env, ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), ...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}), + ...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}), startupCommandDelivery: sequencedStartup.startupCommandDelivery, telemetry: sequencedStartup.telemetry }) diff --git a/src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts b/src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts index cb533408ae6..33dd6f9a30f 100644 --- a/src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts +++ b/src/renderer/src/lib/native-chat-launch-draft-mirrorability.test.ts @@ -22,6 +22,9 @@ import { decideInitialAgentTabViewMode } from './native-chat-initial-view-mode' const DRAFT_TEXTS = [ 'https://github.com/o/r/issues/12', 'Reproduce on Windows first\n\nhttps://github.com/o/r/issues/12', + 'Reproduce on Windows first\rhttps://github.com/o/r/issues/12', + 'Reproduce first\u2028https://github.com/o/r/issues/12', + 'Reproduce first\u2029https://github.com/o/r/issues/12', 'ORC-123: Restore linked quick-create\nhttps://linear.app/o/issue/ORC-123', 'ORC-123 https://linear.app/o/issue/ORC-123\n', ' spaced but single line ', @@ -68,9 +71,12 @@ describe('launch draft mirrorability', () => { // The only test that pins the rule itself; the agreement tests above adapt on // their own. Multi-line send work updates the predicate body and this test. - it('accepts single-line text and rejects anything with a newline', () => { + it('accepts single-line text and rejects any line separator', () => { expect(canMirrorLaunchDraftToNativeChat('https://github.com/o/r/issues/12')).toBe(true) expect(canMirrorLaunchDraftToNativeChat('one\ntwo')).toBe(false) + expect(canMirrorLaunchDraftToNativeChat('one\rtwo')).toBe(false) + expect(canMirrorLaunchDraftToNativeChat('one\u2028two')).toBe(false) + expect(canMirrorLaunchDraftToNativeChat('one\u2029two')).toBe(false) expect(canMirrorLaunchDraftToNativeChat('trailing\n')).toBe(false) expect(canMirrorLaunchDraftToNativeChat(' ')).toBe(false) }) diff --git a/src/renderer/src/lib/native-chat-launch-draft-mirrorability.ts b/src/renderer/src/lib/native-chat-launch-draft-mirrorability.ts index b0093aefae1..4afd4e6c2c0 100644 --- a/src/renderer/src/lib/native-chat-launch-draft-mirrorability.ts +++ b/src/renderer/src/lib/native-chat-launch-draft-mirrorability.ts @@ -14,5 +14,5 @@ * predicate to accept multi-line flips seeding and view mode together. */ export function canMirrorLaunchDraftToNativeChat(text: string): boolean { - return text.trim().length > 0 && !text.includes('\n') + return text.trim().length > 0 && !/[\r\n\u2028\u2029]/.test(text) } diff --git a/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts b/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts index 21f1b76fcf8..a3280957cdc 100644 --- a/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts +++ b/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts @@ -5,7 +5,8 @@ import { seedAgentTabStateAfterWorktreeCreate } from './worktree-creation-agent- const mocks = vi.hoisted(() => ({ seedNativeChatAppliedSessionOptions: vi.fn(), - seedNativeChatLaunchDraftForAgentTab: vi.fn() + seedNativeChatLaunchDraftForAgentTab: vi.fn(), + setWebRuntimeTabProps: vi.fn() })) vi.mock('@/components/native-chat/native-chat-session-option-cache', () => ({ @@ -14,11 +15,18 @@ vi.mock('@/components/native-chat/native-chat-session-option-cache', () => ({ vi.mock('@/lib/agent-launch-prompt-delivery', () => ({ seedNativeChatLaunchDraftForAgentTab: mocks.seedNativeChatLaunchDraftForAgentTab })) +vi.mock('@/runtime/web-runtime-session', () => ({ + setWebRuntimeTabProps: mocks.setWebRuntimeTabProps +})) type AppState = ReturnType const initialTabsByWorktree = useAppStore.getState().tabsByWorktree +const initialUnifiedTabsByWorktree = useAppStore.getState().unifiedTabsByWorktree const initialGetKnownWorktreeById = useAppStore.getState().getKnownWorktreeById +const initialSettings = useAppStore.getState().settings! +const initialWorktreesByRepo = useAppStore.getState().worktreesByRepo +const initialRepos = useAppStore.getState().repos const DRAFT = 'https://github.com/o/r/issues/12' @@ -28,9 +36,40 @@ const request = { launchDraftPrompt: DRAFT } -function setTabs(tabs: { id: string; launchAgent?: string }[]): void { +function setTabs( + tabs: { id: string; launchAgent?: string; viewMode?: 'terminal' | 'chat' }[], + runtimeOwnerEnvironmentId?: string +): void { useAppStore.setState({ tabsByWorktree: { 'wt-1': tabs }, + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + path: '/repo/wt-1', + ...(runtimeOwnerEnvironmentId ? { runtimeOwnerEnvironmentId } : {}) + } + ] + }, + repos: [{ id: 'repo-1', path: '/repo', connectionId: null }], + unifiedTabsByWorktree: { + 'wt-1': tabs.map((tab, index) => ({ + id: tab.id, + entityId: tab.id, + groupId: 'group-1', + worktreeId: 'wt-1', + contentType: 'terminal' as const, + label: `Terminal ${index + 1}`, + customLabel: null, + color: null, + sortOrder: index, + createdAt: index, + isPreview: false, + isPinned: false, + ...(tab.viewMode ? { viewMode: tab.viewMode } : {}) + })) + }, getKnownWorktreeById: ((id: string) => id === 'wt-1' ? { id } : undefined) as unknown as AppState['getKnownWorktreeById'] } as unknown as Partial) @@ -40,15 +79,31 @@ function seededTabIds(): string[] { return mocks.seedNativeChatLaunchDraftForAgentTab.mock.calls.map((call) => call[0].tabId) } +function tabViewMode(tabId: string): 'terminal' | 'chat' | undefined { + return useAppStore.getState().unifiedTabsByWorktree['wt-1']?.find((tab) => tab.id === tabId) + ?.viewMode +} + beforeEach(() => { vi.clearAllMocks() + useAppStore.setState({ + settings: { + ...initialSettings, + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true + } + }) }) afterEach(() => { resetHookCommandDelayedDeliveryForTests() useAppStore.setState({ tabsByWorktree: initialTabsByWorktree, - getKnownWorktreeById: initialGetKnownWorktreeById + unifiedTabsByWorktree: initialUnifiedTabsByWorktree, + getKnownWorktreeById: initialGetKnownWorktreeById, + settings: initialSettings, + worktreesByRepo: initialWorktreesByRepo, + repos: initialRepos } as Partial) }) @@ -74,6 +129,60 @@ describe('seedAgentTabStateAfterWorktreeCreate', () => { ) }) + it('moves a backend-spawned non-mirrorable draft out of an inherited chat view', () => { + setTabs([{ id: 'agent-tab', launchAgent: 'claude', viewMode: 'chat' }]) + + seedAgentTabStateAfterWorktreeCreate({ + request: { ...request, launchDraftPrompt: 'note\rhttps://github.com/o/r/issues/12' }, + worktreeId: 'wt-1', + primaryTabId: 'agent-tab', + startupTerminalTabId: 'agent-tab', + backendSpawned: true + }) + + expect(tabViewMode('agent-tab')).toBe('terminal') + }) + + it('opens a backend-spawned mirrorable draft in chat after host reconciliation', () => { + setTabs([{ id: 'agent-tab', launchAgent: 'claude', viewMode: 'terminal' }]) + + seedAgentTabStateAfterWorktreeCreate({ + request, + worktreeId: 'wt-1', + primaryTabId: 'agent-tab', + startupTerminalTabId: 'agent-tab', + backendSpawned: true + }) + + expect(tabViewMode('agent-tab')).toBe('chat') + }) + + it('keys a raw backend tab id and updates the host before its tab mirror lands', async () => { + setTabs([], 'runtime-1') + + seedAgentTabStateAfterWorktreeCreate({ + request, + worktreeId: 'wt-1', + primaryTabId: null, + startupTerminalTabId: 'host-agent-tab', + backendSpawned: true + }) + + expect(seededTabIds()).toEqual(['web-terminal-host-agent-tab']) + await vi.waitFor(() => + expect(mocks.setWebRuntimeTabProps).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + tabId: 'web-terminal-host-agent-tab', + viewMode: 'chat' + }) + ) + setTabs( + [{ id: 'web-terminal-host-agent-tab', launchAgent: 'claude', viewMode: 'chat' }], + 'runtime-1' + ) + expect(seededTabIds()).toEqual(['web-terminal-host-agent-tab']) + }) + it('seeds the launchAgent-stamped tab when the renderer owns startup', () => { setTabs([{ id: 'dev-server' }, { id: 'agent-tab', launchAgent: 'claude' }]) diff --git a/src/renderer/src/lib/worktree-creation-agent-seeds.ts b/src/renderer/src/lib/worktree-creation-agent-seeds.ts index a5173934332..565ea421d71 100644 --- a/src/renderer/src/lib/worktree-creation-agent-seeds.ts +++ b/src/renderer/src/lib/worktree-creation-agent-seeds.ts @@ -2,6 +2,11 @@ import { useAppStore } from '@/store' import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' import { seedNativeChatLaunchDraftForAgentTab } from '@/lib/agent-launch-prompt-delivery' import { queueHookCommandsForFirstWorktreeTab } from '@/lib/hook-command-delayed-delivery' +import { decideInitialAgentTabViewMode } from '@/lib/native-chat-initial-view-mode' +import { getConnectionIdFromState } from '@/lib/connection-context' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { toWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' import type { TuiAgent } from '../../../shared/types' @@ -26,18 +31,69 @@ function resolveLaunchAgentTabId( backendSpawned: boolean } ): string | null { + const worktreeTabs = state.tabsByWorktree[args.worktreeId] ?? [] // Main spawned the agent itself, so its startup tab is authoritative. if (args.backendSpawned && args.startupTerminalTabId) { - return args.startupTerminalTabId + return getRuntimeEnvironmentIdForWorktree(state, args.worktreeId) + ? toWebTerminalSurfaceTabId(args.startupTerminalTabId) + : args.startupTerminalTabId } - const worktreeTabs = state.tabsByWorktree[args.worktreeId] ?? [] const stamped = worktreeTabs.find((tab) => tab.launchAgent === args.agent)?.id // Why: when the renderer owns startup, `ensureAgentStartupInTerminal` queues it // on primaryTabId, so that tab is the agent's tab by construction. return stamped ?? args.primaryTabId ?? args.startupTerminalTabId ?? null } -function applyAgentTabSeeds(request: SeedRequest, agent: TuiAgent, tabId: string): void { +function applyBackendSpawnedDraftViewMode(args: { + state: AppStoreSnapshot + request: SeedRequest + agent: TuiAgent + tabId: string + worktreeId: string + backendSpawned: boolean +}): void { + const { state, request, agent, tabId, worktreeId, backendSpawned } = args + if (!backendSpawned || !request.launchDraftPrompt) { + return + } + const desiredViewMode = + decideInitialAgentTabViewMode({ + experimentalNativeChat: state.settings?.experimentalNativeChat, + openAgentTabsInChatByDefault: state.settings?.openAgentTabsInChatByDefault, + agent, + promptDelivery: 'draft', + launchDraftText: request.launchDraftPrompt, + ...(agent === 'grok' + ? { + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + getConnectionIdFromState(state, worktreeId) + ) + } + : {}) + }) ?? 'terminal' + const tab = state.unifiedTabsByWorktree?.[worktreeId]?.find((tab) => tab.id === tabId) + if (!tab && getRuntimeEnvironmentIdForWorktree(state, worktreeId)) { + void import('@/runtime/web-runtime-session').then(({ setWebRuntimeTabProps }) => + setWebRuntimeTabProps({ worktreeId, tabId, viewMode: desiredViewMode }) + ) + return + } + const currentViewMode = tab?.viewMode ?? 'terminal' + if (currentViewMode !== desiredViewMode) { + state.setTabViewMode(tabId, desiredViewMode) + } +} + +function applyAgentTabSeeds(args: { + state: AppStoreSnapshot + request: SeedRequest + agent: TuiAgent + tabId: string + worktreeId: string + backendSpawned: boolean +}): void { + const { request, agent, tabId } = args + applyBackendSpawnedDraftViewMode(args) seedNativeChatAppliedSessionOptions(tabId, agent, request.startupPlan?.sessionOptions) // Why: draft launch context reaches only the TUI input; seed the // chat-composer copy so it isn't invisible in the chat view. @@ -57,7 +113,7 @@ export function seedAgentTabStateAfterWorktreeCreate(args: { startupTerminalTabId: string | null | undefined backendSpawned: boolean }): void { - const { request, worktreeId } = args + const { request, worktreeId, backendSpawned } = args const agent = request.agent if (!request.startupPlan || !agent) { return @@ -65,7 +121,7 @@ export function seedAgentTabStateAfterWorktreeCreate(args: { const state = useAppStore.getState() const tabId = resolveLaunchAgentTabId(state, { ...args, agent }) if (tabId) { - applyAgentTabSeeds(request, agent, tabId) + applyAgentTabSeeds({ state, request, agent, tabId, worktreeId, backendSpawned }) return } if ((state.tabsByWorktree[worktreeId] ?? []).length > 0) { @@ -81,7 +137,14 @@ export function seedAgentTabStateAfterWorktreeCreate(args: { deliver: (state, firstTerminalTabId) => { const mirroredTabId = resolveLaunchAgentTabId(state, { ...args, agent }) if (mirroredTabId) { - applyAgentTabSeeds(request, agent, mirroredTabId) + applyAgentTabSeeds({ + state, + request, + agent, + tabId: mirroredTabId, + worktreeId, + backendSpawned + }) return } // Why: same invariant as the synchronous branch — never seed a tab that @@ -89,7 +152,14 @@ export function seedAgentTabStateAfterWorktreeCreate(args: { // so accept the first mirrored tab only when it is the worktree's only // one and therefore unambiguously the agent's. if ((state.tabsByWorktree[worktreeId] ?? []).length === 1) { - applyAgentTabSeeds(request, agent, firstTerminalTabId) + applyAgentTabSeeds({ + state, + request, + agent, + tabId: firstTerminalTabId, + worktreeId, + backendSpawned + }) } } }) diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index 12cc43b9ff9..b9cd5bf3410 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -13,7 +13,11 @@ const { prepareEphemeralVmWorkspaceTargetMock } = vi.hoisted(() => ({ type TestActiveView = 'terminal' | 'tasks' const store = { - settings: { activeRuntimeEnvironmentId: null as string | null }, + settings: { + activeRuntimeEnvironmentId: null as string | null, + experimentalNativeChat: undefined as boolean | undefined, + openAgentTabsInChatByDefault: undefined as boolean | undefined + }, activeView: 'terminal' as TestActiveView, activePendingCreationId: 'creation-1' as string | null, repos: [{ id: 'repo-runtime', connectionId: null }], @@ -41,7 +45,9 @@ const store = { setupProjectExistingFolder: vi.fn(), refreshRuntimeEnvironmentStatus: vi.fn(), seedNativeChatLaunchDraft: vi.fn(), - tabsByWorktree: {} as Record + setTabViewMode: vi.fn(), + tabsByWorktree: {} as Record, + unifiedTabsByWorktree: {} } vi.mock('@/store', () => ({ @@ -94,12 +100,15 @@ const FLOW_SOURCE = readFileSync(join(__dirname, 'worktree-creation-flow.ts'), ' beforeEach(() => { vi.clearAllMocks() store.settings.activeRuntimeEnvironmentId = null + store.settings.experimentalNativeChat = undefined + store.settings.openAgentTabsInChatByDefault = undefined store.activeView = 'terminal' store.activePendingCreationId = 'creation-1' store.repos = [] store.pendingWorktreeCreations = { 'creation-1': makePendingCreation(makeRequest()) } store.createWorktree.mockImplementation(() => new Promise(() => {})) store.tabsByWorktree = {} + store.unifiedTabsByWorktree = {} vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('tab-1') }) @@ -697,6 +706,38 @@ describe('staged background worktree creation', () => { ) }) + it.each([ + ['mirrorable local Grok', 'grok', 'https://github.com/o/r/issues/12', 'chat'], + ['multi-line Claude', 'claude', 'note\nhttps://github.com/o/r/issues/12', 'terminal'] + ] as const)('passes %s draft mode to backend startup', async (_label, agent, draft, viewMode) => { + store.settings.experimentalNativeChat = true + store.settings.openAgentTabsInChatByDefault = true + store.repos = [{ id: 'repo-1', connectionId: null }] + continueBackgroundWorktreeCreation( + 'creation-1', + makeRequest({ + agent, + startup: { command: `${agent} --prefill x`, launchAgent: agent }, + startupPlan: { + agent, + launchCommand: `${agent} --prefill x`, + expectedProcess: agent, + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} } + }, + launchDraftPrompt: draft + }) + ) + + await vi.waitFor(() => expect(store.createWorktree).toHaveBeenCalled()) + const createCall = store.createWorktree.mock.calls[0] as unknown[] | undefined + expect(createCall?.[16]).toEqual({ + command: `${agent} --prefill x`, + launchAgent: agent, + viewMode + }) + }) + it('carries launchDraftText into activation for an argv-prefill launch', async () => { // Why: the draft rides inside `launchCommand` here, so the plan sets no // draftPrompt — without launchDraftText the initial view-mode decision diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index 5b8777dd87f..e56436775df 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -26,6 +26,7 @@ import type { } from '@/lib/pending-worktree-creation' import { createBrowserUuid } from '@/lib/browser-uuid' import { seedAgentTabStateAfterWorktreeCreate } from '@/lib/worktree-creation-agent-seeds' +import { resolveBackendDraftStartup } from '@/lib/worktree-draft-startup-view-mode' type ContinueBackgroundWorktreeCreationOptions = { revealCreationSurface?: boolean @@ -141,6 +142,7 @@ async function executeWorktreeCreation( let result: CreateWorktreeResult try { + const backendStartup = resolveBackendDraftStartup(preparedRequest) result = await useAppStore .getState() .createWorktree( @@ -160,7 +162,7 @@ async function executeWorktreeCreation( preparedRequest.workspaceStatus, preparedRequest.linkedGitLabMR, preparedRequest.linkedGitLabIssue, - preparedRequest.startup, + backendStartup, preparedRequest.pendingFirstAgentMessageRename, creationId, preparedRequest.linkedLinearIssueWorkspaceId, diff --git a/src/renderer/src/lib/worktree-draft-startup-view-mode.ts b/src/renderer/src/lib/worktree-draft-startup-view-mode.ts new file mode 100644 index 00000000000..b4ca4000f62 --- /dev/null +++ b/src/renderer/src/lib/worktree-draft-startup-view-mode.ts @@ -0,0 +1,29 @@ +import { useAppStore } from '@/store' +import { decideInitialAgentTabViewMode } from '@/lib/native-chat-initial-view-mode' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' +import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' + +export function resolveBackendDraftStartup( + request: WorktreeCreationRequest +): WorktreeCreationRequest['startup'] { + if (!request.startup || !request.agent || !request.launchDraftPrompt) { + return request.startup + } + const state = useAppStore.getState() + const repo = state.repos.find((entry) => entry.id === request.repoId) + const connectionId = repo ? (repo.connectionId ?? null) : undefined + const viewMode = + decideInitialAgentTabViewMode({ + experimentalNativeChat: state.settings?.experimentalNativeChat, + openAgentTabsInChatByDefault: state.settings?.openAgentTabsInChatByDefault, + agent: request.agent, + promptDelivery: 'draft', + launchDraftText: request.launchDraftPrompt, + ...(request.agent === 'grok' + ? { + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(connectionId) + } + : {}) + }) ?? 'terminal' + return { ...request.startup, viewMode } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index ec27f126711..329498a0399 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2148,6 +2148,7 @@ export type WorktreeStartupLaunch = { launchConfig?: SleepingAgentLaunchConfig launchToken?: string launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' startupCommandDelivery?: StartupCommandDelivery telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } }