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/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} + /> + )}
{/* `overflow-x-clip` (not `overflow-x-auto`) so a wide status item — for diff --git a/apps/desktop/src/components/pet/floating-pet.tsx b/apps/desktop/src/components/pet/floating-pet.tsx index a5745c00e24..2bc9512ecee 100644 --- a/apps/desktop/src/components/pet/floating-pet.tsx +++ b/apps/desktop/src/components/pet/floating-pet.tsx @@ -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(loadPosition) const containerRef = useRef(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() { />
- +
) 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. */ 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..bd4f416180b --- /dev/null +++ b/apps/desktop/src/components/pet/use-pet-roam.ts @@ -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 + /** 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]) +} diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 7af28e24abf..0814377899b 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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…', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 2959c14bfb2..195f478c824 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -319,6 +319,8 @@ export const ja = defineLocale({ 'ペット機能には再起動が必要です。この機能が追加される前に起動したアプリが動作中です。Hermes を終了して再度開き、このページに戻ってください。', scaleTitle: 'サイズ', scaleDesc: '浮遊マスコットの大きさを変更します。すべての画面に即時反映されます。', + roamTitle: '散歩', + roamDesc: 'アイドル中にペットがウィンドウ内を自由に歩き回ります。', on: 'オン', off: 'オフ', chooseTitle: 'ペットを選ぶ', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index de939e8dc42..ed6bf882323 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -331,6 +331,8 @@ export interface Translations { off: string scaleTitle: string scaleDesc: string + roamTitle: string + roamDesc: string chooseTitle: string chooseDesc: string searchPlaceholder: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 4b265e3a803..5b9819c9817 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -308,6 +308,8 @@ export const zhHant = defineLocale({ restartHint: '寵物功能需要重新啟動——目前執行的應用在此功能加入前啟動。請結束並重新開啟 Hermes,然後回到此處。', scaleTitle: '大小', scaleDesc: '調整懸浮寵物的大小,所有介面即時生效。', + roamTitle: '漫遊', + roamDesc: '閒置時讓寵物自己在視窗內四處走動。', on: '開啟', off: '關閉', chooseTitle: '選擇寵物', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index c15396d6c46..0c73a3239d2 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -399,6 +399,8 @@ export const zh: Translations = { restartHint: '宠物功能需要重启——当前运行的应用在此功能加入前启动。请退出并重新打开 Hermes,然后回到此处。', scaleTitle: '大小', scaleDesc: '调整悬浮宠物的大小,所有界面即时生效。', + roamTitle: '漫游', + roamDesc: '空闲时让宠物自己在窗口内四处走动。', on: '开启', off: '关闭', chooseTitle: '选择宠物', 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 })