From 422d9da9bd6468881ed1fb7e1673141315218a66 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 10 Jul 2026 05:41:59 -0500 Subject: [PATCH 1/5] feat(agent): core affection reaction detector + reaction_callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a token-free, curated affection matcher (agent/reactions.py) — the single source of truth for detecting user "vibes" (ily / <3 / good bot / heart emoji). No model call, no tokens. Generalized to return a reaction *kind* so future reactions can ride the same signal. Wire an opt-in AIAgent.reaction_callback that fires from build_turn_context on the incoming user message. It never touches the conversation (cache-safe) and never fatal — a purely cosmetic side-beat each host can consume. --- agent/agent_init.py | 2 ++ agent/reactions.py | 56 +++++++++++++++++++++++++++++++++++ agent/turn_context.py | 14 +++++++++ run_agent.py | 2 ++ tests/agent/test_reactions.py | 56 +++++++++++++++++++++++++++++++++++ 5 files changed, 130 insertions(+) create mode 100644 agent/reactions.py create mode 100644 tests/agent/test_reactions.py diff --git a/agent/agent_init.py b/agent/agent_init.py index f1666a1aafb..0f9376d6c79 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -302,6 +302,7 @@ def init_agent( notice_callback: callable = None, notice_clear_callback: callable = None, event_callback: Optional[Callable[[str, dict], None]] = None, + reaction_callback: Optional[Callable[[str], None]] = None, max_tokens: int = None, reasoning_config: Dict[str, Any] = None, service_tier: str = None, @@ -535,6 +536,7 @@ def init_agent( agent.notice_callback = notice_callback agent.notice_clear_callback = notice_clear_callback agent.event_callback = event_callback + agent.reaction_callback = reaction_callback agent.tool_gen_callback = tool_gen_callback diff --git a/agent/reactions.py b/agent/reactions.py new file mode 100644 index 00000000000..375366ff709 --- /dev/null +++ b/agent/reactions.py @@ -0,0 +1,56 @@ +"""Token-free detection of user *reactions* to the agent. + +Currently the only reaction is ``vibe`` — an expression of affection or +gratitude toward the agent (``ily``, ``<3``, ``love you``, ``good bot``, a heart +emoji, …). Detection is a curated regex/lexicon: **no model call, no tokens**. + +This is the single source of truth shared by every surface — the CLI pet, the +TUI heart, and the desktop floating hearts all react off the same signal, +delivered via ``AIAgent.reaction_callback`` (wired per interactive host). + +Generalized on purpose: :func:`detect_reaction` returns a reaction *kind* +string, so new kinds (other emoji reactions, etc.) can be added here without +touching any caller. We match affection specifically — not general positive +sentiment — so "this is great" does NOT fire, but "good bot" / "❤️" do. +""" + +from __future__ import annotations + +import re + +#: The affection/gratitude reaction — the only kind today. +VIBE = "vibe" + +# Curated affection lexicon. Kept deliberately narrow: gratitude + love aimed at +# the agent, heart emoji, and ``<3`` (but not the broken heart `` str | None: + """Return the reaction kind for *text* (currently :data:`VIBE`), or ``None``. + + Pure, token-free, and safe to call on every user turn. + """ + if not text: + return None + + return VIBE if _VIBE_RE.search(text) else None diff --git a/agent/turn_context.py b/agent/turn_context.py index 4fd6ddff2c3..a5e738588e4 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -319,6 +319,20 @@ def build_turn_context( current_turn_user_idx = len(messages) - 1 agent._persist_user_message_idx = current_turn_user_idx + # Cosmetic side-signal: detect an affection "reaction" (ily / <3 / good bot) + # and notify the host so it can play hearts. Token-free, never touches the + # conversation, and never fatal — a purely optional UI beat. + reaction_callback = getattr(agent, "reaction_callback", None) + if reaction_callback is not None: + try: + from agent.reactions import detect_reaction + + kind = detect_reaction(original_user_message) + if kind: + reaction_callback(kind) + except Exception: + pass + if not agent.quiet_mode: _print_preview = summarize_user_message_for_log(user_message) agent._safe_print( diff --git a/run_agent.py b/run_agent.py index 61e8657b9a8..89c746235b5 100644 --- a/run_agent.py +++ b/run_agent.py @@ -458,6 +458,7 @@ class AIAgent: notice_callback: callable = None, notice_clear_callback: callable = None, event_callback: Optional[Callable[[str, dict], None]] = None, + reaction_callback: Optional[Callable[[str], None]] = None, max_tokens: int = None, reasoning_config: Dict[str, Any] = None, service_tier: str = None, @@ -533,6 +534,7 @@ class AIAgent: notice_callback=notice_callback, notice_clear_callback=notice_clear_callback, event_callback=event_callback, + reaction_callback=reaction_callback, max_tokens=max_tokens, reasoning_config=reasoning_config, service_tier=service_tier, diff --git a/tests/agent/test_reactions.py b/tests/agent/test_reactions.py new file mode 100644 index 00000000000..3b29b1facf5 --- /dev/null +++ b/tests/agent/test_reactions.py @@ -0,0 +1,56 @@ +"""Behavior tests for the token-free reaction detector.""" + +import pytest + +from agent.reactions import VIBE, detect_reaction + + +@pytest.mark.parametrize( + "text", + [ + "good bot", + "Good Bot!", + "ily", + "ilysm", + "i love you", + "love you", + "love u", + "luv ya", + "thanks", + "thank you", + "thx", + "ty", + "tysm", + "you're the best <3", + "here you go <33", + "❤️", + "🥰 amazing", + "sending 💖", + "great job, thank you so much!", + ], +) +def test_affection_fires_vibe(text): + assert detect_reaction(text) == VIBE + + +@pytest.mark.parametrize( + "text", + [ + "", + None, + "run the tests", + "this is great", # positive sentiment, NOT affection — must not fire + "awesome work on the refactor", + "it's broken Date: Fri, 10 Jul 2026 05:42:04 -0500 Subject: [PATCH 2/5] feat(gateway,cli): emit + consume the reaction signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tui_gateway forwards reaction_callback as a `reaction` event (shared by the TUI and the desktop app). The interactive CLI wires reaction_callback to flash the pet's celebrate ("jump") pose — the CLI's analogue of hearts. --- cli.py | 6 ++++++ hermes_cli/cli_agent_setup_mixin.py | 1 + tui_gateway/server.py | 3 +++ 3 files changed, 10 insertions(+) diff --git a/cli.py b/cli.py index 379ca15df1a..560b5d9a2e3 100644 --- a/cli.py +++ b/cli.py @@ -4862,6 +4862,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._pet_event = state self._pet_event_until = time.monotonic() + secs + def _on_reaction(self, kind: str) -> None: + """User affection (ily / <3 / good bot), core-detected — the pet's share + of the vibe signal that plays hearts on the TUI/desktop. Flash a celebrate.""" + if kind == "vibe": + self._pet_flash("jump") + def _pet_react_turn_end(self) -> None: """Flash the end-of-turn beat: failed on error, jump on a finished plan, else wave.""" if not self._pet_enabled: diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index a71d8835698..d3c967405ad 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -390,6 +390,7 @@ class CLIAgentSetupMixin: tool_gen_callback=self._on_tool_gen_start if self.streaming_enabled else None, notice_callback=self._on_notice, notice_clear_callback=self._on_notice_clear, + reaction_callback=self._on_reaction, ) # Store reference for atexit memory provider shutdown. # NOTE: this MUST write to the ``cli`` module's global, not a diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 8e825a22669..b9cc8e478ec 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3860,6 +3860,9 @@ def _agent_cbs(sid: str) -> dict: "tool_gen_callback": lambda name: _tool_progress_enabled(sid) and _emit("tool.generating", sid, {"name": name}), "thinking_callback": lambda text: _emit("thinking.delta", sid, {"text": text}), + # Affection reaction (ily / <3 / good bot) → hearts. Core-detected, so + # the TUI heart and desktop floating hearts share one signal. + "reaction_callback": lambda kind: _emit("reaction", sid, {"kind": kind}), "reasoning_callback": lambda text: _emit( "reasoning.delta", sid, From fbefb5c07551798bf2e4f73ca158bfb4674fca23 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 10 Jul 2026 05:42:08 -0500 Subject: [PATCH 3/5] refactor(tui): drive the vibe heart from the core reaction event Replace the client-side GOOD_VIBES_RE detection with the backend `reaction` event: on it, flash the status-bar heart and the pet's celebrate pose. Detection now lives once in the core, so the TUI, CLI, and desktop stay in sync. --- ui-tui/src/__tests__/submissionCore.test.ts | 1 - ui-tui/src/app/createGatewayEventHandler.ts | 10 +++++++++- ui-tui/src/app/petFlashStore.ts | 7 +++++++ ui-tui/src/app/submissionCore.ts | 2 -- ui-tui/src/app/useMainApp.ts | 12 +++--------- ui-tui/src/app/useSubmission.ts | 5 +---- ui-tui/src/gatewayTypes.ts | 1 + 7 files changed, 21 insertions(+), 17 deletions(-) diff --git a/ui-tui/src/__tests__/submissionCore.test.ts b/ui-tui/src/__tests__/submissionCore.test.ts index 83b89a088c8..23b83e87468 100644 --- a/ui-tui/src/__tests__/submissionCore.test.ts +++ b/ui-tui/src/__tests__/submissionCore.test.ts @@ -38,7 +38,6 @@ function makeDeps(gw: GatewayClient, over: Partial = {}): Subm enqueue: vi.fn(), expand: (t: string) => t, gw, - maybeGoodVibes: vi.fn(), setLastUserMsg: vi.fn(), sys: vi.fn(), ...over diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index d9cbf30663e..5bb819790d4 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -20,7 +20,7 @@ import type { Msg, SubagentProgress, SubagentStatus } from '../types.js' import { applyDelegationStatus, getDelegationState } from './delegationStore.js' import type { GatewayEventHandlerContext } from './interfaces.js' import { getOverlayState, patchOverlayState } from './overlayStore.js' -import { flashPet } from './petFlashStore.js' +import { flashGoodVibes, flashPet } from './petFlashStore.js' import { turnController } from './turnController.js' import { getTurnState } from './turnStore.js' import { getUiState, patchUiState } from './uiStore.js' @@ -717,6 +717,14 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return + case 'reaction': + // Core-detected affection (ily / <3 / good bot): flash the ♥ and let the + // pet celebrate. Same signal drives the desktop's floating hearts. + flashGoodVibes() + flashPet('jump') + + return + case 'tool.start': turnController.recordTodos(ev.payload.todos) turnController.recordToolStart( diff --git a/ui-tui/src/app/petFlashStore.ts b/ui-tui/src/app/petFlashStore.ts index b1ecf97e995..48841a3c4be 100644 --- a/ui-tui/src/app/petFlashStore.ts +++ b/ui-tui/src/app/petFlashStore.ts @@ -14,6 +14,13 @@ export const $petFlash = atom(null) export const flashPet = (state: PetState, ms = 1600) => $petFlash.set({ state, until: Date.now() + ms }) +// Affection-heart beat: a monotonic tick the status-bar ♥ flashes on. Bumped by +// the gateway `reaction` event (core-detected ily / <3 / good bot) — the TUI's +// share of the same signal that plays the desktop's floating hearts. +export const $goodVibesTick = atom(0) + +export const flashGoodVibes = () => $goodVibesTick.set($goodVibesTick.get() + 1) + // The floating pet's footprint, or null when no pet is shown. The transcript // keeps its text clear of the pet responsively: on wide terminals it reserves a // right gutter (`width`) so lines wrap to the pet's LEFT; on narrow terminals it diff --git a/ui-tui/src/app/submissionCore.ts b/ui-tui/src/app/submissionCore.ts index 7c561b745ba..0ec3921bb3f 100644 --- a/ui-tui/src/app/submissionCore.ts +++ b/ui-tui/src/app/submissionCore.ts @@ -15,7 +15,6 @@ export interface SubmitPromptDeps { enqueue: (text: string) => void expand: (text: string) => string gw: GatewayClient - maybeGoodVibes: (text: string) => void setLastUserMsg: (value: string) => void sys: (text: string) => void } @@ -61,7 +60,6 @@ export function submitPrompt(text: string, deps: SubmitPromptDeps, showUserMessa } turnController.clearStatusTimer() - deps.maybeGoodVibes(submitText) deps.setLastUserMsg(text) if (show) { diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 33ab9b2f3e3..50a94c9d45a 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -37,6 +37,7 @@ import { planGatewayRecovery } from './gatewayRecovery.js' import { getInputSelection } from './inputSelectionStore.js' import { type GatewayRpc, type TranscriptRow } from './interfaces.js' import { $overlayState, patchOverlayState } from './overlayStore.js' +import { $goodVibesTick } from './petFlashStore.js' import { scrollWithSelectionBy } from './scroll.js' import { turnController } from './turnController.js' import { patchTurnState, useTurnSelector } from './turnStore.js' @@ -48,7 +49,6 @@ import { useLongRunToolCharms } from './useLongRunToolCharms.js' import { useSessionLifecycle } from './useSessionLifecycle.js' import { useSubmission } from './useSubmission.js' -const GOOD_VIBES_RE = /\b(good bot|thanks|thank you|thx|ty|ily|love you)\b/i const BRACKET_PASTE_ON = '\x1b[?2004h' const BRACKET_PASTE_OFF = '\x1b[?2004l' const MAX_HEIGHT_CACHE_BUCKETS = 12 @@ -185,7 +185,8 @@ export function useMainApp(gw: GatewayClient) { const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now()) const [turnStartedAt, setTurnStartedAt] = useState(null) const [lastTurnEndedAt, setLastTurnEndedAt] = useState(null) - const [goodVibesTick, setGoodVibesTick] = useState(0) + // Bumped by the gateway `reaction` event (core-detected affection). + const goodVibesTick = useStore($goodVibesTick) const [bellOnComplete, setBellOnComplete] = useState(false) const ui = useStore($uiState) @@ -445,12 +446,6 @@ export function useMainApp(gw: GatewayClient) { [sys] ) - const maybeGoodVibes = useCallback((text: string) => { - if (GOOD_VIBES_RE.test(text)) { - setGoodVibesTick(v => v + 1) - } - }, []) - const rpc: GatewayRpc = useCallback( async = Record>( method: string, @@ -690,7 +685,6 @@ export function useMainApp(gw: GatewayClient) { composerRefs, composerState, gw, - maybeGoodVibes, setLastUserMsg, slashRef, submitRef, diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index 6ece0bf6412..6e02d349a1d 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -37,7 +37,6 @@ export function useSubmission(opts: UseSubmissionOptions) { composerRefs, composerState, gw, - maybeGoodVibes, setLastUserMsg, slashRef, submitRef, @@ -87,14 +86,13 @@ export function useSubmission(opts: UseSubmissionOptions) { enqueue: composerActions.enqueue, expand, gw, - maybeGoodVibes, setLastUserMsg, sys }, showUserMessage ) }, - [appendMessage, composerActions, composerState.pasteSnips, gw, maybeGoodVibes, setLastUserMsg, sys] + [appendMessage, composerActions, composerState.pasteSnips, gw, setLastUserMsg, sys] ) const shellExec = useCallback( @@ -362,7 +360,6 @@ export interface UseSubmissionOptions { composerRefs: ComposerRefs composerState: ComposerState gw: GatewayClient - maybeGoodVibes: (text: string) => void setLastUserMsg: (value: string) => void slashRef: MutableRefObject<(cmd: string) => boolean> submitRef: MutableRefObject<(value: string) => void> diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index ee6b8d78c45..6eb0317fc2f 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -618,6 +618,7 @@ export type GatewayEvent = | { payload?: GatewaySkin; session_id?: string; type: 'skin.changed' } | { payload: SessionInfo; session_id?: string; type: 'session.info' } | { payload?: { text?: string }; session_id?: string; type: 'thinking.delta' } + | { payload?: { kind?: string }; session_id?: string; type: 'reaction' } | { payload?: undefined; session_id?: string; type: 'message.start' } | { payload?: { kind?: string; text?: string }; session_id?: string; type: 'status.update' } | { From 3aaf7e3876a274c8dded71a376fbcb4cfe3c6af6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 10 Jul 2026 05:42:14 -0500 Subject: [PATCH 4/5] 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 From 1fa3886bcebe67779f03bb1756cff978ac16f81f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 10 Jul 2026 16:09:23 -0500 Subject: [PATCH 5/5] chore(desktop): remove the DEV Shift+H heart preview The real trigger (core `reaction` event on affectionate messages) is live, so drop the dev-only hotkey and its always-mounted listener. --- apps/desktop/src/app/chat/index.tsx | 2 +- .../src/components/chat/vibe-hearts.tsx | 33 +++---------------- .../src/components/pet/floating-pet.tsx | 6 +--- 3 files changed, 7 insertions(+), 34 deletions(-) diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index b9f0b1a75c2..fb8175102b0 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -499,7 +499,7 @@ 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. */} + they play on the pet). Fired by the core `reaction` event. */} {!petPresent && ( emitter.burst(count) /** - * Fire a vibe burst from anywhere (hotkey, future `ily`/`<3` wiring). Routes to - * where the affection should land: + * 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) @@ -75,29 +75,6 @@ export const burstVibeHearts = (count?: number) => { } } -/** 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 diff --git a/apps/desktop/src/components/pet/floating-pet.tsx b/apps/desktop/src/components/pet/floating-pet.tsx index 3d89c57c460..399ed1bf485 100644 --- a/apps/desktop/src/components/pet/floating-pet.tsx +++ b/apps/desktop/src/components/pet/floating-pet.tsx @@ -4,7 +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 { PetHeartField } from '@/components/chat/vibe-hearts' import { persistString, storedString } from '@/lib/storage' import { $petAtRest, @@ -121,10 +121,6 @@ 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