import { useStore } from '@nanostores/react' import { useEffect, useMemo, useState } from 'react' import { useI18n } from '@/i18n' import { AlertCircle, Clock, type IconComponent } from '@/lib/icons' import { $petActivity, $petState, type PetState } from '@/store/pet' /** * Speech bubble + status glyph for the popped-out pet overlay — the * "notification" half of the mascot. It externalizes what the agent is doing * (Codex-style) so a glance at the desktop pet replaces switching back to the * window. The in-window pet doesn't show it (the app itself is the surface); * only the overlay renders it. * * Text is derived purely from the same `$petState` / `$petActivity` the sprite * already reacts to, so it never drifts from the animation. The bubble is shown * only when there's something worth saying (working / reviewing / a transient * done/error beat / waiting on the user) and is hidden at plain idle. */ type Tone = 'error' | 'wait' interface Spec { lines: string[] glyph?: IconComponent tone?: Tone } // Phrasings per mood, picked at random (no immediate repeat) for a bit of life. // Keep them short — the bubble is tiny and never wraps. const TONE_COLOR: Record = { error: 'var(--ui-red)', wait: 'var(--ui-yellow)' } // Random pick that avoids repeating the line we're already showing. function pick(lines: string[], prev: string): string { if (lines.length <= 1) { return lines[0] ?? '' } let next = prev while (next === prev) { next = lines[Math.floor(Math.random() * lines.length)] } return next } export function PetBubble() { const { t } = useI18n() const specs = useMemo>>( () => ({ run: { lines: t.surfaces.pet.run }, review: { lines: t.surfaces.pet.review }, failed: { glyph: AlertCircle, lines: t.surfaces.pet.failed, tone: 'error' }, waiting: { glyph: Clock, lines: t.surfaces.pet.waiting, tone: 'wait' } }), [t] ) const state = useStore($petState) const activity = useStore($petActivity) const [line, setLine] = useState('') // Finish beats are carried by the sprite/mail icon; idle only speaks up when // it's actually the user's turn. Everything else maps to a mood spec. const specKey: null | PetState = state in specs ? state : state === 'idle' && activity.awaitingInput ? 'waiting' : null const rotating = specKey === 'run' || specKey === 'review' // Pick a fresh line on every mood change, then keep rotating (random, no // repeat) only while the agent is actively working/thinking. useEffect(() => { const spec = specKey ? specs[specKey] : null if (!spec) { setLine('') return } setLine(prev => pick(spec.lines, prev)) if (!rotating || spec.lines.length <= 1) { return } const id = window.setInterval(() => setLine(prev => pick(spec.lines, prev)), 2600) return () => window.clearInterval(id) }, [specKey, rotating, specs]) const spec = specKey ? specs[specKey] : null if (!spec) { return null } const Glyph = spec.glyph const text = line || spec.lines[0] const hasText = Boolean(text) return (
{Glyph && ( )} {text}
) }