fix: make AI target selection exclusive
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user