diff --git a/web/src/components/AgentFlavorIcon.test.tsx b/web/src/components/AgentFlavorIcon.test.tsx index 5dc776cb..76616df3 100644 --- a/web/src/components/AgentFlavorIcon.test.tsx +++ b/web/src/components/AgentFlavorIcon.test.tsx @@ -1,101 +1,71 @@ import { describe, expect, it } from 'vitest' import { render } from '@testing-library/react' +import { AGENT_FLAVORS } from '@hapi/protocol' import { AgentFlavorIcon } from './AgentFlavorIcon' -function getBadge(container: HTMLElement): HTMLElement { - const badge = container.querySelector('span') - if (!badge) throw new Error('AgentFlavorIcon did not render a ') - return badge +// Flavors backed by a @lobehub/icons brand logo. 'pi' has no logo in the +// package and intentionally falls back to the letter badge. +const LOGO_FLAVORS = AGENT_FLAVORS.filter((f) => f !== 'pi') + +function getWrapper(container: HTMLElement): HTMLElement { + const wrapper = container.querySelector('span') + if (!wrapper) throw new Error('AgentFlavorIcon did not render a ') + return wrapper } describe('AgentFlavorIcon', () => { - it('renders the "Pi" label and purple background for the pi flavor', () => { + it.each(LOGO_FLAVORS)('renders an inline SVG brand logo for the %s flavor', (flavor) => { + const { container } = render() + const svg = container.querySelector('svg') + expect(svg, `expected an SVG logo for flavor "${flavor}"`).not.toBeNull() + // Logo variant replaces the old two-letter badge. + expect(getWrapper(container).className).not.toContain('rounded-sm') + }) + + it('normalizes flavor case and whitespace before picking a logo', () => { + for (const flavor of ['CLAUDE', 'Claude', ' claude ', 'Claude ']) { + const { container } = render() + expect(container.querySelector('svg')).not.toBeNull() + } + }) + + it('keeps the "Pi" letter badge for the pi flavor (no brand logo available)', () => { const { container } = render() - const badge = getBadge(container) + const badge = getWrapper(container) + expect(container.querySelector('svg')).toBeNull() expect(badge.textContent).toBe('Pi') - // The Pi badge uses a specific purple; if the literal ever drifts, - // the test should fail and force an intentional design update. expect(badge.className).toContain('bg-[#5b21b6]') expect(badge.className).toContain('text-white') }) - it('matches the exact class contract for all known flavors (regression)', () => { - const cases: Array<{ flavor: string; label: string; bg: string }> = [ - { flavor: 'claude', label: 'Cl', bg: 'bg-[#d97706]' }, - { flavor: 'codex', label: 'Cx', bg: 'bg-[#111827]' }, - { flavor: 'cursor', label: 'Cu', bg: 'bg-[#0f766e]' }, - { flavor: 'gemini', label: 'Gm', bg: 'bg-[#2563eb]' }, - { flavor: 'kimi', label: 'Km', bg: 'bg-[#7c3aed]' }, - { flavor: 'pi', label: 'Pi', bg: 'bg-[#5b21b6]' }, - { flavor: 'opencode', label: 'Op', bg: 'bg-[#15803d]' }, - ] - for (const { flavor, label, bg } of cases) { + it.each([null, undefined, '', ' ', 'mystery-cli'])( + 'renders the "Un" fallback badge for flavor %j', + (flavor) => { const { container } = render() - const badge = getBadge(container) - expect(badge.textContent).toBe(label) - expect(badge.className).toContain(bg) + const badge = getWrapper(container) + expect(container.querySelector('svg')).toBeNull() + expect(badge.textContent).toBe('Un') + expect(badge.className).toContain('bg-[var(--app-secondary-bg)]') } - }) - - it('renders the "Un" badge with secondary-bg colors for null flavor', () => { - const { container } = render() - const badge = getBadge(container) - expect(badge.textContent).toBe('Un') - expect(badge.className).toContain('bg-[var(--app-secondary-bg)]') - }) - - it('renders the "Un" badge for undefined flavor', () => { - const { container } = render() - expect(getBadge(container).textContent).toBe('Un') - }) - - it('renders the "Un" badge for empty string', () => { - const { container } = render() - expect(getBadge(container).textContent).toBe('Un') - }) - - it('renders the "Un" badge for unknown flavor strings', () => { - const { container } = render() - const badge = getBadge(container) - expect(badge.textContent).toBe('Un') - expect(badge.className).toContain('bg-[var(--app-secondary-bg)]') - }) - - it('normalizes flavor case and whitespace', () => { - // The component lowercases + trims internally so 'PI ', 'Pi', ' pi' - // all resolve to the Pi badge. - for (const flavor of ['PI', 'Pi', ' pi ', 'PI ']) { - const { container } = render() - expect(getBadge(container).textContent).toBe('Pi') - } - }) - - it('does NOT match a flavor when only whitespace is present', () => { - // ' '.trim() === '' so the unknown branch is the only valid one. - const { container } = render() - expect(getBadge(container).textContent).toBe('Un') - }) + ) it('applies the default size classes when no className is provided', () => { - const { container } = render() - const badge = getBadge(container) - expect(badge.className).toContain('h-4') - expect(badge.className).toContain('w-4') + const { container } = render() + const wrapper = getWrapper(container) + expect(wrapper.className).toContain('h-4') + expect(wrapper.className).toContain('w-4') }) - it('appends the provided className alongside the badge classes', () => { - const { container } = render() - const badge = getBadge(container) - expect(badge.className).toContain('h-6') - expect(badge.className).toContain('w-6') - // The default size classes must be replaced by the custom className - // (the implementation uses `${className ?? 'h-4 w-4'}`). - expect(badge.className).not.toContain('h-4 w-4') + it('appends the provided className instead of the default size', () => { + const { container } = render() + const wrapper = getWrapper(container) + expect(wrapper.className).toContain('h-6') + expect(wrapper.className).toContain('w-6') + expect(wrapper.className).not.toContain('h-4 w-4') }) - it('marks the badge aria-hidden for screen readers (decorative only)', () => { - const { container } = render() - const badge = getBadge(container) - expect(badge.getAttribute('aria-hidden')).toBe('true') + it('marks the icon aria-hidden for screen readers (decorative only)', () => { + const { container } = render() + expect(getWrapper(container).getAttribute('aria-hidden')).toBe('true') }) }) diff --git a/web/src/components/AgentFlavorIcon.tsx b/web/src/components/AgentFlavorIcon.tsx index 99438fb6..259a4805 100644 --- a/web/src/components/AgentFlavorIcon.tsx +++ b/web/src/components/AgentFlavorIcon.tsx @@ -1,36 +1,36 @@ +// Deep component imports on purpose: the package root re-exports ./features, +// which pulls in uninstalled peer deps (@lobehub/ui, antd). The Mono/Color +// components only depend on react and es-toolkit. +import ClaudeColor from '@lobehub/icons/es/Claude/components/Color' +import CodexColor from '@lobehub/icons/es/Codex/components/Color' +import CursorMono from '@lobehub/icons/es/Cursor/components/Mono' +import GeminiColor from '@lobehub/icons/es/Gemini/components/Color' +import GrokMono from '@lobehub/icons/es/Grok/components/Mono' +import KimiMono from '@lobehub/icons/es/Kimi/components/Mono' +import OpenCodeMono from '@lobehub/icons/es/OpenCode/components/Mono' +import type { IconType } from '@lobehub/icons/es/types' + +// Brand logos per agent flavor. Color variant where it stays visible on both +// light and dark surfaces (claude/codex/gemini); Mono (currentColor) where the +// package ships no Color variant — or where, like KimiColor, the main glyph is +// hard-coded #fff and would vanish on the default light theme. +const FLAVOR_LOGOS: Record = { + claude: ClaudeColor, + codex: CodexColor, + cursor: CursorMono, + gemini: GeminiColor, + grok: GrokMono, + kimi: KimiMono, + opencode: OpenCodeMono, +} + +// Letter-badge fallback for flavors without a brand logo in @lobehub/icons +// (pi) and for anything unrecognized. const FLAVOR_BADGES: Record = { - claude: { - label: 'Cl', - colors: 'bg-[#d97706] text-white', - }, - codex: { - label: 'Cx', - colors: 'bg-[#111827] text-white', - }, - cursor: { - label: 'Cu', - colors: 'bg-[#0f766e] text-white', - }, - gemini: { - label: 'Gm', - colors: 'bg-[#2563eb] text-white', - }, - kimi: { - label: 'Km', - colors: 'bg-[#7c3aed] text-white', - }, - grok: { - label: 'Gr', - colors: 'bg-[#111111] text-white', - }, pi: { label: 'Pi', colors: 'bg-[#5b21b6] text-white', }, - opencode: { - label: 'Op', - colors: 'bg-[#15803d] text-white', - }, } const UNKNOWN_FLAVOR_BADGE = { @@ -40,12 +40,25 @@ const UNKNOWN_FLAVOR_BADGE = { export function AgentFlavorIcon({ flavor, className }: { flavor?: string | null; className?: string }) { const normalized = (flavor ?? '').trim().toLowerCase() - const badge = FLAVOR_BADGES[normalized] ?? UNKNOWN_FLAVOR_BADGE + const sizeClass = className ?? 'h-4 w-4' + const Logo = FLAVOR_LOGOS[normalized] + if (Logo) { + return ( + + ) + } + + const badge = FLAVOR_BADGES[normalized] ?? UNKNOWN_FLAVOR_BADGE return ( diff --git a/web/src/components/NewSession/AgentSelector.tsx b/web/src/components/NewSession/AgentSelector.tsx index 95d693a4..3c283953 100644 --- a/web/src/components/NewSession/AgentSelector.tsx +++ b/web/src/components/NewSession/AgentSelector.tsx @@ -1,5 +1,6 @@ import { CREATABLE_AGENT_FLAVORS, getFlavorLabel } from '@hapi/protocol' import type { AgentType } from './types' +import { AgentFlavorIcon } from '@/components/AgentFlavorIcon' import { useTranslation } from '@/lib/use-translation' export function AgentSelector(props: { @@ -29,6 +30,7 @@ export function AgentSelector(props: { disabled={props.isDisabled} className="accent-[var(--app-link)]" /> + {getFlavorLabel(agentType)} ))}