diff --git a/apps/desktop/src/store/pet.test.ts b/apps/desktop/src/store/pet.test.ts index 2837334ab37..ce2327becb4 100644 --- a/apps/desktop/src/store/pet.test.ts +++ b/apps/desktop/src/store/pet.test.ts @@ -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 diff --git a/apps/desktop/src/store/pet.ts b/apps/desktop/src/store/pet.ts index 68b0c523982..1b189ac8291 100644 --- a/apps/desktop/src/store/pet.ts +++ b/apps/desktop/src/store/pet.ts @@ -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, 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(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(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 })