feat(desktop): add pet roam + motion/direction store signals

Opt-in $petRoam (localStorage), $petMotion (run/jump pose) and $petRoamDir (-1/0/1) feed the shared $petState only while the agent is at rest ($petAtRest), so a wander never overrides real activity.
This commit is contained in:
Brooklyn Nicholson 2026-06-29 14:26:02 -05:00
parent ccc92c5213
commit c6d6a1c30d
2 changed files with 97 additions and 4 deletions

View file

@ -1,6 +1,14 @@
import { describe, expect, it } from 'vitest'
import { $petActivity, $petState, derivePetState, flashPetActivity, setPetActivity } from './pet'
import {
$petActivity,
$petAtRest,
$petMotion,
$petState,
derivePetState,
flashPetActivity,
setPetActivity
} from './pet'
describe('derivePetState', () => {
it('rests at idle by default and uses waiting when awaiting input', () => {
@ -32,6 +40,41 @@ describe('derivePetState', () => {
})
})
describe('roam motion', () => {
it('only reports at-rest when the agent-driven state is plain idle', () => {
$petActivity.set({})
expect($petAtRest.get()).toBe(true)
$petActivity.set({ busy: true })
expect($petAtRest.get()).toBe(false)
$petActivity.set({})
expect($petAtRest.get()).toBe(true)
})
it('shows the roam pose while wandering, but never overrides real activity', () => {
$petActivity.set({})
$petMotion.set('run')
expect($petState.get()).toBe('run')
// Hops surface the jump pose.
$petMotion.set('jump')
expect($petState.get()).toBe('jump')
// Activity wins over a wander in progress.
$petActivity.set({ reasoning: true, busy: true })
expect($petState.get()).toBe('review')
// Back at rest, the wander resumes its pose; clearing it returns to idle.
$petActivity.set({})
expect($petState.get()).toBe('jump')
$petMotion.set(null)
expect($petState.get()).toBe('idle')
$petActivity.set({})
})
})
describe('flashPetActivity', () => {
it('clears stale sibling beats so a completion never inherits a prior error', () => {
// A turn errors (sad), then the next turn finishes cleanly. The celebrate

View file

@ -1,5 +1,6 @@
import { atom, computed } from 'nanostores'
import { persistBoolean, storedBoolean } from '@/lib/storage'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { $busy } from '@/store/session'
@ -134,15 +135,15 @@ export const flashPetActivity = (next: Partial<PetActivity>, ms = 1600) => {
export const setPetInfo = (info: PetInfo) => $petInfo.set(info)
/**
* The live pet state. Derives from the dedicated activity atom, falling back to
* the always-present `$busy` chat signal so the pet reacts out of the box.
* Resolve the live activity state from the dedicated activity atom, falling back
* to the always-present `$busy` chat signal so the pet reacts out of the box.
*
* `awaitingInput` (a clarify/approval blocking on the user) is an explicit flag
* on `$petActivity` set by the controller from `$attentionSessionIds` and
* mirrored to the pop-out overlay through the same atom, so both surfaces agree
* without the overlay needing the session list.
*/
export const $petState = computed([$petActivity, $busy], (activity, busy): PetState => {
function deriveLivePetState(activity: PetActivity, busy: boolean): PetState {
const live = activity.busy ?? busy
return derivePetState({
@ -156,4 +157,53 @@ export const $petState = computed([$petActivity, $busy], (activity, busy): PetSt
justCompleted: activity.justCompleted,
celebrate: activity.celebrate
})
}
/**
* Opt-in: let the floating mascot wander around the window on its own while
* idle. Pure desktop-client behavior (no agent/config dependency), so it lives
* in localStorage like the pet's drag position per-device, not per-profile.
*/
const ROAM_KEY = 'hermes.desktop.pet-roam.v1'
export const $petRoam = atom<boolean>(storedBoolean(ROAM_KEY, false))
export const setPetRoam = (on: boolean) => {
$petRoam.set(on)
persistBoolean(ROAM_KEY, on)
}
/**
* The pose the roam loop is currently driving: `run` while walking a surface,
* `jump` while hopping/falling between surfaces, or `null` at rest. Surfaced
* through `$petState` (below) so the canvas animates the wander without any prop
* change or re-render it already subscribes to `$petState`.
*/
export const $petMotion = atom<PetState | null>(null)
/**
* Horizontal travel direction while roaming: -1 left, 1 right, 0 not walking.
* The floating pet maps this to the directional run row + mirror, keeping the
* wander loop free of sprite-row knowledge.
*/
export const $petRoamDir = atom<-1 | 0 | 1>(0)
/**
* Whether the agent-driven state is at rest (plain `idle`). The roam loop gates
* on this never on `$petState` itself, which would feed back on its own
* `$petMotion`-driven pose and stall the wander.
*/
export const $petAtRest = computed(
[$petActivity, $busy],
(activity, busy): boolean => deriveLivePetState(activity, busy) === 'idle'
)
/**
* The live pet state. Activity always wins; only when the agent is at rest does
* a roam pose (walking `run`, hopping `jump`) show through, so the wander
* reads as deliberate movement.
*/
export const $petState = computed([$petActivity, $busy, $petMotion], (activity, busy, motion): PetState => {
const base = deriveLivePetState(activity, busy)
return base === 'idle' && motion ? motion : base
})