mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 10:37:35 +00:00
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:
+13
-2
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
-5
@@ -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}
|
||||
|
||||
+15
-2
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user