import { useEffect, useRef, useState } from 'react' import type { SaveStatus } from '../../hooks/useAutoSave' import { useCompanion, type Mood } from './useCompanion' import { LottiePlayer } from './LottiePlayer' import { COMPANIONS, DEFAULT_COMPANION } from './companions' interface Props { wordCount: number saveStatus: SaveStatus llmDown: boolean editTick: number acceptTick: number text: string } // Emoji placeholder per mood, used for any mood a companion has no Lottie for. const MOOD_EMOJI: Record = { idle: '🐾', happy: '😺', talking: '😸', celebrate: '😻', sleeping: '🐾', } const STORAGE_KEY = 'petal.companion' // PetalCompanion is the cozy corner mascot: it watches the writing session via // useCompanion and shows a Mandarin-first speech bubble for cheers, tips, and // break reminders. Clicking the mascot opens a picker to switch companions // (the choice persists in localStorage). export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, acceptTick, text }: Props) { const { mood, bubble, dismiss, holdBubble, releaseBubble } = useCompanion({ wordCount, saveStatus, llmDown, editTick, acceptTick, text, }) const [companionId, setCompanionId] = useState(() => { try { return localStorage.getItem(STORAGE_KEY) || DEFAULT_COMPANION } catch { return DEFAULT_COMPANION } }) const companion = COMPANIONS.find((c) => c.id === companionId) ?? COMPANIONS[0] const [pickerOpen, setPickerOpen] = useState(false) const rootRef = useRef(null) // Awake companions (no sleeping clip) don't visibly nap β€” when the engine // dozes them, keep their normal idle pose instead of a sleepy face. Only a // mascot with a real sleep animation (the always-asleep cat) shows the zzz. const hasSleepClip = Boolean(companion.animations.sleeping) // An always-asleep mascot (the cat) stays pinned to one pose so its Lottie // never reloads as the engine flips moods underneath it. const renderMood: Mood = companion.alwaysAsleep ? 'idle' : mood === 'sleeping' && !hasSleepClip ? 'idle' : mood const napping = companion.alwaysAsleep || (mood === 'sleeping' && hasSleepClip) // Never swap the cute companion for a bare emoji on an idle nap: fall back to // the idle animation when a mood has no clip of its own, and only reach for // the emoji if the companion ships no animations at all. const animationData = companion.animations[renderMood] ?? companion.animations.idle function choose(id: string) { setCompanionId(id) try { localStorage.setItem(STORAGE_KEY, id) } catch { /* private mode / storage disabled β€” selection just won't persist */ } setPickerOpen(false) } // Click outside the corner closes the picker. useEffect(() => { if (!pickerOpen) return const onDown = (e: MouseEvent) => { if (!rootRef.current?.contains(e.target as Node)) setPickerOpen(false) } document.addEventListener('mousedown', onDown) return () => document.removeEventListener('mousedown', onDown) }, [pickerOpen]) return (
{pickerOpen && (

选δΈͺ小伙伴 Β· Choose a companion

{COMPANIONS.map((c) => { const active = c.id === companion.id return ( ) })}
)} {bubble && !pickerOpen && (

{bubble.zh}

{bubble.en}

)}
) }