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
183 lines
6.1 KiB
TypeScript
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 }
|
|
}
|