From c6d6a1c30d033287f2cbbd9d7ecdd47146f1942a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 29 Jun 2026 14:26:02 -0500 Subject: [PATCH 1/8] 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. --- apps/desktop/src/store/pet.test.ts | 45 +++++++++++++++++++++++- apps/desktop/src/store/pet.ts | 56 ++++++++++++++++++++++++++++-- 2 files changed, 97 insertions(+), 4 deletions(-) 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 }) From 964ec680cc9250b3389104e4d1e0cabe1dd1872c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 29 Jun 2026 14:26:02 -0500 Subject: [PATCH 2/8] feat(desktop): pick directional run row from travel direction roamWalkRow() prefers running-left/running-right rows, falling back to the generic running row with a mirror for pets that lack them. --- .../desktop/src/components/pet/pet-sprite.tsx | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/components/pet/pet-sprite.tsx b/apps/desktop/src/components/pet/pet-sprite.tsx index 35d5f42f581..b3a0fb66003 100644 --- a/apps/desktop/src/components/pet/pet-sprite.tsx +++ b/apps/desktop/src/components/pet/pet-sprite.tsx @@ -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 = { 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. */ From a8f1d9cc76b1bb2719260534d83aa26c1407731a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 29 Jun 2026 14:26:02 -0500 Subject: [PATCH 3/8] feat(desktop): add surface-aware pet wander loop usePetRoam re-measures ledges from the live DOM each beat and walks/hops/falls between them, driving DOM position imperatively (no per-frame re-render). --- .../src/components/pet/use-pet-roam.ts | 407 ++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 apps/desktop/src/components/pet/use-pet-roam.ts diff --git a/apps/desktop/src/components/pet/use-pet-roam.ts b/apps/desktop/src/components/pet/use-pet-roam.ts new file mode 100644 index 00000000000..c79cc32ee16 --- /dev/null +++ b/apps/desktop/src/components/pet/use-pet-roam.ts @@ -0,0 +1,407 @@ +import { type RefObject, useEffect } from 'react' + +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"]' + +const WALK_SPEED_PX_S = 58 +// 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 +// 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 + /** True while the user is dragging — the loop yields so it never fights a drag. */ + isInteracting: () => boolean + petW: number + petH: number + /** 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, commit }: PetRoamOptions): void { + useEffect(() => { + if (!enabled) { + $petMotion.set(null) + $petRoamDir.set(0) + + return + } + + const el = containerRef.current + + if (!el) { + return + } + + const groundTop = (ledge: Ledge): number => ledge.y - petH + + // 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]! + } + + const planNext = () => { + const ledges = snapshotLedges(petW, petH) + curLedge = resolveLedge(ledges) + const grounded = Math.abs(cur.y - groundTop(curLedge)) <= GROUND_EPS + + if (!grounded) { + // Dragged into the air (or a perch vanished): fall to the surface below. + 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 = WALK_SPEED_PX_S * 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, containerRef, isInteracting, commit]) +} From 7d3c1d55f4e7f74539ffde1c8bde39701c2a3996 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 29 Jun 2026 14:26:02 -0500 Subject: [PATCH 4/8] feat(desktop): wire roaming into the floating pet --- .../src/components/pet/floating-pet.tsx | 44 +++++++++++++++++-- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/components/pet/floating-pet.tsx b/apps/desktop/src/components/pet/floating-pet.tsx index a5745c00e24..18c8952b846 100644 --- a/apps/desktop/src/components/pet/floating-pet.tsx +++ b/apps/desktop/src/components/pet/floating-pet.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' 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 +11,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 +105,9 @@ 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 [position, setPosition] = useState(loadPosition) const containerRef = useRef(null) @@ -367,6 +371,33 @@ 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, + 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 +437,14 @@ export function FloatingPet() { />
- +
) From 4da744ef9b6182982684c6b46bd5f41569e907ee Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 29 Jun 2026 14:26:02 -0500 Subject: [PATCH 5/8] feat(desktop): let the pet perch on the status bar and profile rail Tag both bars with data-slots; the roam loop stands on the status bar's top edge (not over it) and treats the profile rail as a climbable ledge. --- apps/desktop/src/app/chat/sidebar/profile-switcher.tsx | 2 +- apps/desktop/src/app/shell/statusbar-controls.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index 612305b1479..100ad8001e4 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -200,7 +200,7 @@ export function ProfileRail() { }, [createRequest]) return ( -
+
{/* 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. */} diff --git a/apps/desktop/src/app/shell/statusbar-controls.tsx b/apps/desktop/src/app/shell/statusbar-controls.tsx index ca0fbb5b645..9a9f0980884 100644 --- a/apps/desktop/src/app/shell/statusbar-controls.tsx +++ b/apps/desktop/src/app/shell/statusbar-controls.tsx @@ -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 From b72c9e1b2c9f7c60520d6b411f61796d1a0931e1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 29 Jun 2026 14:26:02 -0500 Subject: [PATCH 6/8] feat(desktop): add pet roam opt-in toggle + i18n --- .../desktop/src/app/settings/pet-settings.tsx | 23 ++++++++++++++++++- apps/desktop/src/i18n/en.ts | 2 ++ apps/desktop/src/i18n/ja.ts | 2 ++ apps/desktop/src/i18n/types.ts | 2 ++ apps/desktop/src/i18n/zh-hant.ts | 2 ++ apps/desktop/src/i18n/zh.ts | 2 ++ 6 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/settings/pet-settings.tsx b/apps/desktop/src/app/settings/pet-settings.tsx index ba4c10f5224..1ee2dc4070f 100644 --- a/apps/desktop/src/app/settings/pet-settings.tsx +++ b/apps/desktop/src/app/settings/pet-settings.tsx @@ -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(null) const [renameTarget, setRenameTarget] = useState(null) @@ -279,6 +280,26 @@ export function PetSettings() { title={copy.scaleTitle} /> )} + + {enabled && ( + { + 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} + /> + )}
Date: Mon, 29 Jun 2026 14:47:37 -0500 Subject: [PATCH 7/8] =?UTF-8?q?feat(desktop):=20ground=20the=20roaming=20p?= =?UTF-8?q?et=20=E2=80=94=20sprite-paced=20walk=20+=20feet=20on=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walk speed is derived from the sprite's animation loop + on-screen size (one body-width per loop) instead of a fixed px/s, so it steps rather than glides; the pet also sinks a few px so its feet meet the surface instead of hovering. --- .../src/components/pet/floating-pet.tsx | 1 + .../src/components/pet/use-pet-roam.ts | 21 ++++++++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/components/pet/floating-pet.tsx b/apps/desktop/src/components/pet/floating-pet.tsx index 18c8952b846..87c9026ed4e 100644 --- a/apps/desktop/src/components/pet/floating-pet.tsx +++ b/apps/desktop/src/components/pet/floating-pet.tsx @@ -390,6 +390,7 @@ export function FloatingPet() { containerRef, enabled: roamEnabled && active && !overlayActive && atRest, isInteracting: isDragging, + loopMs: info.loopMs ?? 1100, petH, petW }) diff --git a/apps/desktop/src/components/pet/use-pet-roam.ts b/apps/desktop/src/components/pet/use-pet-roam.ts index c79cc32ee16..aff24b5b7bf 100644 --- a/apps/desktop/src/components/pet/use-pet-roam.ts +++ b/apps/desktop/src/components/pet/use-pet-roam.ts @@ -28,7 +28,10 @@ const PERCH_SELECTORS = ['[data-slot="composer-surface"]', '[data-slot="profile- // the bar rather than covering it. const FLOOR_BAR_SELECTOR = '[data-slot="statusbar"]' -const WALK_SPEED_PX_S = 58 +// 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. @@ -44,6 +47,9 @@ const HOP_CHANCE = 0.45 // 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 @@ -118,6 +124,8 @@ interface PetRoamOptions { isInteracting: () => boolean petW: number petH: number + /** Sprite animation loop duration (ms) — paces the walk to the leg cadence. */ + loopMs: number /** Persist the resting position back to React state when the loop settles. */ commit: (point: Point) => void } @@ -143,7 +151,7 @@ interface PetRoamOptions { * 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, commit }: PetRoamOptions): void { +export function usePetRoam({ enabled, containerRef, isInteracting, petW, petH, loopMs, commit }: PetRoamOptions): void { useEffect(() => { if (!enabled) { $petMotion.set(null) @@ -158,7 +166,10 @@ export function usePetRoam({ enabled, containerRef, isInteracting, petW, petH, c return } - const groundTop = (ledge: Ledge): number => ledge.y - petH + // 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 @@ -330,7 +341,7 @@ export function usePetRoam({ enabled, containerRef, isInteracting, petW, petH, c case 'walk': { const remaining = walkTargetX - cur.x - const stepDist = WALK_SPEED_PX_S * dt + const stepDist = walkSpeedPxS * dt if (Math.abs(remaining) <= Math.max(ARRIVE_EPS, stepDist)) { cur.x = walkTargetX @@ -403,5 +414,5 @@ export function usePetRoam({ enabled, containerRef, isInteracting, petW, petH, c // the loop stops re-asserting it. commit({ ...cur }) } - }, [enabled, petW, petH, containerRef, isInteracting, commit]) + }, [enabled, petW, petH, loopMs, containerRef, isInteracting, commit]) } From a1e699ae55085416924df00b9105cc1764f1fa06 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 29 Jun 2026 14:57:26 -0500 Subject: [PATCH 8/8] feat(desktop): roaming pet patrols the base of an open overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a full-screen route overlay (settings/profiles/cron/agents/command-center) is up, the pet's walkable surface swaps to a single ledge at the overlay card's bottom edge — derived from OverlayView's shared inset, not measured — so it patrols there; closing the overlay restores the normal surfaces and it drops back down. --- .../src/components/pet/floating-pet.tsx | 3 ++ .../src/components/pet/use-pet-roam.ts | 41 +++++++++++++++---- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/components/pet/floating-pet.tsx b/apps/desktop/src/components/pet/floating-pet.tsx index 87c9026ed4e..2bc9512ecee 100644 --- a/apps/desktop/src/components/pet/floating-pet.tsx +++ b/apps/desktop/src/components/pet/floating-pet.tsx @@ -2,6 +2,7 @@ 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 { $petAtRest, $petInfo, $petRoam, $petRoamDir, clearPetUnread, type PetInfo, petProfile, setPetInfo } from '@/store/pet' import { resetPetGallery, setPetScale } from '@/store/pet-gallery' @@ -108,6 +109,7 @@ export function FloatingPet() { const roamEnabled = useStore($petRoam) const atRest = useStore($petAtRest) const roamDir = useStore($petRoamDir) + const routeOverlayOpen = useRouteOverlayActive() const [position, setPosition] = useState(loadPosition) const containerRef = useRef(null) @@ -391,6 +393,7 @@ export function FloatingPet() { enabled: roamEnabled && active && !overlayActive && atRest, isInteracting: isDragging, loopMs: info.loopMs ?? 1100, + overlayOpen: routeOverlayOpen, petH, petW }) diff --git a/apps/desktop/src/components/pet/use-pet-roam.ts b/apps/desktop/src/components/pet/use-pet-roam.ts index aff24b5b7bf..bd4f416180b 100644 --- a/apps/desktop/src/components/pet/use-pet-roam.ts +++ b/apps/desktop/src/components/pet/use-pet-roam.ts @@ -1,5 +1,6 @@ import { type RefObject, useEffect } from 'react' +import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar' import { $petMotion, $petRoamDir, type PetState } from '@/store/pet' interface Point { @@ -126,6 +127,8 @@ interface PetRoamOptions { 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 } @@ -151,7 +154,16 @@ interface PetRoamOptions { * 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, commit }: PetRoamOptions): void { +export function usePetRoam({ + enabled, + containerRef, + isInteracting, + petW, + petH, + loopMs, + overlayOpen, + commit +}: PetRoamOptions): void { useEffect(() => { if (!enabled) { $petMotion.set(null) @@ -281,13 +293,26 @@ export function usePetRoam({ enabled, containerRef, isInteracting, petW, petH, l return best ?? ledges[0]! } - const planNext = () => { - const ledges = snapshotLedges(petW, petH) - curLedge = resolveLedge(ledges) - const grounded = Math.abs(cur.y - groundTop(curLedge)) <= GROUND_EPS + // 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 - if (!grounded) { - // Dragged into the air (or a perch vanished): fall to the surface below. + 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 @@ -414,5 +439,5 @@ export function usePetRoam({ enabled, containerRef, isInteracting, petW, petH, l // the loop stops re-asserting it. commit({ ...cur }) } - }, [enabled, petW, petH, loopMs, containerRef, isInteracting, commit]) + }, [enabled, petW, petH, loopMs, overlayOpen, containerRef, isInteracting, commit]) }