Merge pull request #55114 from NousResearch/bb/pet-roam

feat(desktop): roaming pet (opt-in)
This commit is contained in:
brooklyn! 2026-06-29 15:00:03 -05:00 committed by GitHub
commit d417ffb363
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 660 additions and 11 deletions

View file

@ -200,7 +200,7 @@ export function ProfileRail() {
}, [createRequest])
return (
<div aria-label="Profiles" className="flex items-center gap-0.5" role="tablist">
<div aria-label="Profiles" className="flex items-center gap-0.5" data-slot="profile-rail" role="tablist">
{/* One button toggles default all: home face when scoped to a profile,
layers face when showing everything. Pinned left like Manage is right.
Hidden until a second profile exists. */}

View file

@ -13,7 +13,7 @@ import { triggerHaptic } from '@/lib/haptics'
import { Download, Loader2, PawPrint, Pencil, Trash2 } from '@/lib/icons'
import { selectableCardClass } from '@/lib/selectable-card'
import { cn } from '@/lib/utils'
import { $petInfo } from '@/store/pet'
import { $petInfo, $petRoam, setPetRoam } from '@/store/pet'
import {
$petBusy,
$petGallery,
@ -54,6 +54,7 @@ export function PetSettings() {
const error = useStore($petGalleryError)
const busySlug = useStore($petBusy)
const petInfo = useStore($petInfo)
const roam = useStore($petRoam)
const [query, setQuery] = useState('')
const [confirmDelete, setConfirmDelete] = useState<GalleryPet | null>(null)
const [renameTarget, setRenameTarget] = useState<GalleryPet | null>(null)
@ -279,6 +280,26 @@ export function PetSettings() {
title={copy.scaleTitle}
/>
)}
{enabled && (
<ListRow
action={
<SegmentedControl
onChange={id => {
setPetRoam(id === 'on')
triggerHaptic('crisp')
}}
options={[
{ id: 'off', label: copy.off },
{ id: 'on', label: copy.on }
]}
value={roam ? 'on' : 'off'}
/>
}
description={copy.roamDesc}
title={copy.roamTitle}
/>
)}
</div>
<ConfirmDialog

View file

@ -64,6 +64,7 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
'flex h-5 shrink-0 items-stretch justify-between gap-2 border-t border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background) px-1 py-0 text-(--ui-text-tertiary) [-webkit-app-region:no-drag]',
className
)}
data-slot="statusbar"
{...props}
>
{/* `overflow-x-clip` (not `overflow-x-auto`) so a wide status item for

View file

@ -2,8 +2,9 @@ import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { useRouteOverlayActive } from '@/app/hooks/use-route-overlay-active'
import { persistString, storedString } from '@/lib/storage'
import { $petInfo, clearPetUnread, type PetInfo, petProfile, setPetInfo } from '@/store/pet'
import { $petAtRest, $petInfo, $petRoam, $petRoamDir, clearPetUnread, type PetInfo, petProfile, setPetInfo } from '@/store/pet'
import { resetPetGallery, setPetScale } from '@/store/pet-gallery'
import { $petOverlayActive, initPetOverlayBridge, popOutPet, restorePetOverlay } from '@/store/pet-overlay'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
@ -11,7 +12,8 @@ import { $gatewayState } from '@/store/session'
import { isSecondaryWindow } from '@/store/windows'
import { useTheme } from '@/themes/context'
import { PetSprite } from './pet-sprite'
import { PetSprite, roamWalkRow } from './pet-sprite'
import { usePetRoam } from './use-pet-roam'
import { type PetZoomAnchor, usePetZoomGesture } from './use-pet-zoom-gesture'
// v2: positions are now top/left anchored (v1 stored bottom-anchored values,
@ -104,6 +106,10 @@ export function FloatingPet() {
const gatewayState = useStore($gatewayState)
const info = useStore($petInfo)
const overlayActive = useStore($petOverlayActive)
const roamEnabled = useStore($petRoam)
const atRest = useStore($petAtRest)
const roamDir = useStore($petRoamDir)
const routeOverlayOpen = useRouteOverlayActive()
const [position, setPosition] = useState<Point>(loadPosition)
const containerRef = useRef<HTMLDivElement | null>(null)
@ -367,6 +373,35 @@ export function FloatingPet() {
usePetZoomGesture(containerRef, onScale, active && !overlayActive)
// Commit a roamed-to position back to React state + storage when the wander
// loop settles, so the inline style matches the DOM once the loop stops
// driving it imperatively. Stable identity keeps the roam effect from
// restarting every render.
const commitRoamPosition = useCallback((point: Point) => {
setPosition(point)
persistString(POSITION_KEY, JSON.stringify(point))
}, [])
const isDragging = useCallback(() => dragRef.current !== null, [])
// Roam only the in-window pet, only while it's idle (agent at rest) and not
// popped out into the OS overlay. Activity pauses the wander; the pet reacts
// in place, then resumes strolling when the turn ends.
usePetRoam({
commit: commitRoamPosition,
containerRef,
enabled: roamEnabled && active && !overlayActive && atRest,
isInteracting: isDragging,
loopMs: info.loopMs ?? 1100,
overlayOpen: routeOverlayOpen,
petH,
petW
})
// While roaming, drive the directional run row + mirror from the travel
// direction; at rest, fall back to the inward-facing static mascot.
const walk = roamWalkRow(roamDir, info.stateRows)
// While popped out, the desktop overlay window owns the mascot — hide the
// in-window one so there aren't two.
if (!info.enabled || !info.spritesheetBase64 || overlayActive) {
@ -406,9 +441,14 @@ export function FloatingPet() {
/>
<div
ref={spriteWrapRef}
style={{ lineHeight: 0, position: 'relative', transform: facing(position.x, petW), zIndex: 1 }}
style={{
lineHeight: 0,
position: 'relative',
transform: roamDir !== 0 ? (walk.mirror ? 'scaleX(-1)' : 'none') : facing(position.x, petW),
zIndex: 1
}}
>
<PetSprite info={info} />
<PetSprite info={info} rowOverride={walk.row} />
</div>
</div>
)

View file

@ -11,7 +11,7 @@ const DEFAULT_LOOP_MS = 1100
const DEFAULT_SCALE = 0.33
// Mirrors agent.pet.constants.CODEX_STATE_ROWS (Petdex current taxonomy).
const DEFAULT_STATE_ROWS = [
export const DEFAULT_STATE_ROWS = [
'idle',
'running-right',
'running-left',
@ -48,6 +48,47 @@ const ROW_TO_STATE: Record<string, PetState> = {
waiting: 'waiting'
}
/**
* Pick the running row + mirror for a horizontal travel direction.
*
* Codex sheets ship dedicated `running-left` / `running-right` locomotion rows
* (already facing their way no flip). Pets without them fall back to the
* in-place `running`/`run` row, which faces left by convention, so rightward
* travel is mirrored. Returns no `row` in that fallback case so the caller lets
* `$petState` resolve it (and applies `mirror`).
*/
export function roamWalkRow(dir: -1 | 0 | 1, stateRows?: string[]): { row?: string; mirror: boolean } {
if (dir === 0) {
return { mirror: false }
}
const rows = stateRows ?? DEFAULT_STATE_ROWS
const hasLeft = rows.includes('running-left')
const hasRight = rows.includes('running-right')
if (dir > 0) {
if (hasRight) {
return { mirror: false, row: 'running-right' }
}
if (hasLeft) {
return { mirror: true, row: 'running-left' }
}
return { mirror: true }
}
if (hasLeft) {
return { mirror: false, row: 'running-left' }
}
if (hasRight) {
return { mirror: true, row: 'running-right' }
}
return { mirror: false }
}
interface PetSpriteProps {
info: PetInfo
/** On-screen scale multiplier applied on top of the pet's native scale. */

View file

@ -0,0 +1,443 @@
import { type RefObject, useEffect } from 'react'
import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar'
import { $petMotion, $petRoamDir, type PetState } from '@/store/pet'
interface Point {
x: number
y: number
}
/**
* A horizontal surface the pet can stand and walk on. `y` is the surface line
* (where the pet's feet rest); `left`/`right` bound the pet's top-left x so the
* whole sprite stays on the ledge.
*/
interface Ledge {
y: number
left: number
right: number
}
// Elements the pet can perch on top of, measured fresh each beat. The bottom
// floor is always a ledge; these add app furniture the pet can climb onto (the
// composer, the profile rail). Add a `data-slot` here to grow the playground.
const PERCH_SELECTORS = ['[data-slot="composer-surface"]', '[data-slot="profile-rail"]']
// A full-width bar pinned to the window bottom (the status bar). When present,
// the pet walks along its TOP edge instead of the window edge, so it stands on
// the bar rather than covering it.
const FLOOR_BAR_SELECTOR = '[data-slot="statusbar"]'
// Foot-sync: advance this many body-widths per animation loop so the walk reads
// as steps, not a glide. Actual px/s is derived from the sprite's loop duration
// and on-screen size (see `walkSpeedPxS`).
const STRIDE_PER_LOOP = 0.8
// Downward acceleration for falls between ledges — fast enough to read as a drop.
const GRAVITY_PX_S2 = 5200
// Time to spring up onto a higher ledge.
const JUMP_DUR_MS = 460
const PAUSE_MIN_MS = 1800
const PAUSE_MAX_MS = 5200
// Tiny settle after a drag release before the pet re-plans (and usually falls),
// so dropping it in mid-air snaps down promptly instead of hanging for a beat.
const DROP_SETTLE_MS = 90
// Chance a beat hops to another ledge instead of strolling the current one.
const HOP_CHANCE = 0.45
// Strolls should cover ground, not shuffle: travel at least this fraction of the
// ledge (or this many px, whichever is larger), up to the room available.
const STROLL_MIN_FRACTION = 0.45
const STROLL_MIN_PX = 110
// Sprites carry a few px of transparent padding below the feet; sink the pet by
// this much so the visible feet meet the surface instead of hovering above it.
const FEET_DROP_PX = 4
// Snap distances: "on this ledge" / arrived at a walk target.
const GROUND_EPS = 2
const ARRIVE_EPS = 1.5
// Cap dt so a backgrounded/throttled tab can't teleport the pet on resume.
const MAX_DT_S = 0.05
type Phase = 'pause' | 'walk' | 'fall' | 'jump'
const rand = (min: number, max: number): number => min + Math.random() * (max - min)
const easeOutCubic = (t: number): number => 1 - (1 - t) ** 3
const signDir = (n: number): -1 | 0 | 1 => (n > 0 ? 1 : n < 0 ? -1 : 0)
function vw(): number {
return window.innerWidth || 800
}
function vh(): number {
return window.innerHeight || 600
}
/** The bottom ground line: the top of the status bar if it's pinned full-width
* across the window bottom, otherwise the window edge. */
function floorY(width: number, height: number, petH: number): number {
const bar = document.querySelector(FLOOR_BAR_SELECTOR)
if (bar) {
const rect = bar.getBoundingClientRect()
if (rect.width >= width * 0.5 && height - rect.bottom < 4 && rect.top - petH >= 0) {
return rect.top
}
}
return height
}
/** Snapshot the walkable surfaces right now: the bottom floor plus any on-screen
* perch element with room above it for the pet to stand. */
function snapshotLedges(petW: number, petH: number): Ledge[] {
const width = vw()
const height = vh()
const ledges: Ledge[] = [{ left: 0, right: Math.max(0, width - petW), y: floorY(width, height, petH) }]
for (const selector of PERCH_SELECTORS) {
const el = document.querySelector(selector)
if (!el) {
continue
}
const rect = el.getBoundingClientRect()
const left = Math.max(0, rect.left)
const right = Math.min(width - petW, rect.right - petW)
// Skip surfaces that are too narrow for the pet, have no headroom above, or
// sit off-screen / flush with the floor (no daylight between them).
if (right <= left + 2 || rect.top - petH < 0 || rect.top > height - 8 || height - rect.top < 12) {
continue
}
ledges.push({ left, right, y: rect.top })
}
return ledges
}
interface PetRoamOptions {
/** Run the wander loop (roam opt-in + pet active + in-window + agent at rest). */
enabled: boolean
containerRef: RefObject<HTMLDivElement | null>
/** True while the user is dragging — the loop yields so it never fights a drag. */
isInteracting: () => boolean
petW: number
petH: number
/** Sprite animation loop duration (ms) — paces the walk to the leg cadence. */
loopMs: number
/** A full-screen route overlay (settings/profiles/…) is up: patrol its base. */
overlayOpen: boolean
/** Persist the resting position back to React state when the loop settles. */
commit: (point: Point) => void
}
/**
* Make the floating pet wander the app like a platformer character: it walks
* along surfaces (the window floor, the top of the composer, ), hops up onto
* higher ledges, and drops off them instead of drifting diagonally through
* empty space. Surfaces are re-measured from the live DOM at the start of every
* beat (`snapshotLedges`), so the pet tracks the composer growing, the sidebar
* opening, the window resizing, and even falls back to the floor when its perch
* disappears.
*
* Movement mutates `el.style.left/top` directly each frame like the drag
* handler so a steady wander triggers no React re-renders, and because it
* re-asserts the DOM position every frame, an incidental parent re-render that
* snaps `style` back self-heals within a frame. State is only committed (via
* `commit`) when the pet settles, keeping React's `position` in sync once the
* loop stops driving it.
*
* Two signals publish the wander so the canvas/sprite react without a prop
* change: `$petMotion` (`run` while walking, `jump` while hopping/falling) flips
* the shared `$petState`, and `$petRoamDir` (-1/0/1) lets the floating pet pick
* the directional run row + mirror for the travel direction.
*/
export function usePetRoam({
enabled,
containerRef,
isInteracting,
petW,
petH,
loopMs,
overlayOpen,
commit
}: PetRoamOptions): void {
useEffect(() => {
if (!enabled) {
$petMotion.set(null)
$petRoamDir.set(0)
return
}
const el = containerRef.current
if (!el) {
return
}
// Pace the stride to the sprite: one body-width per animation loop.
const walkSpeedPxS = (petW * STRIDE_PER_LOOP) / (loopMs / 1000)
const groundTop = (ledge: Ledge): number => ledge.y - petH + FEET_DROP_PX
// A stroll destination on `ledge` that actually goes somewhere: lean toward
// the side with more room (so the pet crosses the app rather than shuffling
// in place) and guarantee a decent minimum travel distance.
const pickStrollTarget = (ledge: Ledge): number => {
const span = ledge.right - ledge.left
if (span <= 4) {
return ledge.left
}
const roomLeft = cur.x - ledge.left
const roomRight = ledge.right - cur.x
const goRight = roomRight >= roomLeft ? Math.random() < 0.85 : Math.random() < 0.15
const room = Math.max(0, goRight ? roomRight : roomLeft)
const minDist = Math.min(room, Math.max(span * STROLL_MIN_FRACTION, STROLL_MIN_PX))
const dist = minDist + Math.random() * Math.max(0, room - minDist)
return goRight ? cur.x + dist : cur.x - dist
}
// Seed from the live DOM rect so we resume from wherever the pet actually is
// (after a drag, reclamp, or activity pause) rather than a stale closure.
const rect = el.getBoundingClientRect()
const cur: Point = { x: rect.left, y: rect.top }
let phase: Phase = 'pause'
let pauseUntil = performance.now() + rand(400, 1200)
let last = performance.now()
let raf = 0
let walkTargetX = cur.x
let curLedge: Ledge | null = null
let targetLedge: Ledge | null = null
// When set, the current walk is the approach run before a hop to this ledge.
let pendingHop: Ledge | null = null
// Fall / jump integrators.
let fallVel = 0
let jumpFromY = 0
let jumpElapsed = 0
const applyDom = () => {
el.style.left = `${cur.x}px`
el.style.top = `${cur.y}px`
}
// One chokepoint for the wander signals: the pose (drives `$petState`) and
// the travel direction (drives the floating pet's directional row + mirror).
const signal = (pose: PetState | null, dir: -1 | 0 | 1) => {
$petMotion.set(pose)
$petRoamDir.set(dir)
}
const beginPause = (now: number) => {
phase = 'pause'
pauseUntil = now + rand(PAUSE_MIN_MS, PAUSE_MAX_MS)
signal(null, 0)
commit({ ...cur })
}
// Land flush on a ledge, then settle into the next idle beat.
const settleOn = (ledge: Ledge, now: number) => {
cur.y = groundTop(ledge)
curLedge = ledge
applyDom()
beginPause(now)
}
const beginVertical = (ledge: Ledge) => {
targetLedge = ledge
if (groundTop(ledge) < cur.y - 1) {
// Up onto a higher ledge: a quick spring.
phase = 'jump'
jumpFromY = cur.y
jumpElapsed = 0
} else {
// Down off a ledge: let gravity take it.
phase = 'fall'
fallVel = 0
}
signal('jump', 0)
}
// Does the pet, standing at cur.x, have a stretch of `to` it can step across
// to from `from` (their walkable x-ranges overlap)?
const overlapsX = (from: Ledge, to: Ledge): boolean =>
Math.min(from.right, to.right) > Math.max(from.left, to.left) + 2
// Find the highest surface at or below the pet's feet under its current x —
// i.e. what it's standing on, or what it would fall onto.
const resolveLedge = (ledges: Ledge[]): Ledge => {
const bottom = cur.y + petH
let best: Ledge | null = null
for (const ledge of ledges) {
if (cur.x < ledge.left - 2 || cur.x > ledge.right + 2) {
continue
}
if (ledge.y >= bottom - GROUND_EPS && (!best || ledge.y < best.y)) {
best = ledge
}
}
// Floor always spans the clamped x-range, so this only falls back if the
// pet is somehow below everything — drop it to the floor.
return best ?? ledges[0]!
}
// While an overlay is up, it's the only walkable surface: a single ledge at
// the overlay card's bottom inner edge. The card uses `OverlayView`'s equal
// inset on every side — `titlebar-height + padding` — so derive it from that
// (never measured).
const overlayLedge = (): Ledge => {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
const inset = TITLEBAR_HEIGHT + (vw() >= 640 ? 0.875 : 0.625) * rem
return { left: inset, right: Math.max(0, vw() - inset - petW), y: vh() - inset }
}
const planNext = () => {
// An open overlay swaps the surface set to just its bottom edge, so the pet
// patrols along it; closing it restores the normal surfaces (and the pet
// drops to whatever's below).
const ledges = overlayOpen ? [overlayLedge()] : snapshotLedges(petW, petH)
curLedge = resolveLedge(ledges)
if (Math.abs(cur.y - groundTop(curLedge)) > GROUND_EPS) {
// Dragged into the air, or the surface moved out from under it: fall.
beginVertical(curLedge)
return
}
const reachable = ledges.filter(ledge => ledge !== curLedge && overlapsX(curLedge!, ledge))
if (reachable.length > 0 && Math.random() < HOP_CHANCE) {
const next = reachable[Math.floor(Math.random() * reachable.length)]!
const lo = Math.max(curLedge.left, next.left)
const hi = Math.min(curLedge.right, next.right)
pendingHop = next
walkTargetX = lo + Math.random() * (hi - lo)
} else {
pendingHop = null
walkTargetX = pickStrollTarget(curLedge)
}
phase = 'walk'
signal('run', signDir(walkTargetX - cur.x))
}
const step = (now: number) => {
const dt = Math.min(MAX_DT_S, (now - last) / 1000)
last = now
// Yield to a drag: track the pet so we resume from the drop point, and
// reset the idle beat so it doesn't bolt the instant it's let go.
if (isInteracting()) {
const live = el.getBoundingClientRect()
cur.x = live.left
cur.y = live.top
phase = 'pause'
pendingHop = null
// Short settle so the pet falls right after you drop it, not seconds later.
pauseUntil = now + DROP_SETTLE_MS
signal(null, 0)
raf = requestAnimationFrame(step)
return
}
switch (phase) {
case 'pause': {
if (now >= pauseUntil) {
planNext()
}
break
}
case 'walk': {
const remaining = walkTargetX - cur.x
const stepDist = walkSpeedPxS * dt
if (Math.abs(remaining) <= Math.max(ARRIVE_EPS, stepDist)) {
cur.x = walkTargetX
applyDom()
if (pendingHop) {
const next = pendingHop
pendingHop = null
beginVertical(next)
} else {
beginPause(now)
}
} else {
cur.x += Math.sign(remaining) * stepDist
applyDom()
}
break
}
case 'fall': {
if (!targetLedge) {
beginPause(now)
break
}
fallVel += GRAVITY_PX_S2 * dt
cur.y += fallVel * dt
if (cur.y >= groundTop(targetLedge)) {
settleOn(targetLedge, now)
} else {
applyDom()
}
break
}
case 'jump': {
if (!targetLedge) {
beginPause(now)
break
}
jumpElapsed += dt * 1000
const t = Math.min(1, jumpElapsed / JUMP_DUR_MS)
cur.y = jumpFromY + (groundTop(targetLedge) - jumpFromY) * easeOutCubic(t)
if (t >= 1) {
settleOn(targetLedge, now)
} else {
applyDom()
}
break
}
}
raf = requestAnimationFrame(step)
}
raf = requestAnimationFrame(step)
return () => {
cancelAnimationFrame(raf)
signal(null, 0)
// Hand the final position back to React so its `style` matches the DOM once
// the loop stops re-asserting it.
commit({ ...cur })
}
}, [enabled, petW, petH, loopMs, overlayOpen, containerRef, isInteracting, commit])
}

View file

@ -414,6 +414,8 @@ export const en: Translations = {
off: 'Off',
scaleTitle: 'Size',
scaleDesc: 'Resize the floating mascot. Applies everywhere instantly.',
roamTitle: 'Roam',
roamDesc: 'Let the pet wander the window on its own while idle.',
chooseTitle: 'Choose a pet',
chooseDesc: 'Picking one installs it (if needed) and makes it active.',
searchPlaceholder: 'Search pets…',

View file

@ -319,6 +319,8 @@ export const ja = defineLocale({
'ペット機能には再起動が必要です。この機能が追加される前に起動したアプリが動作中です。Hermes を終了して再度開き、このページに戻ってください。',
scaleTitle: 'サイズ',
scaleDesc: '浮遊マスコットの大きさを変更します。すべての画面に即時反映されます。',
roamTitle: '散歩',
roamDesc: 'アイドル中にペットがウィンドウ内を自由に歩き回ります。',
on: 'オン',
off: 'オフ',
chooseTitle: 'ペットを選ぶ',

View file

@ -331,6 +331,8 @@ export interface Translations {
off: string
scaleTitle: string
scaleDesc: string
roamTitle: string
roamDesc: string
chooseTitle: string
chooseDesc: string
searchPlaceholder: string

View file

@ -308,6 +308,8 @@ export const zhHant = defineLocale({
restartHint: '寵物功能需要重新啟動——目前執行的應用在此功能加入前啟動。請結束並重新開啟 Hermes然後回到此處。',
scaleTitle: '大小',
scaleDesc: '調整懸浮寵物的大小,所有介面即時生效。',
roamTitle: '漫遊',
roamDesc: '閒置時讓寵物自己在視窗內四處走動。',
on: '開啟',
off: '關閉',
chooseTitle: '選擇寵物',

View file

@ -399,6 +399,8 @@ export const zh: Translations = {
restartHint: '宠物功能需要重启——当前运行的应用在此功能加入前启动。请退出并重新打开 Hermes然后回到此处。',
scaleTitle: '大小',
scaleDesc: '调整悬浮宠物的大小,所有界面即时生效。',
roamTitle: '漫游',
roamDesc: '空闲时让宠物自己在窗口内四处走动。',
on: '开启',
off: '关闭',
chooseTitle: '选择宠物',

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
})