Skip to content
2 changes: 1 addition & 1 deletion apps/desktop/src/app/chat/sidebar/profile-switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */}
Expand Down
23 changes: 22 additions & 1 deletion apps/desktop/src/app/settings/pet-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/shell/statusbar-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 44 additions & 4 deletions apps/desktop/src/components/pet/floating-pet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@ 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'
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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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>
)
Expand Down
43 changes: 42 additions & 1 deletion apps/desktop/src/components/pet/pet-sprite.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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. */
Expand Down
Loading
Loading