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