From 3aaf7e3876a274c8dded71a376fbcb4cfe3c6af6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 10 Jul 2026 05:42:14 -0500 Subject: [PATCH] feat(desktop): TikTok-style vibe hearts on a reusable particle system Add a glyph-agnostic ParticleField (float-up + organic sway/bank + springy pop-in), skinned as pink pixel hearts. Hearts play on the pet when one is out (in-window or popped out) and celebrate alongside; otherwise they rise from the composer. A generic $petReaction bus mirrors the burst to the pop-out overlay window so it reacts even while the app is minimized. Consume the core `reaction` event to fire hearts on affectionate messages. DEV Shift+H previews a burst. --- apps/desktop/src/app/chat/index.tsx | 19 ++ .../src/app/pet-overlay/pet-overlay-app.tsx | 22 ++ .../hooks/use-message-stream/gateway-event.ts | 7 + .../src/components/chat/vibe-hearts.tsx | 142 ++++++++++++ .../components/particles/particle-field.css | 126 +++++++++++ .../components/particles/particle-field.tsx | 204 ++++++++++++++++++ .../src/components/pet/floating-pet.tsx | 8 + apps/desktop/src/store/pet-overlay.ts | 24 ++- apps/desktop/src/store/pet.ts | 3 + 9 files changed, 553 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/components/chat/vibe-hearts.tsx create mode 100644 apps/desktop/src/components/particles/particle-field.css create mode 100644 apps/desktop/src/components/particles/particle-field.tsx diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index bece9b49152..b9f0b1a75c2 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -12,6 +12,7 @@ import { useLocation } from 'react-router-dom' import { Thread } from '@/components/assistant-ui/thread' import { Backdrop } from '@/components/Backdrop' +import { COMPOSER_HEART_CONFIG, HeartField } from '@/components/chat/vibe-hearts' import { PromptOverlays } from '@/components/prompt-overlays' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' @@ -30,6 +31,8 @@ import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-s import { cn } from '@/lib/utils' import type { ComposerAttachment } from '@/store/composer' import { $pinnedSessionIds } from '@/store/layout' +import { $petActive } from '@/store/pet' +import { $petOverlayActive } from '@/store/pet-overlay' import { $gatewaySwapTarget } from '@/store/profile' import { $activeSessionId, @@ -297,6 +300,10 @@ export function ChatView({ const currentCwd = useStore($currentCwd) const currentModel = useStore($currentModel) const currentProvider = useStore($currentProvider) + // A pet anywhere (in-window or popped out) owns the hearts; composer only when none. + const petActive = useStore($petActive) + const petOverlayActive = useStore($petOverlayActive) + const petPresent = petActive || petOverlayActive const freshDraftReady = useStore($freshDraftReady) const gatewayState = useStore($gatewayState) const gatewaySwapTarget = useStore($gatewaySwapTarget) @@ -491,6 +498,18 @@ export function ChatView({ )} {showChatBar && } + {/* Vibe hearts rise from the composer only when no pet is out (else + they play on the pet). DEV Shift+H to preview; not wired to ily/<3 yet. */} + {!petPresent && ( + + )} diff --git a/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx index ae3c1860b7c..d635186e34e 100644 --- a/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx +++ b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx @@ -1,6 +1,7 @@ import { useStore } from '@nanostores/react' import { useCallback, useEffect, useRef, useState } from 'react' +import { PetHeartField, playVibeHearts } from '@/components/chat/vibe-hearts' import { PetBubble } from '@/components/pet/pet-bubble' import { PetSprite } from '@/components/pet/pet-sprite' import { type PetZoomAnchor, usePetZoomGesture } from '@/components/pet/use-pet-zoom-gesture' @@ -72,6 +73,8 @@ export function PetOverlayApp() { const zoomAnchorRef = useRef(null) const petRef = useRef(null) const inputRef = useRef(null) + // Last mirrored reaction id — a bump means the main window fired a reaction. + const lastReactionRef = useRef(null) const ignoreRef = useRef(true) const composerOpenRef = useRef(false) const clickTimerRef = useRef | undefined>(undefined) @@ -91,6 +94,19 @@ export function PetOverlayApp() { setBusy(Boolean(payload.busy)) setAwaitingResponse(Boolean(payload.awaiting)) setUnread(Boolean(payload.unread)) + + // Play a reaction on a new id (ignore the first sync, which just primes it). + const reaction = payload.reaction ?? null + + if (lastReactionRef.current === null) { + lastReactionRef.current = reaction?.id ?? 0 + } else if (reaction && reaction.id > lastReactionRef.current) { + lastReactionRef.current = reaction.id + + if (reaction.kind === 'vibe') { + playVibeHearts() + } + } }) // Tell the main renderer we're mounted so it pushes the current frame (the @@ -416,6 +432,12 @@ export function PetOverlayApp() {
+ {/* Hearts on the popped-out pet — identical to in-window. */} + + {/* Mail icon: only when a finish landed while you were away. Jumps to the app's most recent thread. Anchored to the sprite (kept inside its box so the overlay's click-through hit-test still catches it); diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index f057bca4f24..664e4b9d53e 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -4,6 +4,7 @@ import { type MutableRefObject, useCallback } from 'react' import { writeAgentTerminalChunk } from '@/app/right-sidebar/terminal/agent-terminal-stream' import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer' import { closeAgentTerminalByProc } from '@/app/right-sidebar/terminal/terminals' +import { burstVibeHearts } from '@/components/chat/vibe-hearts' import { translateNow } from '@/i18n' import { type GatewayEventPayload, textPart } from '@/lib/chat-messages' import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime' @@ -264,6 +265,12 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { // KawaiiSpinner), not real reasoning. The bottom-of-thread loading // indicator already covers that UX, so we ignore these events to // avoid a duplicative "Thinking" disclosure showing spinner text. + } else if (event.type === 'reaction') { + // Core-detected affection (ily / <3 / good bot) on the user's message. + // Play hearts only for the visible session so background turns stay quiet. + if (isActiveEvent && (payload?.kind ?? 'vibe') === 'vibe') { + burstVibeHearts() + } } else if (event.type === 'reasoning.delta') { if (sessionId) { appendReasoningDelta(sessionId, coerceThinkingText(payload?.text)) diff --git a/apps/desktop/src/components/chat/vibe-hearts.tsx b/apps/desktop/src/components/chat/vibe-hearts.tsx new file mode 100644 index 00000000000..17b9a61351c --- /dev/null +++ b/apps/desktop/src/components/chat/vibe-hearts.tsx @@ -0,0 +1,142 @@ +import { type CSSProperties, useEffect } from 'react' + +import { + createParticleEmitter, + ParticleField, + type ParticleFieldConfig +} from '@/components/particles/particle-field' +import { $petActive, flashPetActivity } from '@/store/pet' +import { $petOverlayActive, forwardPetReaction } from '@/store/pet-overlay' + +/** + * TikTok-style floating hearts — a thin skin over {@link ParticleField} (pixel + * heart glyph + pink). Placed two ways: rising from the composer when no pet is + * out, or from the pet when one is. Not wired to vibes/`ily`/`<3` yet — call + * {@link burstVibeHearts} (or hit the DEV hotkey Shift+H). + */ + +// Light pink reads on both light and dark chat surfaces. +const HEART_COLORS = ['#ff9ec4'] as const + +/** Composer placement: hearts rise the thread height (rise = % of the tall lane). */ +export const COMPOSER_HEART_CONFIG: Partial = { + count: 12, + size: [6, 13], + rise: [6.75, 15.75], + duration: [320, 700] +} + +/** Pet placement: a compact puff off the pet. The field box spans feet→head, so + * rise ≥100% carries hearts from the feet to ~10-20% above the pet before fading. */ +const PET_HEART_CONFIG: Partial = { + count: 10, + spawnWindowMs: 450, + size: [6, 12], + rise: [98, 118], + duration: [480, 880], + swayAmp: [5, 14], + bank: [6, 14] +} + +// Pixel-art heart from @nous-research/ui (14×12), crisp + `currentColor`. +const HEART_GLYPH = ( + + + +) + +const emitter = createParticleEmitter() + +/** Play hearts in THIS window (whichever HeartField is mounted). The overlay + * window calls this directly off the mirrored vibe signal. */ +export const playVibeHearts = (count?: number) => emitter.burst(count) + +/** + * Fire a vibe burst from anywhere (hotkey, future `ily`/`<3` wiring). Routes to + * where the affection should land: + * - pet popped out → forward to the overlay window + celebrate (mirrored) + * - pet in-window → play here (on the pet) + celebrate + * - no pet → play here (composer) + */ +export const burstVibeHearts = (count?: number) => { + const overlay = $petOverlayActive.get() + + if (overlay || $petActive.get()) { + flashPetActivity({ celebrate: true }) + } + + if (overlay) { + forwardPetReaction('vibe') + } else { + playVibeHearts(count) + } +} + +/** DEV-only preview: Shift+H, firing even while the composer is focused. Mount + * once in an always-present component (FloatingPet) — a single listener. */ +export function useHeartPreviewHotkey() { + useEffect(() => { + if (!import.meta.env.DEV) { + return + } + + const onKeyDown = (e: KeyboardEvent) => { + if (!e.shiftKey || e.repeat || e.altKey || e.ctrlKey || e.metaKey || e.code !== 'KeyH') { + return + } + + e.preventDefault() + burstVibeHearts() + } + + window.addEventListener('keydown', onKeyDown) + + return () => window.removeEventListener('keydown', onKeyDown) + }, []) +} + +export interface HeartFieldProps { + config?: Partial + className?: string + style?: CSSProperties +} + +/** Heart-skinned particle field. Caller supplies placement + a config preset. */ +export function HeartField({ config, className, style }: HeartFieldProps) { + return ( + + ) +} + +/** + * Pet-anchored hearts, feet→~10-20% above. One place owns the geometry so the + * in-window pet and the popped-out overlay stay identical. `petW`/`petH` are the + * rendered sprite dimensions (frame × scale). + */ +export function PetHeartField({ petW, petH }: { petW: number; petH: number }) { + return ( + + ) +} diff --git a/apps/desktop/src/components/particles/particle-field.css b/apps/desktop/src/components/particles/particle-field.css new file mode 100644 index 00000000000..94bf2258aae --- /dev/null +++ b/apps/desktop/src/components/particles/particle-field.css @@ -0,0 +1,126 @@ +/* ── Particle field ───────────────────────────────────────────────────────── + Reusable float-up emitter. Three nested layers, each owning ONE transform so + they never fight: + .particle — full-height track: vertical rise + fade (linear) + .particle__sway — horizontal weave + bank/tilt on its OWN period + (ease-in-out, alternating), so the path desyncs from + the rise and never repeats — the organic-motion trick + .particle__glyph — one-shot springy pop-in scale + The track is full-height and anchored at the field bottom, so the rise's + `translateY(-rise%)` measures against the field height, not the glyph's. All + tuning arrives as inline `--particle-*` custom properties. */ +.particle-field { + pointer-events: none; + overflow: visible; +} + +.particle { + position: absolute; + inset-block: 0; + left: var(--particle-left); + display: flex; + align-items: flex-end; + justify-content: center; + width: var(--particle-size); + margin-left: calc(var(--particle-size) / -2); + will-change: transform, opacity; + animation: particle-rise var(--particle-duration) linear var(--particle-delay) both; +} + +.particle__sway { + display: block; + will-change: transform; + animation: particle-sway var(--particle-sway-duration) ease-in-out var(--particle-sway-delay) + infinite alternate; +} + +.particle__glyph { + display: block; + width: var(--particle-size); + color: var(--particle-color); + will-change: transform; + animation: particle-pop 260ms cubic-bezier(0.34, 1.56, 0.64, 1) var(--particle-delay) both; +} + +.particle__glyph > svg { + display: block; + width: 100%; + height: auto; +} + +/* Rise: straight up the field, holds opacity through most of the climb, fades + near the top. */ +@keyframes particle-rise { + 0% { + opacity: 0; + transform: translate3d(0, 0, 0); + } + + 6% { + opacity: 1; + transform: translate3d(0, calc(var(--particle-rise) * -0.06%), 0); + } + + 70% { + opacity: 1; + transform: translate3d(0, calc(var(--particle-rise) * -0.7%), 0); + } + + 100% { + opacity: 0; + transform: translate3d(0, calc(var(--particle-rise) * -1%), 0); + } +} + +/* Sway + bank: swings left↔right and tilts INTO the direction of travel, like a + leaf on a breeze. Runs on its own clock. */ +@keyframes particle-sway { + from { + transform: translateX(calc(var(--particle-sway) * -1)) rotate(calc(var(--particle-bank) * -1)); + } + + to { + transform: translateX(var(--particle-sway)) rotate(var(--particle-bank)); + } +} + +/* Pop: a springy scale-in overshoot (the bezier is a spring, not a path wobble). */ +@keyframes particle-pop { + 0% { + transform: scale(0.3); + } + + 100% { + transform: scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + .particle { + inset-block: auto 0; + height: var(--particle-size); + animation: particle-flash 650ms ease-out var(--particle-delay) both; + } + + .particle__sway, + .particle__glyph { + animation: none; + } + + @keyframes particle-flash { + 0% { + opacity: 0; + transform: translate3d(0, 0, 0) scale(0.6); + } + + 20% { + opacity: 1; + transform: translate3d(0, -0.5rem, 0) scale(1.1); + } + + 100% { + opacity: 0; + transform: translate3d(0, -1rem, 0) scale(1); + } + } +} diff --git a/apps/desktop/src/components/particles/particle-field.tsx b/apps/desktop/src/components/particles/particle-field.tsx new file mode 100644 index 00000000000..a7e70c933c6 --- /dev/null +++ b/apps/desktop/src/components/particles/particle-field.tsx @@ -0,0 +1,204 @@ +import './particle-field.css' + +import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react' + +import { cn } from '@/lib/utils' + +/** + * Reusable float-up particle emitter. It owns the motion (rise + organic sway + + * springy pop) and lifecycle (staggered burst, lifetime, cleanup); callers just + * hand it a `glyph` (any element using `currentColor`) and `colors`, then place + * it with `className` / `style`. See {@link VibeHearts} for the chat-hearts use. + */ + +type Range = readonly [min: number, max: number] + +const rand = ([min, max]: Range) => min + Math.random() * (max - min) +/** Sample a range along `t` (0→min, 1→max) — couples travel/lifetime to `life`. */ +const lerp = ([min, max]: Range, t: number) => min + (max - min) * t + +export interface ParticleFieldConfig { + /** Particles per burst when `burst()` is called without a count. */ + count: number + /** Window (ms) over which a burst releases its particles — small = one poof. */ + spawnWindowMs: number + /** Glyph edge size (px), uniform. */ + size: Range + /** Vertical travel before fade-out (% of field height), `life`-biased. */ + rise: Range + /** Rise duration (ms), `life`-biased so short-lived particles also rise less. */ + duration: Range + /** Sway amplitude each side of center (px), uniform. */ + swayAmp: Range + /** Peak tilt into the sway (deg), uniform. */ + bank: Range + /** Sway period (ms), uniform — independent of the rise so paths never repeat. */ + swayDuration: Range + /** Cap on simultaneously-alive particles. */ + maxAlive: number +} + +export const DEFAULT_PARTICLE_CONFIG: ParticleFieldConfig = { + count: 12, + spawnWindowMs: 550, + size: [6, 13], + rise: [6.75, 15.75], + duration: [320, 700], + swayAmp: [9, 24], + bank: [7, 16], + swayDuration: [1300, 2800], + maxAlive: 200 +} + +export interface ParticleEmitter { + /** Fire a burst (defaults to the field's configured `count`). */ + burst: (count?: number) => void + /** Internal: field subscription. */ + subscribe: (fn: (count?: number) => void) => () => void +} + +/** Create an emitter handle. `burst()` is safe to call from anywhere. */ +export function createParticleEmitter(): ParticleEmitter { + const listeners = new Set<(count?: number) => void>() + + return { + burst: count => listeners.forEach(fn => fn(count)), + subscribe: fn => { + listeners.add(fn) + + return () => void listeners.delete(fn) + } + } +} + +interface Particle { + id: number + leftPct: number + size: number + color: string + delayMs: number + durationMs: number + rise: number + swayAmp: number + bank: number + swayDurationMs: number + swayDelayMs: number +} + +let nextId = 1 + +function spawn(cfg: ParticleFieldConfig, colors: readonly string[]): Particle { + // Short-lived particles fade out lower; a few live longer and rise higher. + const life = Math.random() ** 1.7 + const swayDurationMs = Math.round(rand(cfg.swayDuration)) + + return { + id: nextId++, + // Spread edge to edge across the lane, not clustered near center. + leftPct: 4 + Math.random() * 92, + size: rand(cfg.size), + color: colors[Math.floor(Math.random() * colors.length)]!, + delayMs: Math.round(Math.random() * 120), + durationMs: Math.round(lerp(cfg.duration, life)), + rise: lerp(cfg.rise, life), + swayAmp: rand(cfg.swayAmp), + bank: rand(cfg.bank), + swayDurationMs, + // Negative delay drops each particle in mid-swing (desynced phases). + swayDelayMs: -Math.round(Math.random() * swayDurationMs) + } +} + +const prefersReducedMotion = () => + typeof window !== 'undefined' && Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) + +export interface ParticleFieldProps { + emitter: ParticleEmitter + /** Any element that paints with `currentColor` (SVG, glyph, …). */ + glyph: ReactNode + colors: readonly string[] + config?: Partial + className?: string + style?: CSSProperties +} + +export function ParticleField({ emitter, glyph, colors, config, className, style }: ParticleFieldProps) { + const cfg = useMemo(() => ({ ...DEFAULT_PARTICLE_CONFIG, ...config }), [config]) + const [particles, setParticles] = useState([]) + const timers = useRef>>(new Set()) + + useEffect(() => { + const pool = timers.current + const add = () => setParticles(prev => [...prev, spawn(cfg, colors)].slice(-cfg.maxAlive)) + + // Release a burst across a tight window so it reads as one poof, each node + // with its own random birth time; reduced motion gets a single flash. + const onBurst = (count?: number) => { + const n = Math.max(1, Math.min(cfg.maxAlive, Math.round(count ?? cfg.count))) + + if (prefersReducedMotion()) { + add() + + return + } + + for (let i = 0; i < n; i++) { + const timer = setTimeout(() => { + pool.delete(timer) + add() + }, Math.random() * cfg.spawnWindowMs) + + pool.add(timer) + } + } + + const unsubscribe = emitter.subscribe(onBurst) + + return () => { + unsubscribe() + pool.forEach(clearTimeout) + pool.clear() + } + }, [cfg, colors, emitter]) + + const remove = (id: number) => setParticles(prev => prev.filter(p => p.id !== id)) + + if (particles.length === 0) { + return null + } + + return ( +
+ {particles.map(p => ( + { + if (e.animationName === 'particle-rise' || e.animationName === 'particle-flash') { + remove(p.id) + } + }} + style={ + { + '--particle-left': `${p.leftPct}%`, + '--particle-size': `${p.size}px`, + '--particle-color': p.color, + '--particle-delay': `${p.delayMs}ms`, + '--particle-duration': `${p.durationMs}ms`, + '--particle-rise': p.rise, + '--particle-sway': `${p.swayAmp}px`, + '--particle-bank': `${p.bank}deg`, + '--particle-sway-duration': `${p.swayDurationMs}ms`, + '--particle-sway-delay': `${p.swayDelayMs}ms` + } as CSSProperties + } + > + + {glyph} + + + ))} +
+ ) +} diff --git a/apps/desktop/src/components/pet/floating-pet.tsx b/apps/desktop/src/components/pet/floating-pet.tsx index 280aa5d4f4b..3d89c57c460 100644 --- a/apps/desktop/src/components/pet/floating-pet.tsx +++ b/apps/desktop/src/components/pet/floating-pet.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' import { useOnProfileSwitch } from '@/app/hooks/use-on-profile-switch' import { useRouteOverlayActive } from '@/app/hooks/use-route-overlay-active' +import { PetHeartField, useHeartPreviewHotkey } from '@/components/chat/vibe-hearts' import { persistString, storedString } from '@/lib/storage' import { $petAtRest, @@ -120,6 +121,10 @@ export function FloatingPet() { const roamDir = useStore($petRoamDir) const routeOverlayOpen = useRouteOverlayActive() + // DEV Shift+H heart preview lives here — FloatingPet is always mounted + // (app-shell), so one listener covers every route whether a pet is out or not. + useHeartPreviewHotkey() + const [position, setPosition] = useState(loadPosition) const containerRef = useRef(null) // The facing mirror lives on the sprite wrapper, not the container, so the @@ -447,6 +452,9 @@ export function FloatingPet() { >
+ {/* Hearts puff off the pet; its celebrate ("yay"/jump) pose is driven by + burstVibeHearts's router. */} + ) } diff --git a/apps/desktop/src/store/pet-overlay.ts b/apps/desktop/src/store/pet-overlay.ts index cffa6b822b5..62fb3f4ba37 100644 --- a/apps/desktop/src/store/pet-overlay.ts +++ b/apps/desktop/src/store/pet-overlay.ts @@ -46,6 +46,8 @@ export interface PetOverlayStatePayload { awaiting: boolean /** Drives the overlay's mail icon: a finish landed while you were away. */ unread: boolean + /** Latest reaction — bumping its id forwards a burst to the overlay. */ + reaction: PetReaction | null } export type PetOverlayControl = @@ -67,6 +69,22 @@ export const $petOverlayActive = atom(storedBoolean(OVERLAY_ACTIVE_KEY, false)) // Persist the in/out choice so a popped-out pet comes back popped out. $petOverlayActive.subscribe(active => persistBoolean(OVERLAY_ACTIVE_KEY, active)) +/** + * Reaction signal forwarded to the popped-out overlay window via the state + * mirror below. `id` is a monotonic nonce so the overlay fires once per bump; + * `kind` selects the renderer (today only `vibe` → hearts). Generic on purpose + * so future reactions (emoji, etc.) ride the same channel. + */ +export interface PetReaction { + id: number + kind: string +} + +export const $petReaction = atom(null) + +export const forwardPetReaction = (kind: string) => + $petReaction.set({ id: ($petReaction.get()?.id ?? 0) + 1, kind }) + function loadSavedBounds(): null | PetOverlayBounds { try { const raw = storedString(OVERLAY_BOUNDS_KEY) @@ -129,7 +147,8 @@ function currentPayload(): PetOverlayStatePayload { activity: $petActivity.get(), busy: $busy.get(), awaiting: $awaitingResponse.get(), - unread: $petUnread.get() + unread: $petUnread.get(), + reaction: $petReaction.get() } } @@ -165,7 +184,8 @@ function openOverlay(request: PetOverlayOpenRequest): void { $petActivity.subscribe(pushNow), $busy.subscribe(pushNow), $awaitingResponse.subscribe(pushNow), - $petUnread.subscribe(pushNow) + $petUnread.subscribe(pushNow), + $petReaction.subscribe(pushNow) ] } diff --git a/apps/desktop/src/store/pet.ts b/apps/desktop/src/store/pet.ts index 1b189ac8291..b1e2e9d214e 100644 --- a/apps/desktop/src/store/pet.ts +++ b/apps/desktop/src/store/pet.ts @@ -92,6 +92,9 @@ export function derivePetState(activity: PetActivity): PetState { export const $petInfo = atom({ enabled: false }) export const $petActivity = atom({}) +/** Pet installed + enabled with a loaded spritesheet (ready to show/react). */ +export const $petActive = computed($petInfo, info => info.enabled && Boolean(info.spritesheetBase64)) + /** * Profile the pet RPCs should resolve against. Pets are per-profile — the active * pet (`display.pet.*`) and the installed sprites live under each profile's