+ {/* 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..b4f5e7e1a24
--- /dev/null
+++ b/apps/desktop/src/components/chat/vibe-hearts.tsx
@@ -0,0 +1,119 @@
+import { type CSSProperties } 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. Fired by the core `reaction` event (affection
+ * in a user message) via {@link burstVibeHearts}.
+ */
+
+// 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 the core `reaction` event). 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)
+ }
+}
+
+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..399ed1bf485 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 } from '@/components/chat/vibe-hearts'
import { persistString, storedString } from '@/lib/storage'
import {
$petAtRest,
@@ -447,6 +448,9 @@ export function FloatingPet() {
>
+ {/* Hearts puff off the pet; its celebrate ("yay"/jump) pose is driven by
+ burstVibeHearts's router. */}
+