feat(web): replace agent flavor letter badges with brand logos

Use @lobehub/icons (already a dependency) for per-agent SVG logos in the
session list, session header, and new-session agent selector.

- Color variants for claude/codex/gemini; Mono (currentColor) for
  cursor/grok/opencode so glyphs track the theme text color
- kimi uses Mono as well: KimiColor's main glyph is hard-coded #fff and
  vanishes on the light theme
- pi and unknown flavors keep the letter-badge fallback (no logo shipped)
- Deep component imports avoid the package root's ./features re-export,
  which pulls uninstalled peer deps (antd, @lobehub/ui)
This commit is contained in:
weishu
2026-07-19 13:05:25 +08:00
parent 289c9f2218
commit 651c83a1d9
3 changed files with 93 additions and 108 deletions
+46 -76
View File
@@ -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 <span>')
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 <span>')
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(<AgentFlavorIcon flavor={flavor} />)
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(<AgentFlavorIcon flavor={flavor} />)
expect(container.querySelector('svg')).not.toBeNull()
}
})
it('keeps the "Pi" letter badge for the pi flavor (no brand logo available)', () => {
const { container } = render(<AgentFlavorIcon flavor="pi" />)
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(<AgentFlavorIcon flavor={flavor} />)
const badge = getBadge(container)
expect(badge.textContent).toBe(label)
expect(badge.className).toContain(bg)
}
})
it('renders the "Un" badge with secondary-bg colors for null flavor', () => {
const { container } = render(<AgentFlavorIcon flavor={null} />)
const badge = getBadge(container)
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 for undefined flavor', () => {
const { container } = render(<AgentFlavorIcon flavor={undefined} />)
expect(getBadge(container).textContent).toBe('Un')
})
it('renders the "Un" badge for empty string', () => {
const { container } = render(<AgentFlavorIcon flavor="" />)
expect(getBadge(container).textContent).toBe('Un')
})
it('renders the "Un" badge for unknown flavor strings', () => {
const { container } = render(<AgentFlavorIcon flavor="mystery-cli" />)
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(<AgentFlavorIcon flavor={flavor} />)
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(<AgentFlavorIcon flavor=" " />)
expect(getBadge(container).textContent).toBe('Un')
})
)
it('applies the default size classes when no className is provided', () => {
const { container } = render(<AgentFlavorIcon flavor="pi" />)
const badge = getBadge(container)
expect(badge.className).toContain('h-4')
expect(badge.className).toContain('w-4')
const { container } = render(<AgentFlavorIcon flavor="claude" />)
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(<AgentFlavorIcon flavor="pi" className="h-6 w-6" />)
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(<AgentFlavorIcon flavor="claude" className="h-6 w-6" />)
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(<AgentFlavorIcon flavor="pi" />)
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(<AgentFlavorIcon flavor="claude" />)
expect(getWrapper(container).getAttribute('aria-hidden')).toBe('true')
})
})
+43 -30
View File
@@ -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<string, IconType> = {
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<string, { label: string; colors: string }> = {
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 (
<span
aria-hidden="true"
className={`inline-flex items-center justify-center rounded-sm text-[8px] font-semibold leading-none ${badge.colors} ${className ?? 'h-4 w-4'}`}
className={`inline-flex items-center justify-center leading-none ${sizeClass}`}
>
<Logo size="100%" />
</span>
)
}
const badge = FLAVOR_BADGES[normalized] ?? UNKNOWN_FLAVOR_BADGE
return (
<span
aria-hidden="true"
className={`inline-flex items-center justify-center rounded-sm text-[8px] font-semibold leading-none ${badge.colors} ${sizeClass}`}
>
{badge.label}
</span>
@@ -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)]"
/>
<AgentFlavorIcon flavor={agentType} className="h-4 w-4 shrink-0" />
<span className="text-sm">{getFlavorLabel(agentType)}</span>
</label>
))}