diff --git a/src/main/runtime/agent-launch-settings-rollback.ts b/src/main/runtime/agent-launch-settings-rollback.ts index 125d4ef7240..d6d4bccafdb 100644 --- a/src/main/runtime/agent-launch-settings-rollback.ts +++ b/src/main/runtime/agent-launch-settings-rollback.ts @@ -6,12 +6,19 @@ export function agentLaunchSettingsRollbackUpdates( current: AgentLaunchSettingsSource, updates: AgentLaunchSettingsSource ): AgentLaunchSettingsSource { + // A newer default choice supersedes both fields, including None → Blank with the same agent. + const defaultUnchanged = + Object.is(current.defaultTuiAgent, applied.defaultTuiAgent) && + (!Object.hasOwn(updates, 'newWorkspaceSetupOnly') || + Object.is(current.newWorkspaceSetupOnly, applied.newWorkspaceSetupOnly)) // A newer full-field write supersedes this mutation and must survive its rollback. return { - ...(Object.hasOwn(updates, 'defaultTuiAgent') && - Object.is(current.defaultTuiAgent, applied.defaultTuiAgent) + ...(Object.hasOwn(updates, 'defaultTuiAgent') && defaultUnchanged ? { defaultTuiAgent: before.defaultTuiAgent ?? null } : {}), + ...(Object.hasOwn(updates, 'newWorkspaceSetupOnly') && defaultUnchanged + ? { newWorkspaceSetupOnly: before.newWorkspaceSetupOnly === true } + : {}), ...(Object.hasOwn(updates, 'disabledTuiAgents') && Object.is(current.disabledTuiAgents, applied.disabledTuiAgents) ? { disabledTuiAgents: before.disabledTuiAgents ?? [] } diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index 6e5eaec593a..53eaaa30548 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -313,6 +313,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM defaultTabs?: CreateWorktreeResult['defaultTabs'] primaryTerminalHandle?: string | null hasStartupTerminal: boolean + setupOnly?: boolean setupCommandPlatform: 'windows' | 'posix' observeSetupCompletion?: boolean // Why: when the agent startup is sequenced to wait for setup diff --git a/src/main/runtime/rpc/methods/worktree-create-args.ts b/src/main/runtime/rpc/methods/worktree-create-args.ts index 173fa8ea7cd..5f2cc2be88b 100644 --- a/src/main/runtime/rpc/methods/worktree-create-args.ts +++ b/src/main/runtime/rpc/methods/worktree-create-args.ts @@ -65,6 +65,7 @@ export function buildManagedWorktreeCreateArgs( defaultTarget: 'host' }), setupDecision: params.setupDecision, + ...(params.setupOnly !== undefined ? { setupOnly: params.setupOnly } : {}), createdWithAgent: params.createdWithAgent ?? params.startupAgent, ...provenance, // Why: a person initiated this create (the phone, CLI text and agent.launch don't surface the diff --git a/src/main/runtime/rpc/methods/worktree-create-setup-choice.test.ts b/src/main/runtime/rpc/methods/worktree-create-setup-choice.test.ts new file mode 100644 index 00000000000..153c4560d5d --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-create-setup-choice.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import { WorktreeCreate } from '../../../../shared/rpc-contract/worktree-create-params' +import { buildManagedWorktreeCreateArgs } from './worktree-create-args' + +describe('worktree setup choice', () => { + it.each([false, true])('preserves the submitted choice across RPC (%s)', (setupOnly) => { + const params = WorktreeCreate.parse({ repo: 'repo-1', name: 'choice', setupOnly }) + expect(buildManagedWorktreeCreateArgs(params, {})).toMatchObject({ setupOnly }) + }) +}) diff --git a/src/main/runtime/runtime-client-agent-launch-settings-durability.test.ts b/src/main/runtime/runtime-client-agent-launch-settings-durability.test.ts index 0ea3f8f018c..292fa4204be 100644 --- a/src/main/runtime/runtime-client-agent-launch-settings-durability.test.ts +++ b/src/main/runtime/runtime-client-agent-launch-settings-durability.test.ts @@ -110,6 +110,27 @@ describe('durable execution-host launch settings', () => { expect(readState().settings.agentDefaultArgs.claude).toBe('--durable-marker') }) + it('rolls back both None fields when the database write fails', async () => { + const { store, authority } = await fixture() + const controller = new RuntimeClientSettingsController(store) + const before = store.getSettings().defaultTuiAgent + authority.failNextWrite() + try { + await expect( + controller.mutateAgentLaunch({ type: 'default', agent: 'blank', setupOnly: true }) + ).rejects.toThrow('profile_state_write_failed') + } finally { + await store.flushPendingOrThrowAsync().catch(() => {}) + } + expect(store.getSettings().defaultTuiAgent).toBe(before) + expect(store.getSettings().newWorkspaceSetupOnly).toBe(false) + await controller.mutateAgentLaunch({ type: 'default', agent: 'blank', setupOnly: true }) + expect(controller.getAgentLaunch()).toMatchObject({ + defaultTuiAgent: 'blank', + newWorkspaceSetupOnly: true + }) + }) + it('rejects a failed disk write, rolls back its settings, and allows an explicit retry', async () => { const { store, authority, readState } = await fixture() const controller = new RuntimeClientSettingsController(store) @@ -192,6 +213,27 @@ describe('durable execution-host launch settings', () => { ]) }) + it('preserves a newer blank choice when a pending None write fails', async () => { + const { store, authority } = await fixture() + const controller = new RuntimeClientSettingsController(store) + authority.failNextWrite() + const gate = authority.pause() + const first = controller.mutateAgentLaunch({ type: 'default', agent: 'blank', setupOnly: true }) + const refused = expect(first).rejects.toThrow('profile_state_write_failed') + try { + await gate.started.promise + store.updateSettings({ defaultTuiAgent: 'blank', newWorkspaceSetupOnly: false }) + } finally { + gate.finish.resolve() + } + await refused + expect(controller.getAgentLaunch()).toMatchObject({ + defaultTuiAgent: 'blank', + newWorkspaceSetupOnly: false + }) + await store.flushPendingOrThrowAsync() + }) + it('preserves a newer legacy full-map write when an older mutation fails', async () => { const { store, authority, readState } = await fixture() const controller = new RuntimeClientSettingsController(store) diff --git a/src/main/runtime/runtime-client-agent-launch-settings.test.ts b/src/main/runtime/runtime-client-agent-launch-settings.test.ts index c78df7c3b31..171dee760b2 100644 --- a/src/main/runtime/runtime-client-agent-launch-settings.test.ts +++ b/src/main/runtime/runtime-client-agent-launch-settings.test.ts @@ -106,6 +106,38 @@ describe('execution-host agent launch settings', () => { expect(JSON.stringify(controller.getAgentLaunch())).not.toContain('host-only') }) + it('persists and projects None, then clears it for a blank default', async () => { + const { controller, host } = fixture() + const mutation = AgentLaunchSettingsMutation.parse({ + type: 'default', + agent: 'blank', + setupOnly: true + }) + await expect(controller.mutateAgentLaunch(mutation)).resolves.toMatchObject({ + defaultTuiAgent: 'blank', + newWorkspaceSetupOnly: true + }) + expect(host.newWorkspaceSetupOnly).toBe(true) + await expect( + controller.mutateAgentLaunch({ type: 'default', agent: 'blank' }) + ).resolves.toMatchObject({ + defaultTuiAgent: 'blank', + newWorkspaceSetupOnly: false + }) + expect(host.newWorkspaceSetupOnly).toBe(false) + }) + + it('clears None when a legacy client explicitly picks Blank Terminal', async () => { + const { controller, host } = fixture() + await controller.mutateAgentLaunch({ type: 'default', agent: 'blank', setupOnly: true }) + await controller.update({ defaultTuiAgent: 'blank' }) + expect(host.newWorkspaceSetupOnly).toBe(false) + expect(controller.getAgentLaunch()).toMatchObject({ + defaultTuiAgent: 'blank', + newWorkspaceSetupOnly: false + }) + }) + it('clears the host default when disabling that agent and preserves others', async () => { const { controller, host } = fixture() host.defaultTuiAgent = 'claude' diff --git a/src/main/runtime/runtime-client-settings-shape.ts b/src/main/runtime/runtime-client-settings-shape.ts index ad2a699cd87..3dff43bfdc4 100644 --- a/src/main/runtime/runtime-client-settings-shape.ts +++ b/src/main/runtime/runtime-client-settings-shape.ts @@ -7,6 +7,7 @@ import type { SourceControlAiActionDefaults } from '../../shared/source-control- export type RuntimeClientSettings = Pick< GlobalSettings, | 'defaultTuiAgent' + | 'newWorkspaceSetupOnly' | 'disabledTuiAgents' | 'agentCmdOverrides' | 'agentDefaultArgs' diff --git a/src/main/runtime/runtime-client-settings.ts b/src/main/runtime/runtime-client-settings.ts index 75fafa74103..6208d52ce3c 100644 --- a/src/main/runtime/runtime-client-settings.ts +++ b/src/main/runtime/runtime-client-settings.ts @@ -53,6 +53,7 @@ export class RuntimeClientSettingsController { const settings = this.store.getSettings() return { defaultTuiAgent: settings.defaultTuiAgent ?? null, + newWorkspaceSetupOnly: settings.newWorkspaceSetupOnly === true, disabledTuiAgents: settings.disabledTuiAgents ?? [], agentCmdOverrides: settings.agentCmdOverrides ?? {}, agentDefaultArgs: settings.agentDefaultArgs ?? {}, @@ -106,7 +107,14 @@ export class RuntimeClientSettingsController { } const beforeSettings = this.store.getSettings() const before = beforeSettings.agentStatusHooksEnabled !== false - this.store.updateSettings(updates, { notifyListeners: true }) + // Legacy clients can select an agent or a shell, but cannot select None. + this.store.updateSettings( + { + ...updates, + ...(updates.defaultTuiAgent !== undefined ? { newWorkspaceSetupOnly: false } : {}) + }, + { notifyListeners: true } + ) const settings = this.store.getSettings() if (updates.worktreeVisibilityDefaults !== undefined) { this.notifyReposChanged?.() diff --git a/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts b/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts index 42962a8c3d5..343bf5c422c 100644 --- a/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.test.ts @@ -88,6 +88,26 @@ describe('startRuntimeLocalWorktreeTerminals reserved startup pane', () => { }) describe('startRuntimeLocalWorktreeTerminals default shell seeding', () => { + it.each([false, true])( + 'passes the submitted setup-only choice to the host (%s)', + async (setupOnly) => { + const { ports } = createPorts() + await startRuntimeLocalWorktreeTerminals({ + request: { + repoSelector: `id:${repo.id}`, + name: 'choice', + setupOnly, + awaitTerminalProvisioning: true + }, + repo, + worktree, + setup: { runnerScriptPath: '/repo/setup.sh', envVars: {} }, + ports + }) + expect(ports.provision).toHaveBeenCalledWith(expect.objectContaining({ setupOnly })) + } + ) + it.each([false, true])( 'provisions a headless activated workspace without a viewer (setup=%s)', async (withSetup) => { diff --git a/src/main/runtime/runtime-local-worktree-terminal-startup.ts b/src/main/runtime/runtime-local-worktree-terminal-startup.ts index c3af6551368..2f1cea7159f 100644 --- a/src/main/runtime/runtime-local-worktree-terminal-startup.ts +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.ts @@ -224,6 +224,7 @@ function provisionArgs( ...(args.defaultTabs ? { defaultTabs: args.defaultTabs } : {}), primaryTerminalHandle, hasStartupTerminal, + ...(args.request.setupOnly !== undefined ? { setupOnly: args.request.setupOnly } : {}), setupCommandPlatform: setupPlatform(args.setup, 'posix'), observeSetupCompletion: args.request.observeSetupCompletion, ...(wrappedSetupCommand ? { wrappedSetupCommand } : {}) diff --git a/src/main/runtime/runtime-managed-worktree-create-types.ts b/src/main/runtime/runtime-managed-worktree-create-types.ts index c2d919ba4b3..1ea39b11e49 100644 --- a/src/main/runtime/runtime-managed-worktree-create-types.ts +++ b/src/main/runtime/runtime-managed-worktree-create-types.ts @@ -47,6 +47,7 @@ export type RuntimeManagedWorktreeCreateArgs = { runHooks?: boolean activate?: boolean setupDecision?: 'run' | 'skip' | 'inherit' + setupOnly?: boolean awaitTerminalProvisioning?: boolean observeSetupCompletion?: boolean createdWithAgent?: TuiAgent diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index fda26c88034..57472cd4061 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -91,6 +91,8 @@ export type RuntimeStore = { branchPrefix: string branchPrefixCustom: string defaultTuiAgent?: GlobalSettings['defaultTuiAgent'] + newWorkspaceSetupOnly?: GlobalSettings['newWorkspaceSetupOnly'] + setupScriptLaunchMode?: GlobalSettings['setupScriptLaunchMode'] disabledTuiAgents?: GlobalSettings['disabledTuiAgents'] agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] agentDefaultArgs?: GlobalSettings['agentDefaultArgs'] diff --git a/src/main/runtime/runtime-worktree-terminal-provisioning.test.ts b/src/main/runtime/runtime-worktree-terminal-provisioning.test.ts index 3ae0cd8e1b9..c52ac93b64e 100644 --- a/src/main/runtime/runtime-worktree-terminal-provisioning.test.ts +++ b/src/main/runtime/runtime-worktree-terminal-provisioning.test.ts @@ -94,3 +94,98 @@ describe('default tabs beside the agent a create started', () => { warn.mockRestore() }) }) + +const setup = { runnerScriptPath: '/repo/.orca/setup.sh', envVars: {} } + +function createHost(settings: Record): { + host: WorktreeTerminalProvisioningHost + titles: () => (string | undefined)[] +} { + const createTerminal = vi.fn(async () => ({ + handle: `term-${createTerminal.mock.calls.length}` + })) + return { + host: { + canSpawn: () => true, + createTerminal, + splitTerminal: vi.fn(async () => ({ handle: 'split' })), + setTabTitle: vi.fn(async () => {}), + setTabColor: vi.fn(async () => {}), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: provisioning reads only the optional keys each test supplies. + getSettings: () => settings as ReturnType, + getPtyId: () => undefined, + recordSetupCompletionToken: vi.fn() + }, + titles: () => createTerminal.mock.calls.map(([, options]) => options.title) + } +} + +function provisionWithSetup( + host: WorktreeTerminalProvisioningHost, + withSetup = true, + setupOnly?: boolean +) { + return provisionWorktreeTerminals(host, { + worktreeSelector: 'id:wt-1', + worktreeId: 'wt-1', + worktreePath: '/repo/wt-1', + ...(withSetup ? { setup } : {}), + hasStartupTerminal: false, + ...(setupOnly !== undefined ? { setupOnly } : {}), + setupCommandPlatform: 'posix' + }) +} + +describe('provisionWorktreeTerminals with the "None" default', () => { + const none = { defaultTuiAgent: 'blank', newWorkspaceSetupOnly: true } + + it('honours a one-off None choice over the host blank default', async () => { + const { host, titles } = createHost({ defaultTuiAgent: 'blank' }) + await provisionWithSetup(host, true, true) + expect(titles()).toEqual(['Setup']) + }) + + it('honours a one-off blank choice over the host None default', async () => { + const { host, titles } = createHost(none) + await provisionWithSetup(host, true, false) + expect(titles()).toEqual([undefined, 'Setup']) + }) + + it('opens only the Setup tab', async () => { + const { host, titles } = createHost(none) + await expect(provisionWithSetup(host)).resolves.toMatchObject({ setupSpawned: true }) + expect(titles()).toEqual(['Setup']) + }) + + it('keeps the blank terminal for a plain blank default', async () => { + const { host, titles } = createHost({ defaultTuiAgent: 'blank' }) + await provisionWithSetup(host) + expect(titles()).toEqual([undefined, 'Setup']) + }) + + it('falls back to a blank terminal when no setup runs', async () => { + const { host, titles } = createHost(none) + await provisionWithSetup(host, false) + expect(titles()).toEqual([undefined]) + }) + + it('opens a blank terminal when the Setup tab fails to spawn', async () => { + const { host, titles } = createHost(none) + const createTerminal = vi.mocked(host.createTerminal) + createTerminal.mockRejectedValueOnce(new Error('spawn failed')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await expect(provisionWithSetup(host)).resolves.toEqual({ + setupSpawned: false, + setupTerminalHandle: null + }) + expect(titles()).toEqual(['Setup', undefined]) + warn.mockRestore() + }) + + it('keeps the shell a split setup needs', async () => { + const { host, titles } = createHost({ ...none, setupScriptLaunchMode: 'split-vertical' }) + await provisionWithSetup(host) + expect(titles()).toEqual([undefined]) + expect(host.splitTerminal).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/runtime/runtime-worktree-terminal-provisioning.ts b/src/main/runtime/runtime-worktree-terminal-provisioning.ts index 3110c5228c6..d46158aa498 100644 --- a/src/main/runtime/runtime-worktree-terminal-provisioning.ts +++ b/src/main/runtime/runtime-worktree-terminal-provisioning.ts @@ -1,8 +1,8 @@ import { randomUUID } from 'node:crypto' import type { CreateWorktreeResult } from '../../shared/worktree/create-types' -import type { GlobalSettings } from '../../shared/global-settings-types' import { buildObservedSetupCommand } from './orchestration/setup-completion-signal' import { buildSetupRunnerCommand } from '../../shared/setup-runner-command' +import { isNewWorkspaceSetupOnlyDefault } from '../../shared/new-workspace-setup-only' import type { RuntimeStore } from './runtime-store-contract' type TerminalResult = { handle: string } @@ -42,6 +42,7 @@ export type WorktreeTerminalProvisioningArgs = { defaultTabs?: CreateWorktreeResult['defaultTabs'] primaryTerminalHandle?: string | null hasStartupTerminal: boolean + setupOnly?: boolean setupCommandPlatform: 'windows' | 'posix' observeSetupCompletion?: boolean // Why: setup and startup must use the same wrapper when startup waits for setup. @@ -118,6 +119,7 @@ export async function provisionWorktreeTerminals( const surfacing = args.surfaceOwner === false ? { surfaceOwner: false as const } : {} let setupSpawned = false let setupTerminalHandle: string | null = null + let shellSkippedForSetup = false try { const defaultHandles = await createWorktreeDefaultTabTerminals( host, @@ -128,11 +130,19 @@ export async function provisionWorktreeTerminals( args.hasStartupTerminal ? (args.primaryTerminalHandle ?? undefined) : undefined ) let primaryHandle = args.primaryTerminalHandle ?? defaultHandles[0] ?? null - const setupLaunchMode = - (host.getSettings() as Partial>) - .setupScriptLaunchMode ?? 'new-tab' + const settings = host.getSettings() + const setupLaunchMode = settings.setupScriptLaunchMode ?? 'new-tab' + // Why: "None" default leaves the Setup tab as the only surface; a split still needs a shell to split from. + const setupOnly = + args.setup !== undefined && + setupLaunchMode === 'new-tab' && + (args.setupOnly ?? isNewWorkspaceSetupOnlyDefault(settings)) if (!args.hasStartupTerminal && !primaryHandle) { - primaryHandle = (await host.createTerminal(args.worktreeSelector, surfacing)).handle + if (setupOnly) { + shellSkippedForSetup = true + } else { + primaryHandle = (await host.createTerminal(args.worktreeSelector, surfacing)).handle + } } if (args.setup) { const completionToken = @@ -183,6 +193,17 @@ export async function provisionWorktreeTerminals( console.warn( `[worktree-create] Failed to create setup/default terminals for ${args.worktreePath}: ${message}` ) + // Why: "None" skipped the shell on the promise of a Setup tab; without one the workspace would have no terminal. + if (shellSkippedForSetup && !setupSpawned) { + await host + .createTerminal(args.worktreeSelector, surfacing) + .catch((fallbackError: unknown) => { + console.warn( + `[worktree-create] Failed to open a fallback terminal for ${args.worktreePath}:`, + fallbackError + ) + }) + } } return { setupSpawned, setupTerminalHandle } } diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 91e4ba46e58..96b5722a4b8 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -38,6 +38,10 @@ import { import { getSshStatusLabel } from './new-workspace/new-workspace-composer-ssh-status' import { useNewWorkspaceComposerFileDrop } from './new-workspace/use-new-workspace-composer-file-drop' import { useComposerFileDragOver } from './new-workspace/use-composer-file-drag-over' +import { + buildDefaultAgentSettingsUpdate, + isNewWorkspaceSetupOnlyDefault +} from '../../../shared/new-workspace-setup-only' // Why lazy: this pulls the ~41 KB project-location browser onto the boot graph, and nothing // reaches it without an explicit "Set location" click. Shared with the warm below so both hit @@ -93,6 +97,7 @@ export default function NewWorkspaceComposerCard( const openModal = useAppStore((state) => state.openModal) const activeModal = useAppStore((state) => state.activeModal) const defaultTuiAgent = useAppStore((state) => state.settings?.defaultTuiAgent ?? null) + const setupOnlyIsDefault = useAppStore((state) => isNewWorkspaceSetupOnlyDefault(state.settings)) const disabledTuiAgents = useAppStore( (state) => state.settings?.disabledTuiAgents ?? DEFAULT_DISABLED_TUI_AGENTS ) @@ -272,10 +277,13 @@ export default function NewWorkspaceComposerCard( ) const handleSetDefaultAgent = React.useCallback( (next: TuiAgent | 'blank' | null): void => { - void updateSettings({ defaultTuiAgent: next }) + void updateSettings(buildDefaultAgentSettingsUpdate(next)) }, [updateSettings] ) + const handleSetSetupOnlyDefault = React.useCallback((): void => { + void updateSettings(buildDefaultAgentSettingsUpdate('setup-only')) + }, [updateSettings]) const handleNamePlainEnter = React.useCallback((): void => { const agentTrigger = composerRef?.current?.querySelector( '[data-agent-combobox-root="true"][role="combobox"]' @@ -337,6 +345,8 @@ export default function NewWorkspaceComposerCard( visibleQuickAgents={visibleQuickAgents} defaultTuiAgent={defaultTuiAgent} handleSetDefaultAgent={handleSetDefaultAgent} + setupOnlyIsDefault={setupOnlyIsDefault} + handleSetSetupOnlyDefault={handleSetSetupOnlyDefault} /> ( + undefined + ) + const quickSetupOnly = + quickAgent === null && + (quickSetupOnlyOverride ?? + (quickAgentOverride === undefined && isNewWorkspaceSetupOnlyDefault(settings))) const handleQuickAgentChange = useCallback((agent: TuiAgent | null) => { setQuickAgentOverride(agent) + setQuickSetupOnlyOverride(false) + }, []) + + const handleQuickSetupOnlySelect = useCallback(() => { + setQuickAgentOverride(null) + setQuickSetupOnlyOverride(true) }, []) const handleCreate = useCallback(async (): Promise => { - await submitQuick(quickAgent) - }, [quickAgent, submitQuick]) + await submitQuick(quickAgent, { setupOnly: quickSetupOnly }) + }, [quickAgent, quickSetupOnly, submitQuick]) // Why: Add Project layers over the composer as a nested dialog instead of // replacing it in the activeModal slot — closing the composer mid-flow (and // losing the typed name/prompt) was the old, abrupt behavior. Once opened it @@ -305,6 +319,8 @@ function QuickTabBody({ nameInputRef={nameInputRef} quickAgent={quickAgent} onQuickAgentChange={handleQuickAgentChange} + quickSetupOnly={quickSetupOnly} + onQuickSetupOnlySelect={handleQuickSetupOnlySelect} {...cardProps} primaryActionLabel={primaryActionLabel} onOpenAgentSettings={() => setAgentSettingsOpen(true)} diff --git a/src/renderer/src/components/agent/AgentCombobox.test.tsx b/src/renderer/src/components/agent/AgentCombobox.test.tsx index 7ac5a9187bc..0eb73fa0da1 100644 --- a/src/renderer/src/components/agent/AgentCombobox.test.tsx +++ b/src/renderer/src/components/agent/AgentCombobox.test.tsx @@ -11,6 +11,40 @@ import AgentCombobox from './AgentCombobox' afterEach(cleanup) describe('AgentCombobox', () => { + it('lets a blank selection replace the saved None default', () => { + const onSetDefault = vi.fn() + render( + + ) + fireEvent.contextMenu(screen.getByRole('combobox')) + fireEvent.click(screen.getByRole('menuitem', { name: 'Set as default' })) + expect(onSetDefault).toHaveBeenCalledWith('blank') + }) + + it('shows None without a suffix and saves it through its own default action', () => { + const onSetDefault = vi.fn() + render( + + ) + expect(screen.getByRole('combobox').textContent).toBe('None') + fireEvent.contextMenu(screen.getByRole('combobox')) + fireEvent.click(screen.getByRole('menuitem', { name: 'Set as default' })) + expect(onSetDefault).toHaveBeenCalledOnce() + }) it('sets the closed trigger selection as the default agent', () => { const onSetDefault = vi.fn() render( diff --git a/src/renderer/src/components/agent/AgentCombobox.tsx b/src/renderer/src/components/agent/AgentCombobox.tsx index 4d10363d721..dc98d86c8e6 100644 --- a/src/renderer/src/components/agent/AgentCombobox.tsx +++ b/src/renderer/src/components/agent/AgentCombobox.tsx @@ -1,23 +1,12 @@ import React, { useCallback, useMemo, useState } from 'react' -import { ArrowRight, Check, ChevronsUpDown, Star, Terminal } from 'lucide-react' +import { ArrowRight, Ban, ChevronsUpDown, Terminal } from 'lucide-react' import { Button } from '@/components/ui/button' -import { - Command, - CommandEmpty, - CommandInput, - CommandItem, - CommandList -} from '@/components/ui/command' -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger -} from '@/components/ui/context-menu' +import { Command, CommandEmpty, CommandInput, CommandList } from '@/components/ui/command' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { AgentIcon, type AgentCatalogEntry } from '@/lib/agent-catalog' import { agentPickerBlankTerminalMatches, + agentPickerSetupOnlyMatches, getAgentPickerCommandValue, searchAgentPickerEntries } from '@/lib/agent-picker-search' @@ -28,6 +17,11 @@ import { resolveAgentComboboxCommandState, updateAgentComboboxCommandValue } from './agent-combobox-command-state' +import { + AgentDefaultContextMenu, + AgentIconLabel, + renderAgentComboboxItem +} from './agent-combobox-items' import { translate } from '@/i18n/i18n' type DefaultAgentPreference = TuiAgent | 'blank' | null @@ -55,98 +49,21 @@ type AgentComboboxProps = AgentComboboxSelectionProps & { * field as the last keyboard-submit step. */ onTriggerEnter?: () => void allowNarrowTrigger?: boolean + /** Adds "None": no agent and no shell, only the setup script. Kept outside `value` so other pickers stay TuiAgent | null. */ + setupOnly?: { + selected: boolean + isDefault: boolean + onSelect: () => void + onSetDefault?: () => void + } } const BLANK_VALUE = '__none__' +const SETUP_ONLY_VALUE = '__setup_only__' const TRIGGER_MIN_WIDTH_CLASS = '!min-w-[260px]' -type ItemRenderArgs = { - key: string - itemValue: string - isChecked: boolean - isDefault: boolean - onSelect: () => void - onSetDefault?: () => void - icon: React.ReactNode - label: string -} - -type AgentDefaultContextMenuProps = { - children: React.ReactNode - isDefault: boolean - onSetDefault?: () => void -} - -function AgentIconLabel({ - icon, - label -}: { - icon: React.ReactNode - label: string -}): React.JSX.Element { - return ( - - - {icon} - - {label} - - ) -} - -function AgentDefaultContextMenu({ - children, - isDefault, - onSetDefault -}: AgentDefaultContextMenuProps): React.ReactNode { - if (!onSetDefault) { - return children - } - return ( - - {children} - - - - {isDefault - ? translate('auto.components.agent.AgentCombobox.1b0d6965fa', 'Current default') - : translate('auto.components.agent.AgentCombobox.9c6b59fe58', 'Set as default')} - - - - ) -} - -function renderItem({ - key, - itemValue, - isChecked, - isDefault, - onSelect, - onSetDefault, - icon, - label -}: ItemRenderArgs): React.ReactNode { - const row = ( - - - - - ) - return ( - // Why: z-[70] sits above PopoverContent's z-[60] so the right-click menu - // renders in front of the still-open combobox popover instead of behind it. - - {row} - - ) +function setupOnlyLabel(): string { + return translate('auto.components.agent.AgentCombobox.setupOnly', 'None') } export default function AgentCombobox({ @@ -158,6 +75,7 @@ export default function AgentCombobox({ triggerClassName, onTriggerEnter, allowNarrowTrigger = false, + setupOnly, ...selection }: AgentComboboxProps): React.JSX.Element { const allowBlankTerminal = selection.allowBlankTerminal === true @@ -175,19 +93,29 @@ export default function AgentCombobox({ () => (value ? (agents.find((agent) => agent.id === value) ?? null) : null), [agents, value] ) + const setupOnlySelected = setupOnly?.selected === true const selectedDefaultPreference = value ?? (allowBlankTerminal ? 'blank' : null) + const openCommandValue = setupOnlySelected ? SETUP_ONLY_VALUE : (value ?? BLANK_VALUE) const filteredAgents = useMemo(() => searchAgentPickerEntries(agents, query), [agents, query]) const blankMatchesQuery = useMemo( () => allowBlankTerminal && agentPickerBlankTerminalMatches(query), [allowBlankTerminal, query] ) - const activeCommandValue = getAgentPickerCommandValue({ + const setupOnlyMatchesQuery = useMemo( + () => setupOnly !== undefined && agentPickerSetupOnlyMatches(query), + [setupOnly, query] + ) + const pickedCommandValue = getAgentPickerCommandValue({ blankValue: BLANK_VALUE, blankMatchesQuery, currentValue: value, filteredAgents, rawQuery: query }) + const activeCommandValue = + setupOnlySelected && pickedCommandValue === BLANK_VALUE && !query + ? SETUP_ONLY_VALUE + : pickedCommandValue || (setupOnlyMatchesQuery ? SETUP_ONLY_VALUE : '') const resolvedCommandState = resolveAgentComboboxCommandState( commandState, open, @@ -242,13 +170,13 @@ export default function AgentCombobox({ (nextOpen: boolean) => { setOpen(nextOpen) if (nextOpen) { - setCommandState(createAgentComboboxCommandState(value ?? BLANK_VALUE)) + setCommandState(createAgentComboboxCommandState(openCommandValue)) return } cancelFocusFrame() setQuery('') }, - [cancelFocusFrame, value] + [cancelFocusFrame, openCommandValue] ) const closePicker = useCallback(() => { @@ -268,6 +196,12 @@ export default function AgentCombobox({ closePicker() } + const handleSelectSetupOnly = useCallback(() => { + setupOnly?.onSelect() + setOpen(false) + setQuery('') + }, [setupOnly]) + // Why: mirror RepoCombobox's trigger-keydown handling — the button-style // trigger treats the current value as a confirmed selection. Plain focus does // not open the dropdown. Only explicit intent opens: Arrow keys open without @@ -292,7 +226,7 @@ export default function AgentCombobox({ } if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault() - setCommandState(createAgentComboboxCommandState(value ?? BLANK_VALUE)) + setCommandState(createAgentComboboxCommandState(openCommandValue)) setOpen(true) return } @@ -301,12 +235,12 @@ export default function AgentCombobox({ } if (event.key.length === 1 && /\S/.test(event.key)) { event.preventDefault() - setCommandState(createAgentComboboxCommandState(value ?? BLANK_VALUE)) + setCommandState(createAgentComboboxCommandState(openCommandValue)) setQuery(event.key) setOpen(true) } }, - [open, onTriggerEnter, value] + [open, onTriggerEnter, openCommandValue] ) return ( @@ -316,12 +250,18 @@ export default function AgentCombobox({ onSetDefault(selectedDefaultPreference) - : undefined + setupOnlySelected + ? setupOnly.onSetDefault + : onSetDefault && selectedDefaultPreference !== null + ? () => onSetDefault(selectedDefaultPreference) + : undefined } > @@ -342,7 +282,9 @@ export default function AgentCombobox({ )} data-agent-combobox-root="true" > - {selectedAgent ? ( + {setupOnlySelected ? ( + } label={setupOnlyLabel()} /> + ) : selectedAgent ? ( } label={selectedAgent.label} @@ -397,11 +339,11 @@ export default function AgentCombobox({ )} {blankMatchesQuery - ? renderItem({ + ? renderAgentComboboxItem({ key: BLANK_VALUE, itemValue: BLANK_VALUE, - isChecked: value === null, - isDefault: defaultAgent === 'blank', + isChecked: value === null && !setupOnlySelected, + isDefault: defaultAgent === 'blank' && setupOnly?.isDefault !== true, onSelect: handleSelectBlank, onSetDefault: onSetDefault ? () => onSetDefault('blank') : undefined, icon: , @@ -411,8 +353,20 @@ export default function AgentCombobox({ ) }) : null} + {setupOnly && setupOnlyMatchesQuery + ? renderAgentComboboxItem({ + key: SETUP_ONLY_VALUE, + itemValue: SETUP_ONLY_VALUE, + isChecked: setupOnlySelected, + isDefault: setupOnly.isDefault, + onSelect: handleSelectSetupOnly, + onSetDefault: setupOnly.onSetDefault, + icon: , + label: setupOnlyLabel() + }) + : null} {filteredAgents.map((agent) => - renderItem({ + renderAgentComboboxItem({ key: agent.id, itemValue: agent.id, isChecked: value === agent.id, diff --git a/src/renderer/src/components/agent/agent-combobox-items.tsx b/src/renderer/src/components/agent/agent-combobox-items.tsx new file mode 100644 index 00000000000..268045053be --- /dev/null +++ b/src/renderer/src/components/agent/agent-combobox-items.tsx @@ -0,0 +1,100 @@ +import React from 'react' +import { Check, Star } from 'lucide-react' +import { CommandItem } from '@/components/ui/command' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger +} from '@/components/ui/context-menu' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' + +type ItemRenderArgs = { + key: string + itemValue: string + isChecked: boolean + isDefault: boolean + onSelect: () => void + onSetDefault?: () => void + icon: React.ReactNode + label: string +} + +type AgentDefaultContextMenuProps = { + children: React.ReactNode + isDefault: boolean + onSetDefault?: () => void +} + +export function AgentIconLabel({ + icon, + label +}: { + icon: React.ReactNode + label: string +}): React.JSX.Element { + return ( + + + {icon} + + {label} + + ) +} + +export function AgentDefaultContextMenu({ + children, + isDefault, + onSetDefault +}: AgentDefaultContextMenuProps): React.ReactNode { + if (!onSetDefault) { + return children + } + return ( + + {children} + + + + {isDefault + ? translate('auto.components.agent.AgentCombobox.1b0d6965fa', 'Current default') + : translate('auto.components.agent.AgentCombobox.9c6b59fe58', 'Set as default')} + + + + ) +} + +export function renderAgentComboboxItem({ + key, + itemValue, + isChecked, + isDefault, + onSelect, + onSetDefault, + icon, + label +}: ItemRenderArgs): React.ReactNode { + const row = ( + + + + + ) + return ( + // Why: z-[70] sits above PopoverContent's z-[60] so the right-click menu + // renders in front of the still-open combobox popover instead of behind it. + + {row} + + ) +} diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.test.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.test.tsx index eff02a5f233..b2f975e6cb7 100644 --- a/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.test.tsx +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.test.tsx @@ -24,6 +24,8 @@ describe('NewWorkspaceComposerAgentSection agent picker', () => { visibleQuickAgents={AGENT_CATALOG} defaultTuiAgent="blank" handleSetDefaultAgent={vi.fn()} + setupOnlyIsDefault={false} + handleSetSetupOnlyDefault={vi.fn()} /> ) diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.tsx index 5f790a5748e..6323802997d 100644 --- a/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.tsx +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.tsx @@ -11,6 +11,8 @@ type NewWorkspaceComposerAgentSectionProps = Pick< NewWorkspaceComposerCardProps, | 'quickAgent' | 'onQuickAgentChange' + | 'quickSetupOnly' + | 'onQuickSetupOnlySelect' | 'onOpenAgentSettings' | 'createDisabled' | 'onCreate' @@ -23,11 +25,15 @@ type NewWorkspaceComposerAgentSectionProps = Pick< handleSetDefaultAgent: ( next: Parameters['onSetDefault']>>[0] ) => void + setupOnlyIsDefault: boolean + handleSetSetupOnlyDefault: () => void } export function NewWorkspaceComposerAgentSection({ quickAgent, onQuickAgentChange, + quickSetupOnly = false, + onQuickSetupOnlySelect, onOpenAgentSettings, createDisabled, onCreate, @@ -36,7 +42,9 @@ export function NewWorkspaceComposerAgentSection({ onToggleAdvanced, visibleQuickAgents, defaultTuiAgent, - handleSetDefaultAgent + handleSetDefaultAgent, + setupOnlyIsDefault, + handleSetSetupOnlyDefault }: NewWorkspaceComposerAgentSectionProps): React.JSX.Element { return ( <> @@ -75,6 +83,16 @@ export function NewWorkspaceComposerAgentSection({ onOpenManageAgents={onOpenAgentSettings} defaultAgent={defaultTuiAgent} onSetDefault={handleSetDefaultAgent} + setupOnly={ + onQuickSetupOnlySelect + ? { + selected: quickSetupOnly, + isDefault: setupOnlyIsDefault, + onSelect: onQuickSetupOnlySelect, + onSetDefault: handleSetSetupOnlyDefault + } + : undefined + } allowNarrowTrigger triggerClassName="h-9 w-full min-w-0 border-input text-sm focus:border-ring focus:ring-[3px] focus:ring-ring/50" onTriggerEnter={createDisabled ? undefined : onCreate} diff --git a/src/renderer/src/components/new-workspace/new-workspace-composer-card-props.ts b/src/renderer/src/components/new-workspace/new-workspace-composer-card-props.ts index a23ac3e22e0..89c97b6c2ac 100644 --- a/src/renderer/src/components/new-workspace/new-workspace-composer-card-props.ts +++ b/src/renderer/src/components/new-workspace/new-workspace-composer-card-props.ts @@ -38,6 +38,9 @@ export type NewWorkspaceComposerCardProps = { nameInputRef?: React.RefObject quickAgent: TuiAgent | null onQuickAgentChange: (agent: TuiAgent | null) => void + /** "None": skip the agent and the extra shell so only the setup script's tab opens. */ + quickSetupOnly?: boolean + onQuickSetupOnlySelect?: () => void eligibleRepos: readonly RepoOption[] repoId: string projectOptions?: NewWorkspaceProjectOption[] diff --git a/src/renderer/src/components/settings/AgentDefaultSetting.tsx b/src/renderer/src/components/settings/AgentDefaultSetting.tsx index 4f70577bf8f..51cfe1242cd 100644 --- a/src/renderer/src/components/settings/AgentDefaultSetting.tsx +++ b/src/renderer/src/components/settings/AgentDefaultSetting.tsx @@ -1,4 +1,4 @@ -import { Check, Terminal } from 'lucide-react' +import { Ban, Check, Terminal } from 'lucide-react' import type { TuiAgent } from '../../../../shared/tui-agent' import type { AgentCatalogEntry } from '@/lib/agent-catalog' import { AgentIcon } from '@/lib/agent-catalog' @@ -41,17 +41,19 @@ export function AgentDefaultSetting({ enabledDetectedAgents, catalog, description, - onSetDefault + onSetDefault, + allowSetupOnly = true }: { - defaultAgent: TuiAgent | 'blank' | null + allowSetupOnly?: boolean + defaultAgent: TuiAgent | 'blank' | 'setup-only' | null detectedIds: Set | null enabledDetectedAgents: AgentCatalogEntry[] catalog: AgentCatalogEntry[] description: string - onSetDefault: (agent: TuiAgent | 'blank' | null) => void + onSetDefault: (agent: TuiAgent | 'blank' | 'setup-only' | null) => void }): React.JSX.Element { const storedDefaultAgent = - defaultAgent !== null && defaultAgent !== 'blank' + defaultAgent !== null && defaultAgent !== 'blank' && defaultAgent !== 'setup-only' ? catalog.find((agent) => agent.id === defaultAgent) : undefined const defaultAgentPills = @@ -75,6 +77,20 @@ export function AgentDefaultSetting({ {translate('auto.components.settings.AgentsPane.110b74b022', 'No agent (blank terminal)')} {defaultAgent === 'blank' && } + {allowSetupOnly && ( + onSetDefault('setup-only')} + title={translate( + 'auto.components.settings.AgentsPane.setupOnlyHint', + 'Runs setup without an extra blank terminal in new-tab mode. Split mode keeps a shell pane; skipped or missing setup opens a blank terminal.' + )} + > + + {translate('auto.components.settings.AgentsPane.setupOnly', 'None')} + {defaultAgent === 'setup-only' && } + + )} {defaultAgentPills.map((agent) => { const isActive = defaultAgent === agent.id const isUndetected = detectedIds !== null && !detectedIds.has(agent.id) diff --git a/src/renderer/src/components/settings/AgentLaunchSettingsCatalog.tsx b/src/renderer/src/components/settings/AgentLaunchSettingsCatalog.tsx index aae67820ab6..a3c275ea294 100644 --- a/src/renderer/src/components/settings/AgentLaunchSettingsCatalog.tsx +++ b/src/renderer/src/components/settings/AgentLaunchSettingsCatalog.tsx @@ -11,6 +11,7 @@ import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from '../../../../shared/tui-agent-launch-defaults' +import { isNewWorkspaceSetupOnlyDefault } from '../../../../shared/new-workspace-setup-only' import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' import { getAgentCatalog } from '@/lib/agent-catalog' import { useDetectedAgents, type AgentDetectionTarget } from '@/hooks/useDetectedAgents' @@ -109,7 +110,10 @@ export function AgentLaunchSettingsCatalog({ return ( <> isTuiAgentEnabled(agent.id, settings.disabledTuiAgents) @@ -119,7 +123,13 @@ export function AgentLaunchSettingsCatalog({ 'settings.agents.hostDefaults', 'Defaults for new agents on the selected host.' )} - onSetDefault={(agent) => save({ type: 'default', agent })} + onSetDefault={(agent) => + save({ + type: 'default', + agent: agent === 'setup-only' ? 'blank' : agent, + ...(agent === 'setup-only' ? { setupOnly: true } : {}) + }) + } /> {renderPermissions(settings.permissionMode, (mode) => save({ type: 'permissions', mode }))} Promise prepareQuickSubmit: ( resolution: PendingSmartGitHubSubmitResolution, @@ -59,6 +61,6 @@ export type ComposerSubmitModel = { workspaceNameSeed: string ) => QuickSubmitSource | null resetForNextCreate: () => void - submitQuick: (agent: TuiAgent | null) => Promise + submitQuick: (agent: TuiAgent | null, options?: QuickSubmitOptions) => Promise submitFolderTarget: (requestedAgent: TuiAgent | null) => Promise } diff --git a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts index e6b279f98e6..e662651103b 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts @@ -41,7 +41,7 @@ import { runBackgroundWorktreeCreation } from '@/lib/worktree-creation-flow' import { translate } from '@/i18n/i18n' import { resolveQuickCreateLinkedWorkItemPrompt } from '@/lib/linked-work-item-context' import { buildQuickComposerStartup } from './quick-startup-plan' -import { buildQuickCreationRequest } from './quick-creation-request' +import { buildQuickCreationRequest, type QuickSubmitOptions } from './quick-creation-request' import type { PendingSmartGitHubSubmitResolution } from './source-selection-decisions' import { resolveAgentSessionLaunchRoute } from '@/lib/agent-session-launch-plan' @@ -83,7 +83,8 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { requestedAgent: TuiAgent | null, workspaceNameSeed: string, workspaceRunContext: WorktreeCreationRequest['workspaceRunContext'], - repoId: string + repoId: string, + options?: QuickSubmitOptions ): Promise => { const prepared = await prepareQuickSubmit( smartGitHubResolution, @@ -237,7 +238,8 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { launchDraftPrompt: quickDraftPrompt, promptDelivery, quickTelemetry, - suppressTerminalFocusOnCompletion: createMultiple + suppressTerminalFocusOnCompletion: createMultiple, + setupOnly: options?.setupOnly === true }) if (isSubmissionCancelled()) { diff --git a/src/renderer/src/hooks/composer-state/quick-creation-request.test.ts b/src/renderer/src/hooks/composer-state/quick-creation-request.test.ts index c2a35decf34..34f7c6e7b6d 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-request.test.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-request.test.ts @@ -44,6 +44,7 @@ function createInput( promptDelivery: 'auto-submit', quickTelemetry: null, suppressTerminalFocusOnCompletion: false, + setupOnly: false, ...overrides } } @@ -59,6 +60,7 @@ describe('quick composer creation request', () => { linkedTaskSourceContext: null, name: 'characterize-request', setupDecision: 'inherit', + setupOnly: false, agent: null, pendingFirstAgentMessageRename: false, note: '', @@ -138,4 +140,15 @@ describe('quick composer creation request', () => { expect(request).not.toHaveProperty('linkedGitLabMR') expect(request).not.toHaveProperty('linkedGitLabIssue') }) + + it('keeps setupOnly only for agentless creates', () => { + expect(buildQuickCreationRequest(createInput({ setupOnly: true }))).toHaveProperty( + 'setupOnly', + true + ) + expect( + buildQuickCreationRequest(createInput({ setupOnly: true, agent: 'claude' })) + ).not.toHaveProperty('setupOnly') + expect(buildQuickCreationRequest(createInput())).toHaveProperty('setupOnly', false) + }) }) diff --git a/src/renderer/src/hooks/composer-state/quick-creation-request.ts b/src/renderer/src/hooks/composer-state/quick-creation-request.ts index 73ecb8a0f71..1a56baab822 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-request.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-request.ts @@ -51,6 +51,11 @@ export type QuickCreationRequestInput = { promptDelivery: 'draft' | 'auto-submit' quickTelemetry: AgentStartedTelemetry | null suppressTerminalFocusOnCompletion: boolean + setupOnly: boolean +} + +export type QuickSubmitOptions = { + setupOnly?: boolean } export function buildQuickCreationRequest( @@ -113,6 +118,7 @@ export function buildQuickCreationRequest( ...(input.launchDraftPrompt ? { launchDraftPrompt: input.launchDraftPrompt } : {}), promptDelivery: input.promptDelivery, quickTelemetry: input.quickTelemetry, - ...(input.suppressTerminalFocusOnCompletion ? { suppressTerminalFocusOnCompletion: true } : {}) + ...(input.suppressTerminalFocusOnCompletion ? { suppressTerminalFocusOnCompletion: true } : {}), + ...(input.agent === null ? { setupOnly: input.setupOnly } : {}) } } diff --git a/src/renderer/src/hooks/composer-state/quick-submit-action.ts b/src/renderer/src/hooks/composer-state/quick-submit-action.ts index 3bdce721bfd..21013ae3551 100644 --- a/src/renderer/src/hooks/composer-state/quick-submit-action.ts +++ b/src/renderer/src/hooks/composer-state/quick-submit-action.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react' import type { TuiAgent } from '../../../../shared/tui-agent' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' +import type { QuickSubmitOptions } from './quick-creation-request' import { findPendingLinkedWorkItemCreationId } from '@/lib/pending-worktree-creation' import { useAppStore } from '@/store' import { getWorkspaceSeedName } from '@/lib/new-workspace' @@ -66,7 +67,7 @@ export function useQuickSubmitAction(input: QuickSubmitActionInput) { } = input const submitQuick = useCallback( - async (requestedAgent: TuiAgent | null): Promise => { + async (requestedAgent: TuiAgent | null, options?: QuickSubmitOptions): Promise => { if (isProjectGroupTarget) { await submitFolderTarget(requestedAgent) return @@ -143,7 +144,8 @@ export function useQuickSubmitAction(input: QuickSubmitActionInput) { requestedAgent, workspaceNameSeed, workspaceRunContext, - repoId + repoId, + options ) } catch (error) { if (isSubmissionCancelled()) { diff --git a/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts b/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts index 3686e70bf50..524b58a3c68 100644 --- a/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts +++ b/src/renderer/src/hooks/ipc-events/worktree-event-runtime.ts @@ -5,6 +5,7 @@ import { type ExecutionHostId } from '../../../../shared/execution-host' import type { RuntimeClientEvent } from '../../../../shared/runtime-client-events' +import { isNewWorkspaceSetupOnlyDefault } from '../../../../shared/new-workspace-setup-only' import type { AppState } from '../../store/types' import { useAppStore } from '../../store' import { @@ -212,6 +213,10 @@ export function createWorktreeEventRuntime( ...(setup ? { setup } : {}), ...(startup ? { startup } : {}), ...(defaultTabs ? { defaultTabs } : {}), + // Why: CLI creates carry no agent choice, so the "None" default decides whether a shell joins Setup. + ...(setup && !startup && isNewWorkspaceSetupOnlyDefault(useAppStore.getState().settings) + ? { providesInitialSurface: true } + : {}), ...(!existedBeforeFetch && existsAfterFetch ? { sidebarRevealBehavior: 'auto' } : {}), // Why: this activation came from the host runtime stream; echoing it back can create a selection loop. notifyHostRuntime: false, diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 228bc63d15e..5461b4c21a0 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -3,6 +3,7 @@ import '@/lib/new-workspace' import type { LinkedWorkItemSummary } from '@/lib/new-workspace' import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import type { TuiAgent } from '../../../shared/tui-agent' +import type { QuickSubmitOptions } from './composer-state/quick-creation-request' import type { TaskSourceContext } from '../../../shared/task-source-context' import type { WorkspaceSource as WorkspaceCreateTelemetrySource } from '../../../shared/workspace-source' import type { WorkspaceStatus } from '../../../shared/worktree/types' @@ -68,7 +69,7 @@ export type UseComposerStateResult = { onComposerNodeChange: (node: HTMLDivElement | null) => void promptTextareaRef: RefObject nameInputRef: RefObject - submitQuick: (agent: TuiAgent | null) => Promise + submitQuick: (agent: TuiAgent | null, options?: QuickSubmitOptions) => Promise createDisabled: boolean selectAddedProjectRepo: (repoId: string) => void } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1a1bc56e5f9..4b68f7a9ea3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -7127,7 +7127,9 @@ "codexSessionSourceInfo": "About importing Codex history", "codexSessionSourceTooltip": "Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex.", "storedDefaultUndetected": "Saved as your default, but not detected right now", - "noAgentsDetected": "No agents detected. If one is installed, the probe may have timed out." + "noAgentsDetected": "No agents detected. If one is installed, the probe may have timed out.", + "setupOnlyHint": "Runs setup without an extra blank terminal in new-tab mode. Split mode keeps a shell pane; skipped or missing setup opens a blank terminal.", + "setupOnly": "None" }, "AppIconSelector": { "d5a112dc9b": "Next icon", @@ -17255,7 +17257,8 @@ "579c768bde": "No agents match your search.", "48c6a5a9b4": "Search agents...", "9c6b59fe58": "Set as default", - "1b0d6965fa": "Current default" + "1b0d6965fa": "Current default", + "setupOnly": "None" }, "AgentSettingsDialog": { "50cdb57c03": "Manage AI agents, set a default, and customize commands.", diff --git a/src/renderer/src/lib/agent-picker-search.test.ts b/src/renderer/src/lib/agent-picker-search.test.ts index c6f02089c0c..b11b2b55cf4 100644 --- a/src/renderer/src/lib/agent-picker-search.test.ts +++ b/src/renderer/src/lib/agent-picker-search.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AGENT_PICKER_QUERY_MAX_BYTES, agentPickerBlankTerminalMatches, + agentPickerSetupOnlyMatches, getAgentPickerCommandValue, searchAgentPickerEntries } from './agent-picker-search' @@ -194,3 +195,12 @@ function entry(id: AgentCatalogEntry['id'], label: string, cmd: string): AgentCa homepageUrl: 'https://example.com' } } + +describe('agentPickerSetupOnlyMatches', () => { + it('matches the None option by label or setup keywords', () => { + expect(agentPickerSetupOnlyMatches('')).toBe(true) + expect(agentPickerSetupOnlyMatches('none')).toBe(true) + expect(agentPickerSetupOnlyMatches('setup')).toBe(true) + expect(agentPickerSetupOnlyMatches('claude')).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/agent-picker-search.ts b/src/renderer/src/lib/agent-picker-search.ts index b3129feb8cc..ccb0bb5118f 100644 --- a/src/renderer/src/lib/agent-picker-search.ts +++ b/src/renderer/src/lib/agent-picker-search.ts @@ -83,6 +83,21 @@ export function agentPickerBlankTerminalMatches(rawQuery: string): boolean { ) } +export function agentPickerSetupOnlyMatches(rawQuery: string): boolean { + const query = getAgentPickerSearchQuery(rawQuery) + if (query === null) { + return false + } + if (!query) { + return true + } + + return ( + scoreCandidate(query, 'None', 0) !== NO_MATCH || + scoreCandidate(query, 'setup script only', 0) !== NO_MATCH + ) +} + function scoreAgent(agent: AgentCatalogEntry, query: string): number { return Math.min( scoreCandidate(query, agent.label, 0), diff --git a/src/renderer/src/lib/pending-worktree-creation.ts b/src/renderer/src/lib/pending-worktree-creation.ts index 8460d917f05..cab36b2922f 100644 --- a/src/renderer/src/lib/pending-worktree-creation.ts +++ b/src/renderer/src/lib/pending-worktree-creation.ts @@ -124,6 +124,8 @@ export type WorktreeCreationRequest = { /** When the composer stays open for sequential creates, completion must not * steal focus from the next workspace name field. */ suppressTerminalFocusOnCompletion?: boolean + /** "None" in the composer: when setup runs, open only its tab instead of an extra shell. */ + setupOnly?: boolean } /** Renderer-only, session-ephemeral record of an in-flight (or failed) worktree diff --git a/src/renderer/src/lib/worktree-creation-flow-execute.ts b/src/renderer/src/lib/worktree-creation-flow-execute.ts index 2989574d288..9375b3028cd 100644 --- a/src/renderer/src/lib/worktree-creation-flow-execute.ts +++ b/src/renderer/src/lib/worktree-creation-flow-execute.ts @@ -60,10 +60,10 @@ export async function executeWorktreeCreation( return } + const structuredLaunch = preparedRequest.agentLaunchRoute === 'structured-native-chat' let result: CreateWorktreeResult try { const provisionedRoot = getProvisionedRootCreateOptions(preparedRequest) - const structuredLaunch = preparedRequest.agentLaunchRoute === 'structured-native-chat' const backendStartup = provisionedRoot || structuredLaunch ? undefined : resolveBackendDraftStartup(preparedRequest) result = await useAppStore @@ -95,6 +95,7 @@ export async function executeWorktreeCreation( preparedRequest.linkedGiteaPR, preparedRequest.compareBaseRef, { + setupOnly: preparedRequest.setupOnly, executionHostId: preparedRequest.workspaceRunContext?.hostId ?? preparedRequest.executionHostId, ...(preparedRequest.nameWasGenerated ? { nameWasGenerated: true } : {}), @@ -146,7 +147,6 @@ export async function executeWorktreeCreation( } const worktree = result.worktree - const structuredLaunch = preparedRequest.agentLaunchRoute === 'structured-native-chat' // Why: cancellation can race a successful backend adoption; clean up again after it settles so an adopted workspace cannot outlive its destroyed VM. if (!useAppStore.getState().pendingWorktreeCreations[creationId]) { if (preparedRequest.ephemeralVmRuntimeId) { @@ -170,6 +170,8 @@ export async function executeWorktreeCreation( // workspace) is handed it; anyone who moved on (another workspace or an app // view) keeps their place and gets a toast instead (#9944). const shouldActivateOnCompletion = isCreatedWorkspaceInView(creationId, worktree.id) + // Why: "None" leaves setup as the only surface; without a setup run it falls back to a shell. + const setupIsOnlySurface = preparedRequest.setupOnly === true && result.setup !== undefined // Why: the worktree exists past this point and nothing awaits this caller, so // each follow-up step is best-effort — an escaped throw would strand the @@ -185,7 +187,8 @@ export async function executeWorktreeCreation( ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), ...(startupOpt ? { startup: startupOpt } : {}), ...(preparedRequest.issueCommand ? { issueCommand: preparedRequest.issueCommand } : {}), - ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}) + ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}), + ...(setupIsOnlySurface ? { providesInitialSurface: true } : {}) }) primaryTabId = activation === false ? null : activation.primaryTabId } catch (error) { @@ -244,6 +247,7 @@ export async function executeWorktreeCreation( const hasExplicitTerminalWork = Boolean( startupOpt || result.setup || preparedRequest.issueCommand || result.defaultTabs ) + const callerProvidesSurface = preparedRequest.agent !== null || setupIsOnlySurface if (preparedRequest.agent === null || hasExplicitTerminalWork) { try { primaryTabId = ensureWorktreeHasInitialTerminal( @@ -255,7 +259,7 @@ export async function executeWorktreeCreation( result.defaultTabs, { activateCreatedTabs: false, - ...(preparedRequest.agent !== null ? { callerProvidesSurface: true } : {}), + ...(callerProvidesSurface ? { callerProvidesSurface: true } : {}), ...(backendSpawned ? { backendStartupTerminalSpawned: true } : {}) } ) diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index f719025dd5b..01864d622a0 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -609,6 +609,68 @@ describe('staged background worktree creation', () => { expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled() }) + it('opens only the setup tab when the composer chose None and setup runs', async () => { + store.activeView = 'terminal' + store.activePendingCreationId = 'creation-1' + const setup = { runnerScriptPath: '/repo/.orca/setup.sh', envVars: {} } + store.createWorktree.mockResolvedValueOnce({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo/wt-1' }, + setup + }) + vi.mocked(activateAndRevealWorktree).mockReturnValueOnce({ primaryTabId: null }) + + continueBackgroundWorktreeCreation('creation-1', makeRequest({ setupOnly: true })) + + await vi.waitFor(() => + expect(activateAndRevealWorktree).toHaveBeenCalledWith( + 'wt-1', + expect.objectContaining({ setup, providesInitialSurface: true }) + ) + ) + expect(store.createWorktree.mock.calls[0]?.at(-1)).toMatchObject({ setupOnly: true }) + }) + + it('falls back to a blank terminal for None when no setup runs', async () => { + store.activeView = 'terminal' + store.activePendingCreationId = 'creation-1' + store.createWorktree.mockResolvedValueOnce({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo/wt-1' } + }) + vi.mocked(activateAndRevealWorktree).mockReturnValueOnce({ primaryTabId: 'tab-1' }) + + continueBackgroundWorktreeCreation('creation-1', makeRequest({ setupOnly: true })) + + await vi.waitFor(() => expect(activateAndRevealWorktree).toHaveBeenCalled()) + expect(vi.mocked(activateAndRevealWorktree).mock.calls[0]?.[1]).not.toHaveProperty( + 'providesInitialSurface' + ) + }) + + it('seeds only the setup tab for None when the user moved on before completion', async () => { + store.activeView = 'tasks' + const setup = { runnerScriptPath: '/repo/.orca/setup.sh', envVars: {} } + store.createWorktree.mockResolvedValueOnce({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo/wt-1' }, + setup + }) + + continueBackgroundWorktreeCreation('creation-1', makeRequest({ setupOnly: true }), { + revealCreationSurface: false + }) + + await vi.waitFor(() => + expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith( + store, + 'wt-1', + undefined, + setup, + undefined, + undefined, + { activateCreatedTabs: false, callerProvidesSurface: true } + ) + ) + }) + it('seeds the chat-composer launch draft on completion for draft launches', async () => { store.activeView = 'terminal' store.activePendingCreationId = 'creation-1' diff --git a/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts b/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts index 76901272bf1..f09fd596a9b 100644 --- a/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts +++ b/src/renderer/src/store/slices/worktrees/create/worktree-create-payload.ts @@ -13,6 +13,7 @@ export type CreateWorktreeCallOptions = { linkedTaskSourceContext?: TaskSourceContext | null /** Lets the owning runtime launch and prefill a task agent without first creating an idle shell. */ startupDraft?: string + setupOnly?: boolean /** True only when `name` came from the creature-name generator; gates host-side retirement. */ nameWasGenerated?: boolean displayNameKind?: CreateWorktreeArgs['displayNameKind'] @@ -53,6 +54,7 @@ function sharedCreateFields( ...(request.compareBaseRef ? { compareBaseRef: request.compareBaseRef } : {}), ...(attempt.branchNameOverride ? { branchNameOverride: attempt.branchNameOverride } : {}), setupDecision: request.setupDecision, + ...(options?.setupOnly !== undefined ? { setupOnly: options.setupOnly } : {}), sparseCheckout: request.sparseCheckout, ...(request.displayName ? { displayName: request.displayName } : {}), ...((request.displayNameKind ?? options?.displayNameKind) diff --git a/src/shared/agent-launch-settings.ts b/src/shared/agent-launch-settings.ts index 8628cae58e9..8bb197cc6f9 100644 --- a/src/shared/agent-launch-settings.ts +++ b/src/shared/agent-launch-settings.ts @@ -1,5 +1,9 @@ import type { GlobalSettings } from './global-settings-types' import type { TuiAgent } from './tui-agent' +import { + buildDefaultAgentSettingsUpdate, + isNewWorkspaceSetupOnlyDefault +} from './new-workspace-setup-only' import { buildAgentAvailabilitySettingsUpdate, normalizeDisabledTuiAgents @@ -15,6 +19,7 @@ export type AgentLaunchSettingsSource = Partial< Pick< GlobalSettings, | 'defaultTuiAgent' + | 'newWorkspaceSetupOnly' | 'disabledTuiAgents' | 'agentCmdOverrides' | 'agentDefaultArgs' @@ -24,6 +29,7 @@ export type AgentLaunchSettingsSource = Partial< export type AgentLaunchSettings = { defaultTuiAgent: GlobalSettings['defaultTuiAgent'] + newWorkspaceSetupOnly?: boolean disabledTuiAgents: TuiAgent[] agentCmdOverrides: NonNullable agentDefaultArgs: NonNullable @@ -32,7 +38,7 @@ export type AgentLaunchSettings = { } export type AgentLaunchSettingsMutation = - | { type: 'default'; agent: GlobalSettings['defaultTuiAgent'] } + | { type: 'default'; agent: GlobalSettings['defaultTuiAgent']; setupOnly?: boolean } | { type: 'availability'; agent: TuiAgent; enabled: boolean } | { type: 'permissions'; mode: Exclude } | { type: 'command'; agent: TuiAgent; value: string } @@ -45,6 +51,7 @@ export function projectAgentLaunchSettings( ): AgentLaunchSettings { return { defaultTuiAgent: settings.defaultTuiAgent ?? null, + newWorkspaceSetupOnly: isNewWorkspaceSetupOnlyDefault(settings), disabledTuiAgents: normalizeDisabledTuiAgents(settings.disabledTuiAgents), agentCmdOverrides: { ...settings.agentCmdOverrides }, agentDefaultArgs: normalizeTuiAgentArgsRecord(settings.agentDefaultArgs), @@ -65,7 +72,9 @@ export function agentLaunchSettingsMutationUpdates( ): AgentLaunchSettingsSource { switch (mutation.type) { case 'default': - return { defaultTuiAgent: mutation.agent } + return buildDefaultAgentSettingsUpdate( + mutation.agent === 'blank' && mutation.setupOnly === true ? 'setup-only' : mutation.agent + ) case 'availability': return buildAgentAvailabilitySettingsUpdate( { diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 66a58756e6d..c2dc053eff5 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -343,6 +343,9 @@ export type GlobalSettings = NativeChatGlobalSettings & { * - 'blank': blank terminal (no agent launched) * - TuiAgent: a specific agent id */ defaultTuiAgent: TuiAgent | 'blank' | null + /** Refines a 'blank' default to "None": workspaces with a setup script open only the Setup tab. + * Separate flag so clients that only know 'blank' degrade to a blank terminal. */ + newWorkspaceSetupOnly?: boolean /** Agents hidden from picker/auto-launch; detection stays a raw PATH snapshot. */ disabledTuiAgents: TuiAgent[] /** Master switch for the experimental plugin system. Off by default: no diff --git a/src/shared/new-workspace-setup-only.test.ts b/src/shared/new-workspace-setup-only.test.ts new file mode 100644 index 00000000000..87679f46be0 --- /dev/null +++ b/src/shared/new-workspace-setup-only.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { + buildDefaultAgentSettingsUpdate, + isNewWorkspaceSetupOnlyDefault +} from './new-workspace-setup-only' + +describe('new workspace setup-only default', () => { + it('stores "None" as a blank default plus the flag', () => { + expect(buildDefaultAgentSettingsUpdate('setup-only')).toEqual({ + defaultTuiAgent: 'blank', + newWorkspaceSetupOnly: true + }) + }) + + it('clears the flag when any other default is chosen', () => { + expect(buildDefaultAgentSettingsUpdate('blank')).toEqual({ + defaultTuiAgent: 'blank', + newWorkspaceSetupOnly: false + }) + expect(buildDefaultAgentSettingsUpdate('claude')).toEqual({ + defaultTuiAgent: 'claude', + newWorkspaceSetupOnly: false + }) + expect(buildDefaultAgentSettingsUpdate(null)).toEqual({ + defaultTuiAgent: null, + newWorkspaceSetupOnly: false + }) + }) + + it('honours the flag only alongside a blank default', () => { + expect( + isNewWorkspaceSetupOnlyDefault({ defaultTuiAgent: 'blank', newWorkspaceSetupOnly: true }) + ).toBe(true) + // Another client may have switched the default to an agent without knowing the flag. + expect( + isNewWorkspaceSetupOnlyDefault({ defaultTuiAgent: 'claude', newWorkspaceSetupOnly: true }) + ).toBe(false) + expect(isNewWorkspaceSetupOnlyDefault({ defaultTuiAgent: 'blank' })).toBe(false) + expect(isNewWorkspaceSetupOnlyDefault(null)).toBe(false) + }) +}) diff --git a/src/shared/new-workspace-setup-only.ts b/src/shared/new-workspace-setup-only.ts new file mode 100644 index 00000000000..059e921916c --- /dev/null +++ b/src/shared/new-workspace-setup-only.ts @@ -0,0 +1,20 @@ +import type { GlobalSettings } from './global-settings-types' +import type { TuiAgent } from './tui-agent' + +type DefaultAgentSettings = Pick + +/** "None": no agent and no extra shell; the new workspace shows only its Setup tab. */ +export function isNewWorkspaceSetupOnlyDefault( + settings: Partial | null | undefined +): boolean { + return settings?.defaultTuiAgent === 'blank' && settings.newWorkspaceSetupOnly === true +} + +export function buildDefaultAgentSettingsUpdate( + next: TuiAgent | 'blank' | 'setup-only' | null +): DefaultAgentSettings { + // Why: persist "None" as 'blank' + flag so clients without the flag still open a blank terminal. + return next === 'setup-only' + ? { defaultTuiAgent: 'blank', newWorkspaceSetupOnly: true } + : { defaultTuiAgent: next, newWorkspaceSetupOnly: false } +} diff --git a/src/shared/rpc-contract/agent-launch-settings-params.ts b/src/shared/rpc-contract/agent-launch-settings-params.ts index c2b679c49cd..cc7de5d33b9 100644 --- a/src/shared/rpc-contract/agent-launch-settings-params.ts +++ b/src/shared/rpc-contract/agent-launch-settings-params.ts @@ -15,7 +15,11 @@ const Name = z export const AgentLaunchSettingsMutation = z.discriminatedUnion('type', [ z - .object({ type: z.literal('default'), agent: z.union([Agent, z.literal('blank'), z.null()]) }) + .object({ + type: z.literal('default'), + agent: z.union([Agent, z.literal('blank'), z.null()]), + setupOnly: z.boolean().optional() + }) .strict(), z.object({ type: z.literal('availability'), agent: Agent, enabled: z.boolean() }).strict(), z.object({ type: z.literal('permissions'), mode: z.enum(['manual', 'yolo']) }).strict(), diff --git a/src/shared/rpc-contract/worktree-create-params.ts b/src/shared/rpc-contract/worktree-create-params.ts index e538b8fbbb1..e674f3a40bc 100644 --- a/src/shared/rpc-contract/worktree-create-params.ts +++ b/src/shared/rpc-contract/worktree-create-params.ts @@ -95,6 +95,7 @@ export const WorktreeCreate = z coordinatorHandle: OptionalString }) .optional(), + setupOnly: z.boolean().optional(), setupDecision: z .unknown() .transform((v) => diff --git a/src/shared/worktree/create-types.ts b/src/shared/worktree/create-types.ts index 08b9241e881..36a3edcb722 100644 --- a/src/shared/worktree/create-types.ts +++ b/src/shared/worktree/create-types.ts @@ -111,6 +111,8 @@ export type CreateWorktreeArgs = { * legitimately contains `/` while the worktree directory must not. */ branchNameOverride?: string setupDecision?: SetupDecision + /** Per-create choice; omitted callers use the execution host's saved default. */ + setupOnly?: boolean sparseCheckout?: CreateSparseCheckoutRequest linkedIssue?: number linkedPR?: number