diff --git a/desktop/src/assets/agent-mascots/agent-mascot-build.png b/desktop/src/assets/agent-mascots/agent-mascot-build.png new file mode 100644 index 00000000..de1b2a83 Binary files /dev/null and b/desktop/src/assets/agent-mascots/agent-mascot-build.png differ diff --git a/desktop/src/assets/agent-mascots/agent-mascot-check.png b/desktop/src/assets/agent-mascots/agent-mascot-check.png new file mode 100644 index 00000000..e5e7b0ef Binary files /dev/null and b/desktop/src/assets/agent-mascots/agent-mascot-check.png differ diff --git a/desktop/src/assets/agent-mascots/agent-mascot-code.png b/desktop/src/assets/agent-mascots/agent-mascot-code.png new file mode 100644 index 00000000..d4c21f11 Binary files /dev/null and b/desktop/src/assets/agent-mascots/agent-mascot-code.png differ diff --git a/desktop/src/assets/agent-mascots/agent-mascot-data.png b/desktop/src/assets/agent-mascots/agent-mascot-data.png new file mode 100644 index 00000000..3a24d905 Binary files /dev/null and b/desktop/src/assets/agent-mascots/agent-mascot-data.png differ diff --git a/desktop/src/assets/agent-mascots/agent-mascot-design.png b/desktop/src/assets/agent-mascots/agent-mascot-design.png new file mode 100644 index 00000000..f1506871 Binary files /dev/null and b/desktop/src/assets/agent-mascots/agent-mascot-design.png differ diff --git a/desktop/src/assets/agent-mascots/agent-mascot-docs.png b/desktop/src/assets/agent-mascots/agent-mascot-docs.png new file mode 100644 index 00000000..f0f07c38 Binary files /dev/null and b/desktop/src/assets/agent-mascots/agent-mascot-docs.png differ diff --git a/desktop/src/assets/agent-mascots/agent-mascot-fix.png b/desktop/src/assets/agent-mascots/agent-mascot-fix.png new file mode 100644 index 00000000..e835150d Binary files /dev/null and b/desktop/src/assets/agent-mascots/agent-mascot-fix.png differ diff --git a/desktop/src/assets/agent-mascots/agent-mascot-plan.png b/desktop/src/assets/agent-mascots/agent-mascot-plan.png new file mode 100644 index 00000000..4f060b07 Binary files /dev/null and b/desktop/src/assets/agent-mascots/agent-mascot-plan.png differ diff --git a/desktop/src/assets/agent-mascots/agent-mascot-release.png b/desktop/src/assets/agent-mascots/agent-mascot-release.png new file mode 100644 index 00000000..61a2b513 Binary files /dev/null and b/desktop/src/assets/agent-mascots/agent-mascot-release.png differ diff --git a/desktop/src/assets/agent-mascots/agent-mascot-search.png b/desktop/src/assets/agent-mascots/agent-mascot-search.png new file mode 100644 index 00000000..97c840cb Binary files /dev/null and b/desktop/src/assets/agent-mascots/agent-mascot-search.png differ diff --git a/desktop/src/assets/agent-mascots/agent-mascot-shield.png b/desktop/src/assets/agent-mascots/agent-mascot-shield.png new file mode 100644 index 00000000..86d00808 Binary files /dev/null and b/desktop/src/assets/agent-mascots/agent-mascot-shield.png differ diff --git a/desktop/src/assets/agent-mascots/agent-mascot-terminal.png b/desktop/src/assets/agent-mascots/agent-mascot-terminal.png new file mode 100644 index 00000000..5b1ef988 Binary files /dev/null and b/desktop/src/assets/agent-mascots/agent-mascot-terminal.png differ diff --git a/desktop/src/components/activity/AgentMascot.test.tsx b/desktop/src/components/activity/AgentMascot.test.tsx new file mode 100644 index 00000000..c198be0f --- /dev/null +++ b/desktop/src/components/activity/AgentMascot.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import { describe, expect, it } from 'vitest' +import { + AGENT_MASCOT_VARIANTS, + AgentMascot, + resolveAgentMascotSpec, +} from './AgentMascot' + +describe('AgentMascot', () => { + it('resolves the same seed to a stable mascot variant across statuses', () => { + const running = resolveAgentMascotSpec({ seed: 'session-1:tool-1', status: 'running' }) + const completed = resolveAgentMascotSpec({ seed: 'session-1:tool-1', status: 'completed' }) + + expect(running.variant).toBe(completed.variant) + expect(running.motion).toBe('active') + expect(completed.motion).toBe('still') + }) + + it.each([ + ['running', 'active', 'accent'], + ['completed', 'still', 'success'], + ['failed', 'still', 'danger'], + ['stopped', 'still', 'danger'], + ['pending', 'still', 'muted'], + ] as const)('renders %s with semantic state metadata', (status, motion, tone) => { + render() + + const mascot = screen.getByTestId('agent-mascot') + + expect(mascot).toHaveAttribute('data-agent-mascot-state', status) + expect(mascot).toHaveAttribute('data-agent-mascot-motion', motion) + expect(mascot).toHaveAttribute('data-agent-mascot-tone', tone) + expect(mascot).toHaveAttribute('aria-hidden', 'true') + expect(mascot).toHaveClass('h-[30px]') + expect(mascot).toHaveClass('w-[30px]') + expect(mascot.querySelector('img')).toHaveAttribute('alt', '') + expect(mascot.querySelector('img')).toHaveAttribute('draggable', 'false') + }) + + it('only renders the motion ring for active agents', () => { + const { rerender } = render() + + expect(screen.getByTestId('agent-mascot-motion-ring')).toBeInTheDocument() + + rerender() + + expect(screen.queryByTestId('agent-mascot-motion-ring')).not.toBeInTheDocument() + }) + + it('maps every mascot variant to a local generated image asset', () => { + const { rerender } = render() + + for (const variant of AGENT_MASCOT_VARIANTS) { + let seed = '' + for (let index = 0; index < 10000; index += 1) { + const candidate = `variant-${variant}-${index}` + if (resolveAgentMascotSpec({ seed: candidate, status: 'running' }).variant === variant) { + seed = candidate + break + } + } + expect(seed).not.toBe('') + + rerender() + + const mascot = screen.getByTestId('agent-mascot') + expect(mascot).toHaveAttribute('data-agent-mascot-variant', variant) + expect(mascot.getAttribute('data-agent-mascot-src')).toContain(`agent-mascot-${variant}`) + expect(mascot.querySelector('img')?.getAttribute('src')).toContain(`agent-mascot-${variant}`) + } + }) +}) diff --git a/desktop/src/components/activity/AgentMascot.tsx b/desktop/src/components/activity/AgentMascot.tsx new file mode 100644 index 00000000..e5acc676 --- /dev/null +++ b/desktop/src/components/activity/AgentMascot.tsx @@ -0,0 +1,123 @@ +import type { ActivityStatus } from './sessionActivityModel' +import mascotBuild from '../../assets/agent-mascots/agent-mascot-build.png' +import mascotCheck from '../../assets/agent-mascots/agent-mascot-check.png' +import mascotCode from '../../assets/agent-mascots/agent-mascot-code.png' +import mascotData from '../../assets/agent-mascots/agent-mascot-data.png' +import mascotDesign from '../../assets/agent-mascots/agent-mascot-design.png' +import mascotDocs from '../../assets/agent-mascots/agent-mascot-docs.png' +import mascotFix from '../../assets/agent-mascots/agent-mascot-fix.png' +import mascotPlan from '../../assets/agent-mascots/agent-mascot-plan.png' +import mascotRelease from '../../assets/agent-mascots/agent-mascot-release.png' +import mascotSearch from '../../assets/agent-mascots/agent-mascot-search.png' +import mascotShield from '../../assets/agent-mascots/agent-mascot-shield.png' +import mascotTerminal from '../../assets/agent-mascots/agent-mascot-terminal.png' + +export const AGENT_MASCOT_VARIANTS = [ + 'code', + 'check', + 'plan', + 'shield', + 'docs', + 'terminal', + 'search', + 'release', + 'build', + 'fix', + 'design', + 'data', +] as const + +export type AgentMascotVariant = typeof AGENT_MASCOT_VARIANTS[number] + +export type AgentMascotSpec = { + seed: string + variant: AgentMascotVariant + state: ActivityStatus + motion: 'active' | 'still' + tone: 'accent' | 'success' | 'danger' | 'muted' +} + +const MASCOT_IMAGE_BY_VARIANT: Record = { + build: mascotBuild, + check: mascotCheck, + code: mascotCode, + data: mascotData, + design: mascotDesign, + docs: mascotDocs, + fix: mascotFix, + plan: mascotPlan, + release: mascotRelease, + search: mascotSearch, + shield: mascotShield, + terminal: mascotTerminal, +} + +export function stableAgentMascotHash(value: string): number { + let hash = 0 + for (let index = 0; index < value.length; index += 1) { + hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0 + } + return Math.abs(hash) +} + +export function resolveAgentMascotSpec({ + seed, + status, +}: { + seed: string + status: ActivityStatus +}): AgentMascotSpec { + const normalizedSeed = seed.trim() || 'agent' + const isRunning = status === 'running' || status === 'in_progress' + const isComplete = status === 'completed' || status === 'idle' + const isFailed = status === 'failed' || status === 'error' || status === 'stopped' + + return { + seed: normalizedSeed, + variant: AGENT_MASCOT_VARIANTS[stableAgentMascotHash(normalizedSeed) % AGENT_MASCOT_VARIANTS.length] ?? 'code', + state: status, + motion: isRunning ? 'active' : 'still', + tone: isFailed ? 'danger' : isComplete ? 'success' : isRunning ? 'accent' : 'muted', + } +} + +function ringClassName(tone: AgentMascotSpec['tone']): string { + if (tone === 'danger') return 'border-[color-mix(in_srgb,var(--color-error)_42%,transparent)]' + if (tone === 'success') return 'border-[color-mix(in_srgb,var(--color-success)_34%,transparent)]' + if (tone === 'muted') return 'border-[color-mix(in_srgb,var(--color-text-tertiary)_24%,transparent)]' + return 'border-[color-mix(in_srgb,var(--color-accent)_42%,transparent)]' +} + +export function AgentMascot({ seed, status }: { seed: string; status: ActivityStatus }) { + const spec = resolveAgentMascotSpec({ seed, status }) + const isActive = spec.motion === 'active' + const imageSrc = MASCOT_IMAGE_BY_VARIANT[spec.variant] + + return ( + ) } @@ -184,7 +185,7 @@ function getRowIcon(row: ActivityRow) { case 'backgroundTasks': return Terminal case 'subagents': - return Bot + return Users case 'sources': case 'output': return FileText @@ -206,6 +207,44 @@ function getStatusTone(status: ActivityRow['status']) { return 'bg-[var(--color-text-tertiary)]' } +function ActivityRowIcon({ row, sessionId }: { row: ActivityRow; sessionId: string }) { + if (row.section === 'subagents') { + return + } + + const Icon = getRowIcon(row) + + return ( + + + ) +} + +function ActivityStatusIndicator({ + status, + label, + animated = true, +}: { + status: ActivityRow['status'] + label: string + animated?: boolean +}) { + const isRunning = animated && (status === 'running' || status === 'in_progress') + + return ( + +