mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user