fix(automations): stop offering Blank Terminal where it cannot be chosen (#26961)

Blank Terminal is now opt-in on AgentCombobox. Only the new-workspace
composer opts in; automations, source-control agent actions, and chat
continuation offer agents only. The props are a discriminated union so
agent-only callers' onValueChange never receives null.

Fixes STA-9868
This commit is contained in:
Jinwoo Hong
2026-10-09 16:48:00 -04:00
committed by GitHub
parent b8a0554357
commit 4a964633f4
9 changed files with 212 additions and 50 deletions
@@ -23,8 +23,17 @@ vi.mock('@/lib/agent-catalog', () => ({
getAgentLabel: () => 'Codex'
}))
vi.mock('@/components/agent/AgentCombobox', () => ({
default: ({ value }: { value: string | null }) =>
React.createElement('div', { 'data-agent': value ?? '' })
default: ({
value,
allowBlankTerminal
}: {
value: string | null
allowBlankTerminal?: boolean
}) =>
React.createElement('div', {
'data-agent': value ?? '',
'data-allow-blank': String(allowBlankTerminal === true)
})
}))
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ open, children }: { open: boolean; children?: ReactNode }) =>
@@ -105,5 +114,7 @@ describe('AgentSessionContinuationDialog', () => {
await act(async () => resolveSecond(['codex']))
await vi.waitFor(() => expect(container.querySelector('[data-agent="codex"]')).not.toBeNull())
// Why: continuing a chat needs an agent to resume it into.
expect(container.querySelector('[data-allow-blank="false"]')).not.toBeNull()
})
})
@@ -200,12 +200,7 @@ export function AgentSessionContinuationDialog({
agents={agents}
value={selectedAgent}
onValueChange={setSelectedAgent}
allowBlankTerminal={false}
allowNarrowTrigger
emptyLabel={translate(
'components.agentSessionContinuation.selectAgent',
'Select an Agent'
)}
triggerClassName="min-w-0 w-full"
/>
{detecting ? (
@@ -38,6 +38,7 @@ describe('AgentCombobox', () => {
agents={AGENT_CATALOG}
value={null}
onValueChange={vi.fn()}
allowBlankTerminal
defaultAgent="codex"
onSetDefault={onSetDefault}
/>
@@ -52,14 +53,7 @@ describe('AgentCombobox', () => {
it('does not offer a default action for an Agent-only empty state', () => {
const onSetDefault = vi.fn()
render(
<AgentCombobox
agents={[]}
value={null}
onValueChange={vi.fn()}
allowBlankTerminal={false}
emptyLabel="Select an Agent"
onSetDefault={onSetDefault}
/>
<AgentCombobox agents={[]} value={null} onValueChange={vi.fn()} onSetDefault={onSetDefault} />
)
fireEvent.contextMenu(screen.getByRole('combobox'))
@@ -162,21 +156,41 @@ describe('AgentCombobox', () => {
}
})
it('supports an Agent-only empty state without presenting a blank terminal', () => {
it('defaults to an Agent-only empty state without presenting a blank terminal', () => {
const markup = renderToStaticMarkup(
<AgentCombobox
agents={[]}
value={null}
onValueChange={vi.fn()}
allowBlankTerminal={false}
emptyLabel="Select an Agent"
/>
<AgentCombobox agents={[]} value={null} onValueChange={vi.fn()} />
)
expect(markup).toContain('Select an Agent')
expect(markup).not.toContain('Blank Terminal')
})
it('hides Blank Terminal from the list unless the caller opts in', () => {
render(<AgentCombobox agents={AGENT_CATALOG} value="codex" onValueChange={vi.fn()} />)
fireEvent.click(screen.getByRole('combobox'))
expect(screen.getByRole('option', { name: 'Claude' })).toBeTruthy()
expect(screen.queryByRole('option', { name: 'Blank Terminal' })).toBeNull()
})
it('offers Blank Terminal and reports null when the caller opts in', () => {
const onValueChange = vi.fn()
render(
<AgentCombobox
agents={AGENT_CATALOG}
value="codex"
onValueChange={onValueChange}
allowBlankTerminal
/>
)
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(screen.getByRole('option', { name: 'Blank Terminal' }))
expect(onValueChange).toHaveBeenCalledWith(null)
})
it('uses the bundled OpenClaude favicon crop instead of Claude or GitHub artwork', () => {
const markup = renderToStaticMarkup(<AgentIcon agent="openclaude" />)
@@ -32,11 +32,15 @@ import { translate } from '@/i18n/i18n'
type DefaultAgentPreference = TuiAgent | 'blank' | null
type AgentComboboxProps = {
// Why: Blank Terminal is opt-in so agent-only flows (automations, source-control
// actions) never offer a choice they cannot run, and never receive null for it.
type AgentComboboxSelectionProps =
| { allowBlankTerminal: true; onValueChange: (agent: TuiAgent | null) => void }
| { allowBlankTerminal?: false; onValueChange: (agent: TuiAgent) => void }
type AgentComboboxProps = AgentComboboxSelectionProps & {
agents: AgentCatalogEntry[]
value: TuiAgent | null
onValueChange: (agent: TuiAgent | null) => void
onValueSelected?: (agent: TuiAgent | null) => void
onOpenManageAgents?: () => void
/** Current saved default agent preference. Used to render a subtle "default"
* indicator in the list and to tell which right-click menu item is the
@@ -51,8 +55,6 @@ type AgentComboboxProps = {
* field as the last keyboard-submit step. */
onTriggerEnter?: () => void
allowNarrowTrigger?: boolean
allowBlankTerminal?: boolean
emptyLabel?: string
}
const BLANK_VALUE = '__none__'
@@ -150,17 +152,15 @@ function renderItem({
export default function AgentCombobox({
agents,
value,
onValueChange,
onValueSelected,
onOpenManageAgents,
defaultAgent,
onSetDefault,
triggerClassName,
onTriggerEnter,
allowNarrowTrigger = false,
allowBlankTerminal = true,
emptyLabel
...selection
}: AgentComboboxProps): React.JSX.Element {
const allowBlankTerminal = selection.allowBlankTerminal === true
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
// Why: controlled cmdk selection so hovering the footer (which lives outside
@@ -251,15 +251,22 @@ export default function AgentCombobox({
[cancelFocusFrame, value]
)
const handleSelect = useCallback(
(nextValue: TuiAgent | null) => {
onValueChange(nextValue)
setOpen(false)
setQuery('')
onValueSelected?.(nextValue)
},
[onValueChange, onValueSelected]
)
const closePicker = useCallback(() => {
setOpen(false)
setQuery('')
}, [])
const handleSelectAgent = (nextValue: TuiAgent): void => {
selection.onValueChange(nextValue)
closePicker()
}
const handleSelectBlank = (): void => {
if (selection.allowBlankTerminal) {
selection.onValueChange(null)
}
closePicker()
}
// Why: mirror RepoCombobox's trigger-keydown handling — the button-style
// trigger treats the current value as a confirmed selection. Plain focus does
@@ -344,8 +351,15 @@ export default function AgentCombobox({
<AgentIconLabel
icon={<Terminal className="size-3.5" />}
label={
emptyLabel ??
translate('auto.components.agent.AgentCombobox.986f946354', 'Blank Terminal')
allowBlankTerminal
? translate(
'auto.components.agent.AgentCombobox.986f946354',
'Blank Terminal'
)
: translate(
'components.agentSessionContinuation.selectAgent',
'Select an Agent'
)
}
/>
)}
@@ -388,7 +402,7 @@ export default function AgentCombobox({
itemValue: BLANK_VALUE,
isChecked: value === null,
isDefault: defaultAgent === 'blank',
onSelect: () => handleSelect(null),
onSelect: handleSelectBlank,
onSetDefault: onSetDefault ? () => onSetDefault('blank') : undefined,
icon: <Terminal className="size-3.5" />,
label: translate(
@@ -403,7 +417,7 @@ export default function AgentCombobox({
itemValue: agent.id,
isChecked: value === agent.id,
isDefault: defaultAgent === agent.id,
onSelect: () => handleSelect(agent.id),
onSelect: () => handleSelectAgent(agent.id),
onSetDefault: onSetDefault ? () => onSetDefault(agent.id) : undefined,
icon: <AgentIcon agent={agent.id} />,
label: agent.label
@@ -0,0 +1,80 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import type { AutomationDraft } from './AutomationEditorDialog'
import { AutomationEditorSettingsSidebar } from './AutomationEditorSettingsSidebar'
// Why: only the Agent field is under test; the sibling fields own their own coverage.
vi.mock('./AutomationProjectCombobox', () => ({ default: () => null }))
vi.mock('./AutomationWorkspaceField', () => ({ AutomationWorkspaceField: () => null }))
vi.mock('./AutomationSessionField', () => ({ AutomationSessionField: () => null }))
vi.mock('./AutomationSchedulePicker', () => ({ AutomationSchedulePicker: () => null }))
vi.mock('./AutomationMissedRunGraceField', () => ({ AutomationMissedRunGraceField: () => null }))
vi.mock('./AutomationPrecheckFields', () => ({ AutomationPrecheckFields: () => null }))
vi.mock('./AutomationSetupDecisionField', () => ({ AutomationSetupDecisionField: () => null }))
vi.mock('./AutomationDestinationField', () => ({ AutomationDestinationField: () => null }))
vi.mock('./AutomationExtraAgentArgsField', () => ({ AutomationExtraAgentArgsField: () => null }))
const DRAFT: AutomationDraft = {
name: '',
prompt: '',
agentId: 'codex',
extraAgentArgs: '',
projectId: '',
workspaceMode: 'existing',
workspaceId: '',
baseBranch: '',
reuseSession: false,
precheckCommand: '',
precheckTimeoutSeconds: '30',
preset: 'weekly',
time: '09:15',
dayOfWeek: '5',
customSchedule: '',
missedRunGraceMinutes: '720',
savedSchedule: null,
scheduleWarning: null
}
function renderSidebar(onDraftChange: (updater: (d: AutomationDraft) => AutomationDraft) => void) {
return render(
<AutomationEditorSettingsSidebar
isHermesTarget={false}
isHermesCreate={false}
repos={[]}
projectHostSetups={[]}
automationYamlHooksByRepoKey={{}}
getAutomationHooksCacheKey={(repoId) => repoId}
repoMap={new Map()}
worktrees={[]}
settings={null}
draft={DRAFT}
visibleAgents={AGENT_CATALOG}
pickerTriggerClassName=""
segmentedGroupClassName=""
segmentedItemClassName=""
onProjectChange={vi.fn()}
onDraftChange={onDraftChange}
onSetupDecisionTouched={vi.fn()}
/>
)
}
afterEach(cleanup)
describe('AutomationEditorSettingsSidebar agent picker', () => {
it('offers agents only, never Blank Terminal', () => {
const onDraftChange = vi.fn()
renderSidebar(onDraftChange)
fireEvent.click(screen.getByRole('combobox'))
expect(screen.queryByRole('option', { name: 'Blank Terminal' })).toBeNull()
fireEvent.click(screen.getByRole('option', { name: 'Claude' }))
expect(onDraftChange).toHaveBeenCalledTimes(1)
const updater = onDraftChange.mock.calls[0][0]
expect(updater(DRAFT).agentId).toBe('claude')
})
})
@@ -102,9 +102,7 @@ export function AutomationEditorSettingsSidebar({
<AgentCombobox
agents={visibleAgents}
value={draft.agentId}
onValueChange={(agentId) =>
agentId && onDraftChange((current) => ({ ...current, agentId }))
}
onValueChange={(agentId) => onDraftChange((current) => ({ ...current, agentId }))}
defaultAgent={settings?.defaultTuiAgent ?? null}
triggerClassName={`h-9 w-full min-w-0 ${pickerTriggerClassName}`}
allowNarrowTrigger
@@ -0,0 +1,36 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import { NewWorkspaceComposerAgentSection } from './NewWorkspaceComposerAgentSection'
afterEach(cleanup)
describe('NewWorkspaceComposerAgentSection agent picker', () => {
it('offers Blank Terminal because a workspace can start without an agent', () => {
const onQuickAgentChange = vi.fn()
render(
<TooltipProvider>
<NewWorkspaceComposerAgentSection
quickAgent="codex"
onQuickAgentChange={onQuickAgentChange}
onOpenAgentSettings={vi.fn()}
createDisabled={false}
onCreate={vi.fn()}
advancedOpen={false}
onToggleAdvanced={vi.fn()}
visibleQuickAgents={AGENT_CATALOG}
defaultTuiAgent="blank"
handleSetDefaultAgent={vi.fn()}
/>
</TooltipProvider>
)
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(screen.getByRole('option', { name: 'Blank Terminal' }))
expect(onQuickAgentChange).toHaveBeenCalledWith(null)
})
})
@@ -71,6 +71,7 @@ export function NewWorkspaceComposerAgentSection({
agents={visibleQuickAgents}
value={quickAgent}
onValueChange={onQuickAgentChange}
allowBlankTerminal
onOpenManageAgents={onOpenAgentSettings}
defaultAgent={defaultTuiAgent}
onSetDefault={handleSetDefaultAgent}
@@ -6,8 +6,17 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { Repo } from '../../../../shared/repo-types'
vi.mock('@/components/agent/AgentCombobox', () => ({
default: ({ value }: { value: string | null }) =>
React.createElement('div', { 'data-agent-value': value ?? '' })
default: ({
value,
allowBlankTerminal
}: {
value: string | null
allowBlankTerminal?: boolean
}) =>
React.createElement('div', {
'data-agent-value': value ?? '',
'data-allow-blank': String(allowBlankTerminal === true)
})
}))
vi.mock('@/components/ui/dialog', () => ({
@@ -110,6 +119,10 @@ describe('SourceControlAgentActionDialogForm', () => {
expect(markup).toContain('Resolve the merge conflicts reported for this pull request.')
})
it('offers agents only, since an action cannot start without one', () => {
expect(renderForm()).toContain('data-allow-blank="false"')
})
it('checks already-saved copy against the selected save target', () => {
const settings = settingsWithSavedGlobalRecipe()
const saveTargets = [