Reuse the companion's prose.ts rules engine as the single source of
deterministic detection instead of duplicating it. Applyable rules now
also emit exact-span fixes (original -> replacement) that surface as
suggestion cards; awareness-only rules (run-ons, splices, ...) stay
companion bubbles. The companion hides fix-bearing hints so a span is
never both a bubble and a card.
Spans are widened to a distinctive phrase ("a old" -> "an old",
"She have" -> "She has") so they re-anchor by string in the editor; a
lone lowercase "i" stays awareness-only since a single char can't anchor.
Backend: detection lives client-side, so the new persist-only
POST /docs/{id}/mechanics endpoint receives findings and stores them as
the 'mechanics' family with their exact offsets. It honours
actioned-suppression, leaves the LLM families untouched, and a checkpoint
no longer wipes it. fetchPending dedupes spans with mechanics winning any
collision against an LLM card (its span is exact). Migration 0008 adds the
'mechanics' suggestion type.
Client renders the mechanics fixes immediately (no LLM wait) and the cards
use a calm sage "Tidy-up" accent.
Verified end-to-end in a real browser on millenia: detect -> persist ->
render -> accept applies the fix.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
303 lines
12 KiB
TypeScript
303 lines
12 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import type { SaveStatus } from '../../hooks/useAutoSave'
|
|
import {
|
|
BEDTIME,
|
|
BREAKS,
|
|
ENCOURAGEMENTS,
|
|
ERRORS,
|
|
GREETING,
|
|
MILESTONES,
|
|
TIPS,
|
|
WELCOME_BACK,
|
|
milestoneLine,
|
|
pick,
|
|
type Line,
|
|
} from './tips'
|
|
import { analyzeProse } from './prose'
|
|
import { playPop, playSound, type SoundName } from '../../audio/sounds'
|
|
import { isBedtime } from '../../lib/night'
|
|
|
|
// 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' | 'error' | 'bedtime'
|
|
export interface Bubble extends Line {
|
|
tone: BubbleTone
|
|
}
|
|
|
|
interface Signals {
|
|
wordCount: number
|
|
saveStatus: SaveStatus
|
|
// True while the writing-assist LLM is unreachable (timeout / down). A
|
|
// false→true flip earns a gentle "haiya".
|
|
llmDown: boolean
|
|
// 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
|
|
const BEDTIME_GAP = 30 * 60_000 // at most one "go to bed" nudge per this window
|
|
// The late-night window itself (isBedtime) lives in ../../lib/night so the
|
|
// companion nag and the night-mode theme/starfall share one definition.
|
|
// 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 = 14_000 // baseline for a tip / break
|
|
const CHEER_MS = 9_000 // a short cheer still needs a beat in two languages
|
|
const READ_MS_PER_CHAR = 55 // ~18 chars/sec, generous for a bilingual ESL read
|
|
const MAX_BUBBLE_MS = 32_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 : b.tone === 'bedtime' ? BUBBLE_MS + 4_000 : 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, llmDown, editTick, acceptTick, text }: 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 lastBedtime = 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<string[]>([])
|
|
const lastRule = useRef<string>('')
|
|
|
|
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; sound?: SoundName }) => {
|
|
const t = now()
|
|
if (opts?.proactive) {
|
|
if (bubble) return
|
|
if (t - lastProactive.current < PROACTIVE_GAP) return
|
|
lastProactive.current = t
|
|
}
|
|
sleeping.current = false
|
|
// A sound to match the bubble: callers can name a specific one (the milestone
|
|
// fanfare, the "haiya" on errors); everything else gets a rotating pop so the
|
|
// same blip never repeats back to back.
|
|
if (opts?.sound) playSound(opts.sound)
|
|
else playPop()
|
|
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 => {
|
|
// Applyable hints (those with a `fix`) surface as one-click suggestion cards,
|
|
// so the companion skips them — a span shouldn't be both a bubble and a card.
|
|
// What's left is the awareness notes (run-ons, splices, …) the kitten alone gives.
|
|
const hints = analyzeProse(textRef.current).filter((h) => !h.fix)
|
|
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, sound: 'milestone' })
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [wordCount])
|
|
|
|
// Trouble — a save failed or the writing-assist LLM went unreachable. Play the
|
|
// playful "haiya" and reassure her, but never spam: at most one per gap, and
|
|
// only on the *transition* into trouble (not while it lingers).
|
|
const lastError = useRef(0)
|
|
const ERROR_GAP = 20_000
|
|
const wasLlmDown = useRef(false)
|
|
const haiya = useCallback(() => {
|
|
const t = now()
|
|
if (t - lastError.current < ERROR_GAP) {
|
|
playSound('error') // still acknowledge it, just without a fresh bubble
|
|
return
|
|
}
|
|
lastError.current = t
|
|
say({ ...pick(ERRORS), tone: 'error' }, { sound: 'error' })
|
|
}, [say])
|
|
|
|
useEffect(() => {
|
|
if (saveStatus === 'error') haiya()
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [saveStatus])
|
|
|
|
useEffect(() => {
|
|
if (llmDown && !wasLlmDown.current) haiya()
|
|
wasLlmDown.current = llmDown
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [llmDown])
|
|
|
|
// 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
|
|
}
|
|
|
|
// Burning the midnight oil? Gently suggest bed — caring, low-frequency,
|
|
// and only while she's actually still at it (the idle branch above already
|
|
// returned if she's away/napping).
|
|
if (isBedtime() && t - lastBedtime.current > BEDTIME_GAP) {
|
|
lastBedtime.current = t
|
|
say({ ...pick(BEDTIME), tone: 'bedtime' }, { 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 }
|
|
}
|