fix: make AI target selection exclusive

This commit is contained in:
lucaronin
2026-05-04 12:39:34 +02:00
parent 69b9da6da6
commit e7e33c479d
2 changed files with 105 additions and 64 deletions

View File

@@ -1,8 +1,9 @@
import { act, fireEvent, render as rtlRender, screen } from '@testing-library/react'
import type { ReactElement } from 'react'
import type { ComponentProps, ReactElement } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { AiAgentsBadge } from './AiAgentsBadge'
import type { AiModelProvider } from '../../lib/aiTargets'
vi.mock('../../utils/url', async () => {
const actual = await vi.importActual('../../utils/url')
@@ -17,62 +18,106 @@ const installedStatuses = {
gemini: { status: 'installed' as const, version: '0.5.1' },
}
const openAiProvider: AiModelProvider = {
id: 'openai',
name: 'OpenAI',
kind: 'open_ai',
base_url: null,
api_key_storage: 'local_file',
api_key_env_var: 'OPENAI_API_KEY',
headers: null,
models: [{
id: 'gpt-5.5',
display_name: null,
context_window: null,
max_output_tokens: null,
capabilities: {
streaming: true,
tools: false,
vision: true,
json_mode: true,
reasoning: true,
},
}],
}
function render(ui: ReactElement) {
return rtlRender(ui, { wrapper: TooltipProvider })
}
type AiAgentsBadgeTestProps = ComponentProps<typeof AiAgentsBadge>
function renderBadge(props: Partial<AiAgentsBadgeTestProps> = {}) {
return render(
<AiAgentsBadge
statuses={installedStatuses}
defaultAgent="claude_code"
onSetDefaultAgent={vi.fn()}
{...props}
/>,
)
}
function focusAiAgentsTrigger() {
const trigger = screen.getByTestId('status-ai-agents')
act(() => {
trigger.focus()
})
return trigger
}
function openAiAgentsMenu() {
const trigger = screen.getByTestId('status-ai-agents')
act(() => {
trigger.focus()
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
})
}
describe('AiAgentsBadge', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('keeps the dropdown trigger off the Radix tooltip popper path', () => {
render(
<AiAgentsBadge
statuses={installedStatuses}
defaultAgent="claude_code"
onSetDefaultAgent={vi.fn()}
/>,
)
renderBadge()
const trigger = screen.getByTestId('status-ai-agents')
const trigger = focusAiAgentsTrigger()
expect(trigger).toHaveAttribute('data-tooltip-mode', 'native-title')
expect(trigger.getAttribute('title')).toContain('Claude Code')
act(() => {
trigger.focus()
})
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
act(() => {
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
})
openAiAgentsMenu()
expect(screen.getByTestId('status-ai-agents-menu')).toBeInTheDocument()
})
it('selects only the active model target when an API model is the default target', () => {
renderBadge({
defaultTarget: 'model:openai/gpt-5.5',
providers: [openAiProvider],
onSetDefaultTarget: vi.fn(),
})
openAiAgentsMenu()
expect(screen.getByText(/Default AI target: OpenAI.*gpt-5\.5/)).toBeInTheDocument()
expect(screen.getByRole('menuitemradio', { name: /Claude Code/ })).toHaveAttribute('aria-checked', 'false')
expect(screen.getByRole('menuitemradio', { name: /OpenAI.*gpt-5\.5/ })).toHaveAttribute('aria-checked', 'true')
})
it('shows the vault guidance summary and restore action', async () => {
const onRestoreGuidance = vi.fn()
render(
<AiAgentsBadge
statuses={installedStatuses}
guidanceStatus={{
agentsState: 'missing',
claudeState: 'managed',
geminiState: 'managed',
canRestore: true,
}}
defaultAgent="claude_code"
onSetDefaultAgent={vi.fn()}
onRestoreGuidance={onRestoreGuidance}
/>,
)
act(() => {
const trigger = screen.getByTestId('status-ai-agents')
trigger.focus()
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
renderBadge({
guidanceStatus: {
agentsState: 'missing',
claudeState: 'managed',
geminiState: 'managed',
canRestore: true,
},
onRestoreGuidance,
})
openAiAgentsMenu()
expect(screen.getByTestId('status-ai-guidance-summary')).toHaveTextContent('Tolaria guidance missing or broken')
act(() => {
@@ -84,26 +129,16 @@ describe('AiAgentsBadge', () => {
it('supports opening the menu and restoring guidance from the keyboard', () => {
const onRestoreGuidance = vi.fn()
render(
<AiAgentsBadge
statuses={installedStatuses}
guidanceStatus={{
agentsState: 'managed',
claudeState: 'broken',
geminiState: 'managed',
canRestore: true,
}}
defaultAgent="claude_code"
onSetDefaultAgent={vi.fn()}
onRestoreGuidance={onRestoreGuidance}
/>,
)
act(() => {
const trigger = screen.getByTestId('status-ai-agents')
trigger.focus()
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
renderBadge({
guidanceStatus: {
agentsState: 'managed',
claudeState: 'broken',
geminiState: 'managed',
canRestore: true,
},
onRestoreGuidance,
})
openAiAgentsMenu()
const restoreItem = screen.getByTestId('status-ai-guidance-restore')
act(() => {

View File

@@ -14,6 +14,7 @@ import {
import {
configuredModelTargets,
resolveAiTarget,
type AiTarget,
type AiModelProvider,
} from '../../lib/aiTargets'
import type { Settings } from '../../types'
@@ -89,8 +90,12 @@ function triggerLabel(defaultAgent: AiAgentId): string {
return getAiAgentDefinition(defaultAgent).shortLabel
}
function menuHeading(locale: AppLocale, defaultAgent: AiAgentId, selectedAgentReady: boolean): string {
const agent = getAiAgentDefinition(defaultAgent).label
function menuHeading(locale: AppLocale, selectedTarget: AiTarget, selectedAgentReady: boolean): string {
if (selectedTarget.kind === 'api_model') {
return translate(locale, 'status.ai.defaultTarget', { target: selectedTarget.label })
}
const agent = selectedTarget.label
return selectedAgentReady
? translate(locale, 'status.ai.active', { agent })
: translate(locale, 'status.ai.unavailable', { agent })
@@ -174,18 +179,18 @@ function GuidanceMenuSection({
function AgentMenuContent({
statuses,
guidanceStatus,
defaultAgent,
defaultTarget,
providers = [],
selectedTarget,
selectedAgentReady,
onSetDefaultAgent,
onSetDefaultTarget,
onRestoreGuidance,
locale = 'en',
}: AiAgentsBadgeProps & { selectedAgentReady: boolean }) {
}: AiAgentsBadgeProps & { selectedTarget: AiTarget; selectedAgentReady: boolean }) {
const installedAgents = installedAgentDefinitions(statuses)
const missingAgents = missingAgentDefinitions(statuses)
const modelTargets = configuredModelTargets(providers)
const selectedAgentValue = selectedTarget.kind === 'agent' && selectedAgentReady ? selectedTarget.agent : undefined
return (
<DropdownMenuContent
@@ -194,12 +199,12 @@ function AgentMenuContent({
className="min-w-[18rem]"
data-testid="status-ai-agents-menu"
>
<DropdownMenuLabel>{menuHeading(locale, defaultAgent, selectedAgentReady)}</DropdownMenuLabel>
<DropdownMenuLabel>{menuHeading(locale, selectedTarget, selectedAgentReady)}</DropdownMenuLabel>
{installedAgents.length === 0 ? (
<DropdownMenuItem disabled>{translate(locale, 'status.ai.noAgents')}</DropdownMenuItem>
) : (
<DropdownMenuRadioGroup
value={selectedAgentReady ? defaultAgent : undefined}
value={selectedAgentValue}
onValueChange={(value) => {
onSetDefaultAgent?.(value as AiAgentId)
onSetDefaultTarget?.(`agent:${value}`)
@@ -217,7 +222,7 @@ function AgentMenuContent({
)}
<ModelTargetMenuSection
targets={modelTargets}
defaultTarget={defaultTarget}
selectedTarget={selectedTarget}
locale={locale}
onSetDefaultTarget={onSetDefaultTarget}
/>
@@ -246,12 +251,12 @@ function AgentMenuContent({
function ModelTargetMenuSection({
targets,
defaultTarget,
selectedTarget,
locale,
onSetDefaultTarget,
}: {
targets: ReturnType<typeof configuredModelTargets>
defaultTarget?: string
selectedTarget: AiTarget
locale: AppLocale
onSetDefaultTarget?: (target: string) => void
}) {
@@ -262,7 +267,7 @@ function ModelTargetMenuSection({
<DropdownMenuSeparator />
<DropdownMenuLabel>{translate(locale, 'status.ai.modelTargets')}</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={defaultTarget}
value={selectedTarget.kind === 'api_model' ? selectedTarget.id : undefined}
onValueChange={(value) => onSetDefaultTarget?.(value)}
>
{targets.map((target) => (
@@ -337,6 +342,7 @@ export function AiAgentsBadge({
onSetDefaultAgent={onSetDefaultAgent}
onSetDefaultTarget={onSetDefaultTarget}
onRestoreGuidance={onRestoreGuidance}
selectedTarget={selectedTarget}
selectedAgentReady={selectedAgentReady}
locale={locale}
/>