diff --git a/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.test.tsx b/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.test.tsx index ac551fbc111..7a4265d3813 100644 --- a/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.test.tsx +++ b/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.test.tsx @@ -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() }) }) diff --git a/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx b/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx index 96d802da2cd..9183212d814 100644 --- a/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx +++ b/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.tsx @@ -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 ? ( diff --git a/src/renderer/src/components/agent/AgentCombobox.test.tsx b/src/renderer/src/components/agent/AgentCombobox.test.tsx index 2ba0a0e2144..d9dd02c751f 100644 --- a/src/renderer/src/components/agent/AgentCombobox.test.tsx +++ b/src/renderer/src/components/agent/AgentCombobox.test.tsx @@ -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( - + ) 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( - + ) 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() + + 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( + + ) + + 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() diff --git a/src/renderer/src/components/agent/AgentCombobox.tsx b/src/renderer/src/components/agent/AgentCombobox.tsx index 1c2ef729db9..4d10363d721 100644 --- a/src/renderer/src/components/agent/AgentCombobox.tsx +++ b/src/renderer/src/components/agent/AgentCombobox.tsx @@ -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({ } 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: , 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: , label: agent.label diff --git a/src/renderer/src/components/automations/AutomationEditorSettingsSidebar.test.tsx b/src/renderer/src/components/automations/AutomationEditorSettingsSidebar.test.tsx new file mode 100644 index 00000000000..dd0d1d1650e --- /dev/null +++ b/src/renderer/src/components/automations/AutomationEditorSettingsSidebar.test.tsx @@ -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( + 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') + }) +}) diff --git a/src/renderer/src/components/automations/AutomationEditorSettingsSidebar.tsx b/src/renderer/src/components/automations/AutomationEditorSettingsSidebar.tsx index ff73ddd116f..e468f5ba4dd 100644 --- a/src/renderer/src/components/automations/AutomationEditorSettingsSidebar.tsx +++ b/src/renderer/src/components/automations/AutomationEditorSettingsSidebar.tsx @@ -102,9 +102,7 @@ export function AutomationEditorSettingsSidebar({ - 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 diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.test.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.test.tsx new file mode 100644 index 00000000000..eff02a5f233 --- /dev/null +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.test.tsx @@ -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( + + + + ) + + fireEvent.click(screen.getByRole('combobox')) + fireEvent.click(screen.getByRole('option', { name: 'Blank Terminal' })) + + expect(onQuickAgentChange).toHaveBeenCalledWith(null) + }) +}) diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.tsx index 4d597d3d436..5f790a5748e 100644 --- a/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.tsx +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerAgentSection.tsx @@ -71,6 +71,7 @@ export function NewWorkspaceComposerAgentSection({ agents={visibleQuickAgents} value={quickAgent} onValueChange={onQuickAgentChange} + allowBlankTerminal onOpenManageAgents={onOpenAgentSettings} defaultAgent={defaultTuiAgent} onSetDefault={handleSetDefaultAgent} diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx index 5d6e784b00c..952ee92494c 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx @@ -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 = [