Files
petal/web/src/components/Companion/useCompanion.ts
prosolis 8a32dde587 Add companion kitten — reactive corner mascot
A cozy bottom-right mascot that reacts to the writing session: cheers on
accepted suggestions and word-count milestones, drops Mandarin-first writing
tips, suggests screen breaks after long stretches, and naps when idle.

- useCompanion: library-agnostic behavior engine (priority + cooldown paced
  so it never nags); tips.ts holds all copy, bilingual zh-first.
- LottiePlayer wraps lottie-web's light build (offline, no eval/CDN) so it
  bundles into the Go binary. Ships with an emoji-kitten placeholder per mood;
  dropping a Lottie cat JSON into animations/index.ts is the only swap needed.
- Speech bubble uses the CJK-first font stack (spec Note #17).

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-25 21:43:58 -07:00

183 lines
6.1 KiB
TypeScript

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'
// 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
}
// 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
const BUBBLE_MS = 6_500 // how long a bubble lingers
const CHEER_MS = 4_500 // shorter for quick cheers
const now = () => Date.now()
// 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 }: Signals) {
const [mood, setMood] = useState<Mood>('idle')
const [bubble, setBubble] = useState<Bubble | null>(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)
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
const moodTimer = useRef<ReturnType<typeof setTimeout>>(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 = b.tone === 'cheer' ? CHEER_MS : BUBBLE_MS
bubbleTimer.current = setTimeout(() => setBubble(null), dur)
moodTimer.current = setTimeout(() => setMood('idle'), dur)
}, [bubble])
const dismiss = useCallback(() => {
clearTimeout(bubbleTimer.current)
clearTimeout(moodTimer.current)
setBubble(null)
setMood('idle')
}, [])
// 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.
if (t - lastTip.current > TIP_MIN_GAP) {
lastTip.current = t
say({ ...pick(TIPS), tone: 'tip' }, { proactive: true })
}
}, 10_000)
return () => clearInterval(id)
}, [bubble, say])
useEffect(
() => () => {
clearTimeout(bubbleTimer.current)
clearTimeout(moodTimer.current)
},
[],
)
return { mood, bubble, dismiss }
}