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.
This commit is contained in:
Brooklyn Nicholson 2026-07-10 05:42:14 -05:00
parent fbefb5c075
commit 3aaf7e3876
9 changed files with 553 additions and 2 deletions

View file

@ -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({
</div>
)}
{showChatBar && <ScrollToBottomButton />}
{/* 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 && (
<HeartField
className="absolute inset-x-0 z-30"
config={COMPOSER_HEART_CONFIG}
style={{
top: 0,
bottom: 'calc(var(--composer-measured-height) + var(--status-stack-measured-height) + 0.25rem)'
}}
/>
)}
<ChatDropOverlay kind={dragKind} />
<ChatSwapOverlay profile={gatewaySwapTarget} />
</div>

View file

@ -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<PetZoomAnchor | null>(null)
const petRef = useRef<HTMLDivElement | null>(null)
const inputRef = useRef<HTMLInputElement | null>(null)
// Last mirrored reaction id — a bump means the main window fired a reaction.
const lastReactionRef = useRef<number | null>(null)
const ignoreRef = useRef(true)
const composerOpenRef = useRef(false)
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | 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() {
<div style={{ lineHeight: 0, position: 'relative' }}>
<PetSprite info={info} />
{/* Hearts on the popped-out pet — identical to in-window. */}
<PetHeartField
petH={(info.frameH ?? DEFAULT_FRAME_H) * (info.scale ?? DEFAULT_SCALE)}
petW={(info.frameW ?? DEFAULT_FRAME_W) * (info.scale ?? DEFAULT_SCALE)}
/>
{/* 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);

View file

@ -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))

View file

@ -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<ParticleFieldConfig> = {
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 feethead, so
* rise 100% carries hearts from the feet to ~10-20% above the pet before fading. */
const PET_HEART_CONFIG: Partial<ParticleFieldConfig> = {
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 = (
<svg fill="none" shapeRendering="crispEdges" viewBox="0 0 14 12" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.2 0v5.65714h-1.8857v1.88572H9.42857v1.88571H7.54286v1.88573H5.65714V9.42857H3.77143V7.54286H1.88571V5.65714H0V0h5.65714v1.88571h1.88572V0z"
fill="currentColor"
/>
</svg>
)
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<ParticleFieldConfig>
className?: string
style?: CSSProperties
}
/** Heart-skinned particle field. Caller supplies placement + a config preset. */
export function HeartField({ config, className, style }: HeartFieldProps) {
return (
<ParticleField
className={className}
colors={HEART_COLORS}
config={config}
emitter={emitter}
glyph={HEART_GLYPH}
style={style}
/>
)
}
/**
* 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 (
<HeartField
config={PET_HEART_CONFIG}
style={{
bottom: 0,
height: Math.max(96, petH),
left: '50%',
pointerEvents: 'none',
position: 'absolute',
transform: 'translateX(-50%)',
width: Math.max(90, petW * 1.5),
zIndex: 2
}}
/>
)
}

View file

@ -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 leftright 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);
}
}
}

View file

@ -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<ParticleFieldConfig>
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<Particle[]>([])
const timers = useRef<Set<ReturnType<typeof setTimeout>>>(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 (
<div aria-hidden className={cn('particle-field', className)} style={style}>
{particles.map(p => (
<span
className="particle"
key={p.id}
// Retire on the RISE track only (sway is infinite, pop is shorter).
onAnimationEnd={e => {
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
}
>
<span className="particle__sway">
<span className="particle__glyph">{glyph}</span>
</span>
</span>
))}
</div>
)
}

View file

@ -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<Point>(loadPosition)
const containerRef = useRef<HTMLDivElement | null>(null)
// The facing mirror lives on the sprite wrapper, not the container, so the
@ -447,6 +452,9 @@ export function FloatingPet() {
>
<PetSprite info={info} rowOverride={walk.row} />
</div>
{/* Hearts puff off the pet; its celebrate ("yay"/jump) pose is driven by
burstVibeHearts's router. */}
<PetHeartField petH={petH} petW={petW} />
</div>
)
}

View file

@ -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<PetReaction | null>(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)
]
}

View file

@ -92,6 +92,9 @@ export function derivePetState(activity: PetActivity): PetState {
export const $petInfo = atom<PetInfo>({ enabled: false })
export const $petActivity = atom<PetActivity>({})
/** 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