From e7e33c479d5b5d5deeed4e07d0037d0e1d927d2c Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 4 May 2026 12:39:34 +0200 Subject: [PATCH] fix: make AI target selection exclusive --- .../status-bar/AiAgentsBadge.test.tsx | 141 +++++++++++------- src/components/status-bar/AiAgentsBadge.tsx | 28 ++-- 2 files changed, 105 insertions(+), 64 deletions(-) diff --git a/src/components/status-bar/AiAgentsBadge.test.tsx b/src/components/status-bar/AiAgentsBadge.test.tsx index da96805e..0e391b8d 100644 --- a/src/components/status-bar/AiAgentsBadge.test.tsx +++ b/src/components/status-bar/AiAgentsBadge.test.tsx @@ -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 + +function renderBadge(props: Partial = {}) { + return render( + , + ) +} + +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( - , - ) + 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( - , - ) - - 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( - , - ) - - 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(() => { diff --git a/src/components/status-bar/AiAgentsBadge.tsx b/src/components/status-bar/AiAgentsBadge.tsx index 8c6ed179..73d45442 100644 --- a/src/components/status-bar/AiAgentsBadge.tsx +++ b/src/components/status-bar/AiAgentsBadge.tsx @@ -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 ( - {menuHeading(locale, defaultAgent, selectedAgentReady)} + {menuHeading(locale, selectedTarget, selectedAgentReady)} {installedAgents.length === 0 ? ( {translate(locale, 'status.ai.noAgents')} ) : ( { onSetDefaultAgent?.(value as AiAgentId) onSetDefaultTarget?.(`agent:${value}`) @@ -217,7 +222,7 @@ function AgentMenuContent({ )} @@ -246,12 +251,12 @@ function AgentMenuContent({ function ModelTargetMenuSection({ targets, - defaultTarget, + selectedTarget, locale, onSetDefaultTarget, }: { targets: ReturnType - defaultTarget?: string + selectedTarget: AiTarget locale: AppLocale onSetDefaultTarget?: (target: string) => void }) { @@ -262,7 +267,7 @@ function ModelTargetMenuSection({ {translate(locale, 'status.ai.modelTargets')} onSetDefaultTarget?.(value)} > {targets.map((target) => ( @@ -337,6 +342,7 @@ export function AiAgentsBadge({ onSetDefaultAgent={onSetDefaultAgent} onSetDefaultTarget={onSetDefaultTarget} onRestoreGuidance={onRestoreGuidance} + selectedTarget={selectedTarget} selectedAgentReady={selectedAgentReady} locale={locale} />