From 701e9f2bdf8c2be48cc94f2b9c3e93ac60dbb770 Mon Sep 17 00:00:00 2001 From: Leo Giovanetti Date: Wed, 14 Oct 2026 00:12:44 -0300 Subject: [PATCH] feat(new-workspace): add "None" agent option that opens only the setup script (#25617) * feat(new-workspace): add "None" agent option that opens only the setup script New workspaces previously required choosing an agent or a blank terminal, so a setup script that launches its own agent always ran next to an extra shell. The quick-create agent picker and Settings > Agents now offer "None (setup script only)": when setup runs, the workspace opens with just the Setup tab focused; without a setup script it falls back to a blank terminal. The default is stored as defaultTuiAgent 'blank' plus a new optional newWorkspaceSetupOnly flag, so clients and servers that only know 'blank' degrade to a blank terminal instead of rejecting an unknown value. Closes stablyai/orca#25247 * fix(new-workspace): honor the "None" default for CLI-created workspaces "None (setup script only)" only took effect in the new-workspace dialog. A workspace created with `orca worktree create` still opened a blank terminal first and left it selected, because the main process builds those tabs and never read the setting. The main-process tab provisioning now skips the blank terminal when the default is "None" and a setup script runs in its own tab, so Setup is the only tab. The same rule applies to CLI creates that activate the workspace, and to dialog creates the user navigated away from before they finished. A split setup or repo default tabs still get their shell. The setup-only check moved to src/shared so main and renderer share it. Co-Authored-By: Claude Fable 5.1 * fix(new-workspace): register the CLI activation caller and stay under the line limit The CLI activation path now passes providesInitialSurface for the "None" default, so it has to appear in the test that lists every caller using that option; the full test run failed without it. Merging main pushed worktree-creation-flow-execute.ts to 301 lines against the 300-line limit. Naming the background-create condition brings it back to 300 with no behavior change. Co-Authored-By: Claude Fable 5.1 * fix(new-workspace): open a blank terminal when the setup-only Setup tab fails to spawn The "None" default skips the blank shell because the Setup tab will be the only surface. If that spawn fails, the workspace was left with no terminal. Co-Authored-By: Claude Fable 5.1 * style: format the setup-only fallback terminal call Co-Authored-By: Claude Fable 5.1 * fix(workspace): save None through host settings and honor per-create choices * fix(settings): clear None when legacy clients choose a blank terminal * fix(settings): clarify None with split or skipped setup --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: Neil --- .../runtime/agent-launch-settings-rollback.ts | 11 +- .../orca-runtime-activate-managed-worktree.ts | 1 + .../rpc/methods/worktree-create-args.ts | 1 + .../worktree-create-setup-choice.test.ts | 10 + ...t-agent-launch-settings-durability.test.ts | 42 ++++ ...ntime-client-agent-launch-settings.test.ts | 32 +++ .../runtime/runtime-client-settings-shape.ts | 1 + src/main/runtime/runtime-client-settings.ts | 10 +- ...me-local-worktree-terminal-startup.test.ts | 20 ++ ...runtime-local-worktree-terminal-startup.ts | 1 + .../runtime-managed-worktree-create-types.ts | 1 + src/main/runtime/runtime-store-contract.ts | 2 + ...ime-worktree-terminal-provisioning.test.ts | 95 +++++++++ .../runtime-worktree-terminal-provisioning.ts | 31 ++- .../components/NewWorkspaceComposerCard.tsx | 12 +- .../components/NewWorkspaceComposerModal.tsx | 20 +- .../components/agent/AgentCombobox.test.tsx | 34 ++++ .../src/components/agent/AgentCombobox.tsx | 186 +++++++----------- .../components/agent/agent-combobox-items.tsx | 100 ++++++++++ .../NewWorkspaceComposerAgentSection.test.tsx | 2 + .../NewWorkspaceComposerAgentSection.tsx | 20 +- .../new-workspace-composer-card-props.ts | 3 + .../settings/AgentDefaultSetting.tsx | 26 ++- .../settings/AgentLaunchSettingsCatalog.tsx | 14 +- .../composer-state/composer-submit-model.ts | 6 +- .../quick-creation-execution.ts | 8 +- .../quick-creation-request.test.ts | 13 ++ .../composer-state/quick-creation-request.ts | 8 +- .../composer-state/quick-submit-action.ts | 6 +- .../ipc-events/worktree-event-runtime.ts | 5 + src/renderer/src/hooks/useComposerState.ts | 3 +- src/renderer/src/i18n/locales/en.json | 7 +- .../src/lib/agent-picker-search.test.ts | 10 + src/renderer/src/lib/agent-picker-search.ts | 15 ++ .../src/lib/pending-worktree-creation.ts | 2 + .../src/lib/worktree-creation-flow-execute.ts | 12 +- .../src/lib/worktree-creation-flow.test.ts | 62 ++++++ .../create/worktree-create-payload.ts | 2 + src/shared/agent-launch-settings.ts | 13 +- src/shared/global-settings-types.ts | 3 + src/shared/new-workspace-setup-only.test.ts | 41 ++++ src/shared/new-workspace-setup-only.ts | 20 ++ .../agent-launch-settings-params.ts | 6 +- .../rpc-contract/worktree-create-params.ts | 1 + src/shared/worktree/create-types.ts | 2 + 45 files changed, 767 insertions(+), 153 deletions(-) create mode 100644 src/main/runtime/rpc/methods/worktree-create-setup-choice.test.ts create mode 100644 src/renderer/src/components/agent/agent-combobox-items.tsx create mode 100644 src/shared/new-workspace-setup-only.test.ts create mode 100644 src/shared/new-workspace-setup-only.ts 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