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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* style: format the setup-only fallback terminal call

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
Leo Giovanetti
2026-10-13 20:12:44 -07:00
committed by GitHub
co-authored by Claude Fable 5.1 Neil
parent a622bb0f4b
commit 701e9f2bdf
45 changed files with 767 additions and 153 deletions
@@ -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 ?? [] }
@@ -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
@@ -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
@@ -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 })
})
})
@@ -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)
@@ -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'
@@ -7,6 +7,7 @@ import type { SourceControlAiActionDefaults } from '../../shared/source-control-
export type RuntimeClientSettings = Pick<
GlobalSettings,
| 'defaultTuiAgent'
| 'newWorkspaceSetupOnly'
| 'disabledTuiAgents'
| 'agentCmdOverrides'
| 'agentDefaultArgs'
+9 -1
View File
@@ -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?.()
@@ -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) => {
@@ -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 } : {})
@@ -47,6 +47,7 @@ export type RuntimeManagedWorktreeCreateArgs = {
runHooks?: boolean
activate?: boolean
setupDecision?: 'run' | 'skip' | 'inherit'
setupOnly?: boolean
awaitTerminalProvisioning?: boolean
observeSetupCompletion?: boolean
createdWithAgent?: TuiAgent
@@ -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']
@@ -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<string, unknown>): {
host: WorktreeTerminalProvisioningHost
titles: () => (string | undefined)[]
} {
const createTerminal = vi.fn<WorktreeTerminalProvisioningHost['createTerminal']>(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<WorktreeTerminalProvisioningHost['getSettings']>,
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)
})
})
@@ -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<Pick<GlobalSettings, 'setupScriptLaunchMode'>>)
.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 }
}
@@ -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<HTMLElement>(
'[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}
/>
<NewWorkspaceComposerAdvancedSection
{...props}
@@ -26,6 +26,7 @@ import type { WorkspaceStatus } from '../../../shared/worktree/types'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import { translate } from '@/i18n/i18n'
import { getWorkspaceComposerInitialFocusTarget } from '@/lib/workspace-composer-initial-focus'
import { isNewWorkspaceSetupOnlyDefault } from '../../../shared/new-workspace-setup-only'
import { getFolderWorkspacePrimaryActionLabel } from '@/components/sidebar/folder-workspace-composer-helpers'
// Why: match App-level AddRepoDialog loading — the add flow is off the hot
@@ -184,14 +185,27 @@ function QuickTabBody({
setQuickAgentOverride(resolvedQuickAgentSelection.quickAgentOverride)
}
const quickAgent = resolvedQuickAgentSelection.quickAgent
const [quickSetupOnlyOverride, setQuickSetupOnlyOverride] = useState<boolean | undefined>(
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<void> => {
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)}
@@ -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(
<AgentCombobox
agents={[]}
value={null}
onValueChange={vi.fn()}
allowBlankTerminal
defaultAgent="blank"
onSetDefault={onSetDefault}
setupOnly={{ selected: false, isDefault: true, onSelect: vi.fn() }}
/>
)
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(
<AgentCombobox
agents={[]}
value={null}
onValueChange={vi.fn()}
allowBlankTerminal
setupOnly={{ selected: true, isDefault: false, onSelect: vi.fn(), onSetDefault }}
/>
)
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(
@@ -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 (
<span className="inline-flex min-w-0 flex-1 items-center gap-1.5">
<span className="inline-flex size-3.5 shrink-0 items-center justify-center [&_img]:size-3.5 [&_svg]:size-3.5!">
{icon}
</span>
<span className="truncate leading-none">{label}</span>
</span>
)
}
function AgentDefaultContextMenu({
children,
isDefault,
onSetDefault
}: AgentDefaultContextMenuProps): React.ReactNode {
if (!onSetDefault) {
return children
}
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="z-[70]">
<ContextMenuItem onSelect={onSetDefault} disabled={isDefault}>
<Star className="size-3.5" />
{isDefault
? translate('auto.components.agent.AgentCombobox.1b0d6965fa', 'Current default')
: translate('auto.components.agent.AgentCombobox.9c6b59fe58', 'Set as default')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
function renderItem({
key,
itemValue,
isChecked,
isDefault,
onSelect,
onSetDefault,
icon,
label
}: ItemRenderArgs): React.ReactNode {
const row = (
<CommandItem
key={key}
value={itemValue}
onSelect={onSelect}
className="items-center gap-2 px-3 py-1.5"
>
<Check
className={cn('size-4 shrink-0 text-foreground', isChecked ? 'opacity-100' : 'opacity-0')}
/>
<AgentIconLabel icon={icon} label={label} />
</CommandItem>
)
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.
<AgentDefaultContextMenu key={key} isDefault={isDefault} onSetDefault={onSetDefault}>
{row}
</AgentDefaultContextMenu>
)
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({
<Popover open={open} onOpenChange={handleOpenChange}>
<AgentDefaultContextMenu
isDefault={
selectedDefaultPreference !== null && defaultAgent === selectedDefaultPreference
setupOnlySelected
? setupOnly.isDefault
: selectedDefaultPreference !== null &&
defaultAgent === selectedDefaultPreference &&
!(selectedDefaultPreference === 'blank' && setupOnly?.isDefault)
}
onSetDefault={
onSetDefault && selectedDefaultPreference !== null
? () => onSetDefault(selectedDefaultPreference)
: undefined
setupOnlySelected
? setupOnly.onSetDefault
: onSetDefault && selectedDefaultPreference !== null
? () => onSetDefault(selectedDefaultPreference)
: undefined
}
>
<PopoverTrigger asChild>
@@ -342,7 +282,9 @@ export default function AgentCombobox({
)}
data-agent-combobox-root="true"
>
{selectedAgent ? (
{setupOnlySelected ? (
<AgentIconLabel icon={<Ban className="size-3.5" />} label={setupOnlyLabel()} />
) : selectedAgent ? (
<AgentIconLabel
icon={<AgentIcon agent={selectedAgent.id} size={14} />}
label={selectedAgent.label}
@@ -397,11 +339,11 @@ export default function AgentCombobox({
)}
</CommandEmpty>
{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: <Terminal className="size-3.5" />,
@@ -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: <Ban className="size-3.5" />,
label: setupOnlyLabel()
})
: null}
{filteredAgents.map((agent) =>
renderItem({
renderAgentComboboxItem({
key: agent.id,
itemValue: agent.id,
isChecked: value === agent.id,
@@ -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 (
<span className="inline-flex min-w-0 flex-1 items-center gap-1.5">
<span className="inline-flex size-3.5 shrink-0 items-center justify-center [&_img]:size-3.5 [&_svg]:size-3.5!">
{icon}
</span>
<span className="truncate leading-none">{label}</span>
</span>
)
}
export function AgentDefaultContextMenu({
children,
isDefault,
onSetDefault
}: AgentDefaultContextMenuProps): React.ReactNode {
if (!onSetDefault) {
return children
}
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="z-[70]">
<ContextMenuItem onSelect={onSetDefault} disabled={isDefault}>
<Star className="size-3.5" />
{isDefault
? translate('auto.components.agent.AgentCombobox.1b0d6965fa', 'Current default')
: translate('auto.components.agent.AgentCombobox.9c6b59fe58', 'Set as default')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
export function renderAgentComboboxItem({
key,
itemValue,
isChecked,
isDefault,
onSelect,
onSetDefault,
icon,
label
}: ItemRenderArgs): React.ReactNode {
const row = (
<CommandItem
key={key}
value={itemValue}
onSelect={onSelect}
className="items-center gap-2 px-3 py-1.5"
>
<Check
className={cn('size-4 shrink-0 text-foreground', isChecked ? 'opacity-100' : 'opacity-0')}
/>
<AgentIconLabel icon={icon} label={label} />
</CommandItem>
)
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.
<AgentDefaultContextMenu key={key} isDefault={isDefault} onSetDefault={onSetDefault}>
{row}
</AgentDefaultContextMenu>
)
}
@@ -24,6 +24,8 @@ describe('NewWorkspaceComposerAgentSection agent picker', () => {
visibleQuickAgents={AGENT_CATALOG}
defaultTuiAgent="blank"
handleSetDefaultAgent={vi.fn()}
setupOnlyIsDefault={false}
handleSetSetupOnlyDefault={vi.fn()}
/>
</TooltipProvider>
)
@@ -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<NonNullable<React.ComponentProps<typeof AgentCombobox>['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}
@@ -38,6 +38,9 @@ export type NewWorkspaceComposerCardProps = {
nameInputRef?: React.RefObject<HTMLInputElement | null>
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[]
@@ -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<string> | 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' && <Check className="size-3.5" />}
</DefaultAgentPill>
{allowSetupOnly && (
<DefaultAgentPill
active={defaultAgent === 'setup-only'}
onClick={() => 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.'
)}
>
<Ban className="size-3.5" />
{translate('auto.components.settings.AgentsPane.setupOnly', 'None')}
{defaultAgent === 'setup-only' && <Check className="size-3.5" />}
</DefaultAgentPill>
)}
{defaultAgentPills.map((agent) => {
const isActive = defaultAgent === agent.id
const isUndetected = detectedIds !== null && !detectedIds.has(agent.id)
@@ -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 (
<>
<AgentDefaultSetting
defaultAgent={settings.defaultTuiAgent}
defaultAgent={
isNewWorkspaceSetupOnlyDefault(settings) ? 'setup-only' : settings.defaultTuiAgent
}
allowSetupOnly={settings.newWorkspaceSetupOnly !== undefined}
detectedIds={detectedIds}
enabledDetectedAgents={detectedAgents.filter((agent) =>
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 }))}
<AgentDetectionCatalog
@@ -1,4 +1,5 @@
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { QuickSubmitOptions } from './quick-creation-request'
import type { GitPushTarget } from '../../../../shared/worktree/types'
import type { SetupDecision } from '../../../../shared/worktree/create-types'
import type { WorkspaceIntentName } from '../../../../shared/workspace-name'
@@ -46,7 +47,8 @@ export type ComposerSubmitModel = {
requestedAgent: TuiAgent | null,
workspaceNameSeed: string,
workspaceRunContext: WorktreeCreationRequest['workspaceRunContext'],
repoId: string
repoId: string,
options?: QuickSubmitOptions
) => Promise<void>
prepareQuickSubmit: (
resolution: PendingSmartGitHubSubmitResolution,
@@ -59,6 +61,6 @@ export type ComposerSubmitModel = {
workspaceNameSeed: string
) => QuickSubmitSource | null
resetForNextCreate: () => void
submitQuick: (agent: TuiAgent | null) => Promise<void>
submitQuick: (agent: TuiAgent | null, options?: QuickSubmitOptions) => Promise<void>
submitFolderTarget: (requestedAgent: TuiAgent | null) => Promise<void>
}
@@ -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<void> => {
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()) {
@@ -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)
})
})
@@ -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 } : {})
}
}
@@ -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<void> => {
async (requestedAgent: TuiAgent | null, options?: QuickSubmitOptions): Promise<void> => {
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()) {
@@ -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,
+2 -1
View File
@@ -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<HTMLTextAreaElement | null>
nameInputRef: RefObject<HTMLInputElement | null>
submitQuick: (agent: TuiAgent | null) => Promise<void>
submitQuick: (agent: TuiAgent | null, options?: QuickSubmitOptions) => Promise<void>
createDisabled: boolean
selectAddedProjectRepo: (repoId: string) => void
}
+5 -2
View File
@@ -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.",
@@ -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)
})
})
@@ -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),
@@ -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
@@ -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 } : {})
}
)
@@ -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'
@@ -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)
+11 -2
View File
@@ -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<GlobalSettings['agentCmdOverrides']>
agentDefaultArgs: NonNullable<GlobalSettings['agentDefaultArgs']>
@@ -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<AgentPermissionMode, 'mixed'> }
| { 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(
{
+3
View File
@@ -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
@@ -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)
})
})
+20
View File
@@ -0,0 +1,20 @@
import type { GlobalSettings } from './global-settings-types'
import type { TuiAgent } from './tui-agent'
type DefaultAgentSettings = Pick<GlobalSettings, 'defaultTuiAgent' | 'newWorkspaceSetupOnly'>
/** "None": no agent and no extra shell; the new workspace shows only its Setup tab. */
export function isNewWorkspaceSetupOnlyDefault(
settings: Partial<DefaultAgentSettings> | 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 }
}
@@ -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(),
@@ -95,6 +95,7 @@ export const WorktreeCreate = z
coordinatorHandle: OptionalString
})
.optional(),
setupOnly: z.boolean().optional(),
setupDecision: z
.unknown()
.transform((v) =>
+2
View File
@@ -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