import { useCallback, useEffect, useRef, useState } from 'react' import type { SaveStatus } from '../../hooks/useAutoSave' import { BREAKS, ENCOURAGEMENTS, GREETING, MILESTONES, TIPS, WELCOME_BACK, milestoneLine, pick, type Line, } from './tips' import { analyzeProse } from './prose' // The kitten's expression. Maps to a Lottie animation when assets are present, // otherwise to an emoji placeholder (see PetalCompanion). export type Mood = 'idle' | 'happy' | 'talking' | 'sleeping' | 'celebrate' export type BubbleTone = 'cheer' | 'tip' | 'break' export interface Bubble extends Line { tone: BubbleTone } interface Signals { wordCount: number saveStatus: SaveStatus // Monotonic counters bumped by the app on each editor change / accepted // suggestion — lets the companion react without a full event bus. editTick: number acceptTick: number // The document's plain text, used by the rules-based prose checker to offer // context-aware writing notes instead of only generic tips. text: string } // Timing knobs (ms). Tuned to feel present but never naggy. const IDLE_MS = 75_000 // no edits → kitten naps const BREAK_MS = 25 * 60_000 // continuous writing → suggest a break const TIP_MIN_GAP = 4 * 60_000 // at most one spontaneous tip per this window const PROACTIVE_GAP = 40_000 // floor between any two unsolicited bubbles // How long a bubble lingers. These are *floors* — readBubbleMs extends them by // how much there is to read, since she reads both the Mandarin and the English // (and tips now quote a slice of her own sentence, so they run longer). const BUBBLE_MS = 9_000 // baseline for a tip / break const CHEER_MS = 6_000 // a short cheer still needs a beat in two languages const READ_MS_PER_CHAR = 45 // ~22 chars/sec, generous for a bilingual read const MAX_BUBBLE_MS = 20_000 // cap so a long quote can't pin the bubble forever const HOVER_GRACE_MS = 3_000 // lingers this long after she stops hovering const now = () => Date.now() // Reading time for a bubble: a base floor by tone, stretched by the combined // length of the Mandarin + English lines so denser advice stays up long enough // to actually finish reading. function readBubbleMs(b: Bubble): number { const base = b.tone === 'cheer' ? CHEER_MS : BUBBLE_MS const chars = b.zh.length + b.en.length return Math.min(MAX_BUBBLE_MS, base + chars * READ_MS_PER_CHAR) } // useCompanion is the behavior engine: it watches writing signals and decides // when the kitten speaks, what mood it shows, and how to pace itself so the // companion feels alive without interrupting. UI-agnostic — returns state only. export function useCompanion({ wordCount, saveStatus, editTick, acceptTick, text }: Signals) { const [mood, setMood] = useState('idle') const [bubble, setBubble] = useState(null) const lastActivity = useRef(now()) const sessionStart = useRef(now()) const lastProactive = useRef(0) const lastTip = useRef(0) const lastBreak = useRef(0) const nextMilestone = useRef(0) // index into MILESTONES const sleeping = useRef(false) // Latest text for the prose checker, read lazily by the heartbeat (kept in a // ref so per-keystroke changes don't re-arm the interval). const textRef = useRef(text) textRef.current = text // Recently-surfaced hint ids, so the same untouched sentence isn't re-flagged // each cadence. A small ring — old ids age out and may resurface later. const recentHints = useRef([]) const lastRule = useRef('') const bubbleTimer = useRef>(undefined) const moodTimer = useRef>(undefined) // Show a bubble + talking mood, then settle back. `proactive` messages respect // the spacing floor; user-triggered ones (cheers) always go through. const say = useCallback((b: Bubble, opts?: { proactive?: boolean; celebrate?: boolean }) => { const t = now() if (opts?.proactive) { if (bubble) return if (t - lastProactive.current < PROACTIVE_GAP) return lastProactive.current = t } sleeping.current = false setBubble(b) setMood(opts?.celebrate ? 'celebrate' : 'talking') clearTimeout(bubbleTimer.current) clearTimeout(moodTimer.current) const dur = readBubbleMs(b) bubbleTimer.current = setTimeout(() => setBubble(null), dur) moodTimer.current = setTimeout(() => setMood('idle'), dur) }, [bubble]) // Choose the next spontaneous tip. Prefer a real, context-aware note from the // rules-based prose checker (it's actionable and clearly about *her* writing); // fall back to a generic encouragement-style tip when the text reads clean or // every finding was shown recently. Avoids repeating a finding or the same // rule family back-to-back so the companion never feels like a broken record. const nextTip = useCallback((): Bubble => { const hints = analyzeProse(textRef.current) const fresh = hints.find( (h) => !recentHints.current.includes(h.id) && h.rule !== lastRule.current, ) const hint = fresh ?? hints.find((h) => !recentHints.current.includes(h.id)) if (hint) { lastRule.current = hint.rule recentHints.current = [hint.id, ...recentHints.current].slice(0, 8) return { zh: hint.zh, en: hint.en, tone: 'tip' } } return { ...pick(TIPS), tone: 'tip' } }, []) const dismiss = useCallback(() => { clearTimeout(bubbleTimer.current) clearTimeout(moodTimer.current) setBubble(null) setMood('idle') }, []) // Pause the auto-dismiss while she hovers a bubble — so a longer note never // vanishes mid-read. Releasing leaves it up for a short, fixed grace so it // doesn't linger forever once she looks away. const holdBubble = useCallback(() => { clearTimeout(bubbleTimer.current) clearTimeout(moodTimer.current) }, []) const releaseBubble = useCallback(() => { clearTimeout(bubbleTimer.current) clearTimeout(moodTimer.current) bubbleTimer.current = setTimeout(() => setBubble(null), HOVER_GRACE_MS) moodTimer.current = setTimeout(() => setMood('idle'), HOVER_GRACE_MS) }, []) // Opening hello (once), after a short beat so it doesn't race the first paint. useEffect(() => { const id = setTimeout(() => say({ ...GREETING, tone: 'tip' }), 1200) return () => clearTimeout(id) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // Edits: record activity and wake from a nap with a warm welcome-back. const firstEdit = useRef(true) useEffect(() => { if (firstEdit.current) { firstEdit.current = false return } lastActivity.current = now() if (sleeping.current) { sleeping.current = false sessionStart.current = now() // a fresh stretch starts on return say({ ...WELCOME_BACK, tone: 'cheer' }) } else if (mood === 'sleeping') { setMood('idle') } // eslint-disable-next-line react-hooks/exhaustive-deps }, [editTick]) // Accepts: always cheer (it's a direct response to her action). const firstAccept = useRef(true) useEffect(() => { if (firstAccept.current) { firstAccept.current = false return } say({ ...pick(ENCOURAGEMENTS), tone: 'cheer' }, { celebrate: true }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [acceptTick]) // Word-count milestones: cheer the first time each threshold is crossed. useEffect(() => { while ( nextMilestone.current < MILESTONES.length && wordCount >= MILESTONES[nextMilestone.current] ) { const n = MILESTONES[nextMilestone.current] nextMilestone.current += 1 // Skip silently if we're just loading a long doc (no edits yet). if (!firstEdit.current) say({ ...milestoneLine(n), tone: 'cheer' }, { celebrate: true }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [wordCount]) // Occasional gentle cheer on a successful save (kept rare so it isn't noise). useEffect(() => { if (saveStatus === 'saved' && Math.random() < 0.18) { say({ ...pick(ENCOURAGEMENTS), tone: 'cheer' }, { proactive: true }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [saveStatus]) // Heartbeat: nap when idle, suggest breaks, and drop the odd writing tip. useEffect(() => { const id = setInterval(() => { const t = now() const idleFor = t - lastActivity.current if (idleFor > IDLE_MS) { sleeping.current = true if (!bubble) setMood('sleeping') return // resting — no nudges while she's away } // Active: time for a break? if (t - sessionStart.current > BREAK_MS && t - lastBreak.current > BREAK_MS) { lastBreak.current = t sessionStart.current = t say({ ...pick(BREAKS), tone: 'break' }, { proactive: true }) return } // Otherwise an occasional tip — context-aware when her text gives us // something concrete to gently point at, generic warmth otherwise. if (t - lastTip.current > TIP_MIN_GAP) { lastTip.current = t say(nextTip(), { proactive: true }) } }, 10_000) return () => clearInterval(id) }, [bubble, say, nextTip]) useEffect( () => () => { clearTimeout(bubbleTimer.current) clearTimeout(moodTimer.current) }, [], ) return { mood, bubble, dismiss, holdBubble, releaseBubble } }