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' import { normalizeLocale, useI18n } from '@/i18n' import { Mail } from '@/lib/icons' import { $petActivity, $petInfo, setPetInfo } from '@/store/pet' import { overlayWindowSize } from '@/store/pet-overlay' import { setAwaitingResponse, setBusy } from '@/store/session' // Fallbacks mirror pet-sprite's defaults; the gateway normally sends real values. const DEFAULT_FRAME_W = 192 const DEFAULT_FRAME_H = 208 const DEFAULT_SCALE = 0.33 // Must match the root's paddingBottom — the sprite renders bottom-centered, this // many px above the window's bottom edge. Used to anchor the resize. const PET_PADDING_BOTTOM = 24 // A sprite pixel counts as "solid" (interactive) at/above this alpha (0-255). // Low enough to catch anti-aliased edges, high enough that the faint halo around // the art still clicks through. const ALPHA_HIT_THRESHOLD = 16 /** * The pop-out overlay's only view: a transparent, draggable mascot with a mini * composer. * * This runs in a separate, gateway-less BrowserWindow (`?win=overlay`). It is a * pure puppet — the main renderer pushes the live pet state over IPC and we * mirror it into the same atoms the in-window pet reads, so `PetSprite` / * `PetBubble` render identically with zero extra logic. * * The window is a full rectangle but mostly transparent; we toggle OS-level * mouse click-through so only the sprite (or the open composer) is interactive * and the empty margins pass clicks through to whatever is behind. * * Gestures on the pet: drag to move it anywhere on screen (even outside the * app), shift-click to pop it back into the window, single-click to open a small * composer, double-click to toggle the app window (minimize ↔ restore). A mail * icon (shown only when a turn finished while you were away) raises the app on * the most recent thread. */ // Below this much pointer travel, a press counts as a click, not a drag. const CLICK_SLOP_PX = 3 // A second click within this window is a double-click (raise app) and cancels // the deferred single-click (open composer), so a double never flashes it open. const DOUBLE_CLICK_MS = 250 interface DragState { startX: number startY: number offX: number offY: number width: number height: number moved: boolean } export function PetOverlayApp() { const { t, setLocale } = useI18n() const info = useStore($petInfo) const [composerOpen, setComposerOpen] = useState(false) const [draft, setDraft] = useState('') // Mirrored from the main renderer: a finish landed while you were away. const [unread, setUnread] = useState(false) const dragRef = useRef(null) // Last Alt+wheel anchor, consumed by the resize effect to zoom toward the // cursor; null means a non-wheel scale change (slider) → anchor bottom-center. 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) const setIgnore = (ignore: boolean) => { if (ignoreRef.current !== ignore) { ignoreRef.current = ignore window.hermesDesktop?.petOverlay?.setIgnoreMouse(ignore) } } // Mirror pushed state into the shared atoms so PetSprite/PetBubble just work. // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const off = window.hermesDesktop?.petOverlay?.onState(payload => { void setLocale(normalizeLocale(payload.locale)) setPetInfo(payload.info) $petActivity.set(payload.activity ?? {}) 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 // subscribe-time pushes during open() can land before this view exists). window.hermesDesktop?.petOverlay?.control({ type: 'ready' }) return off }, [setLocale]) // Click-through: make only the *solid* sprite pixels (plus the bubble / mail // button / open composer) interactive — clicks on the transparent rectangle // around the art pass through to whatever's behind. With ignore+forward, the // renderer still receives mousemove so we can re-arm the moment the cursor // returns to a solid pixel. useEffect(() => { setIgnore(true) // True when the point sits on a solid sprite pixel or on the pet's other // interactive chrome (bubble, mail button). Over the canvas we sample the // rendered alpha; elsewhere inside the pet (bubble/button) we trust DOM // hit-testing. Anything else is transparent backdrop. const isInteractiveAt = (x: number, y: number): boolean => { const pet = petRef.current const target = document.elementFromPoint(x, y) if (!pet || !target || !pet.contains(target)) { return false } if (!(target instanceof HTMLCanvasElement)) { return true } const rect = target.getBoundingClientRect() if (rect.width === 0 || rect.height === 0) { return true } const ctx = target.getContext('2d') if (!ctx) { return true } const px = Math.floor((x - rect.left) * (target.width / rect.width)) const py = Math.floor((y - rect.top) * (target.height / rect.height)) try { return ctx.getImageData(px, py, 1, 1).data[3] >= ALPHA_HIT_THRESHOLD } catch { // Tainted/zero-size read — fail open so the pet stays grabbable. return true } } const onMove = (ev: MouseEvent) => { if (dragRef.current || composerOpenRef.current) { setIgnore(false) return } setIgnore(!isInteractiveAt(ev.clientX, ev.clientY)) } window.addEventListener('mousemove', onMove) return () => { window.removeEventListener('mousemove', onMove) clearTimeout(clickTimerRef.current) } }, []) // The whole window must stay interactive while the composer is open (so the // input keeps focus); focus it on open. The overlay is a non-activating panel // (so it never steals the app's cmd/alt-tab anchor) — flip it focusable while // the composer needs the keyboard, then back to non-activating when it closes. // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { composerOpenRef.current = composerOpen window.hermesDesktop?.petOverlay?.setFocusable(composerOpen) if (composerOpen) { setIgnore(false) // The OS window has to become key first (setFocusable + focus happen in // the main process), so focus the input on the next frame. requestAnimationFrame(() => inputRef.current?.focus()) } }, [composerOpen]) const onPetPointerDown = (e: React.PointerEvent) => { if (e.button !== 0) { return } ;(e.target as Element).setPointerCapture?.(e.pointerId) dragRef.current = { height: window.outerHeight, moved: false, offX: e.screenX - window.screenX, offY: e.screenY - window.screenY, startX: e.screenX, startY: e.screenY, width: window.outerWidth } } const onPetPointerMove = (e: React.PointerEvent) => { const drag = dragRef.current if (!drag) { return } if (Math.hypot(e.screenX - drag.startX, e.screenY - drag.startY) > CLICK_SLOP_PX) { drag.moved = true } window.hermesDesktop?.petOverlay?.setBounds({ height: drag.height, width: drag.width, x: e.screenX - drag.offX, y: e.screenY - drag.offY }) } const onPetPointerUp = (e: React.PointerEvent) => { const drag = dragRef.current dragRef.current = null ;(e.target as Element).releasePointerCapture?.(e.pointerId) if (!drag) { return } if (drag.moved) { // A drag cancels any deferred single-click so the composer can't pop open // after you reposition the pet. clearTimeout(clickTimerRef.current) clickTimerRef.current = undefined // Remember the spot on the desktop (screen coords) so the pet reopens here // next time / after a restart. window.hermesDesktop?.petOverlay?.control({ bounds: { height: drag.height, width: drag.width, x: e.screenX - drag.offX, y: e.screenY - drag.offY }, type: 'bounds' }) return } // Shift-click always pops the pet back in (no double-click ambiguity). if (e.shiftKey) { window.hermesDesktop?.petOverlay?.control({ type: 'pop-in' }) return } // Double-click toggles the app window (minimize ↔ restore); defer the // single-click composer toggle so a double never flashes the composer open. if (clickTimerRef.current) { clearTimeout(clickTimerRef.current) clickTimerRef.current = undefined window.hermesDesktop?.petOverlay?.control({ type: 'toggle-app' }) return } clickTimerRef.current = setTimeout(() => { clickTimerRef.current = undefined setComposerOpen(open => !open) }, DOUBLE_CLICK_MS) } const send = () => { const text = draft.trim() if (text) { window.hermesDesktop?.petOverlay?.control({ text, type: 'submit' }) } setDraft('') setComposerOpen(false) } const openApp = () => { // Hide the icon immediately; the main renderer also clears the source flag. setUnread(false) window.hermesDesktop?.petOverlay?.control({ type: 'open-app' }) } // Alt+wheel over the popped-out pet resizes it. The overlay has no gateway, // so paint the new scale locally for instant feedback, then ask the main // renderer to persist it (it pushes the reconciled scale back). Stash the // cursor anchor for the resize effect; the window itself is grown to fit there. const onScale = useCallback((next: number, anchor: PetZoomAnchor) => { zoomAnchorRef.current = anchor setPetInfo({ ...$petInfo.get(), scale: next }) window.hermesDesktop?.petOverlay?.control({ scale: next, type: 'scale' }) }, []) usePetZoomGesture(petRef, onScale, Boolean(info.enabled && info.spritesheetBase64)) // Grow/shrink the OS overlay window to fit the pet at its current scale so the // sprite is never cropped — covers both the wheel gesture here and a scale // changed from the app's settings slider (pushed in as a state update). With a // wheel anchor we zoom toward the cursor (keep the pixel under it fixed); // otherwise we anchor the bottom-center (the pet's feet stay planted). New // bounds are persisted so the pet reopens at the right size. // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (!info.enabled || !info.spritesheetBase64) { return } const { width, height } = overlayWindowSize( info.frameW ?? DEFAULT_FRAME_W, info.frameH ?? DEFAULT_FRAME_H, info.scale ?? DEFAULT_SCALE ) const curW = window.outerWidth const curH = window.outerHeight if (width === curW && height === curH) { zoomAnchorRef.current = null return } const anchor = zoomAnchorRef.current zoomAnchorRef.current = null // The sprite scales about its bottom-center, at window-local (curW/2, // curH - paddingBottom). Hold the anchor pixel fixed on screen as it scales; // with no wheel anchor we pin the bottom-center itself (ratio 1 ⇒ no shift). const ratio = anchor?.ratio ?? 1 const ax = anchor?.clientX ?? curW / 2 const ay = anchor?.clientY ?? curH - PET_PADDING_BOTTOM const bounds = { height, width, x: Math.round(window.screenX + ax - (ax - curW / 2) * ratio - width / 2), y: Math.round(window.screenY + ay - (ay - (curH - PET_PADDING_BOTTOM)) * ratio - (height - PET_PADDING_BOTTOM)) } window.hermesDesktop?.petOverlay?.setBounds(bounds) window.hermesDesktop?.petOverlay?.control({ bounds, type: 'bounds' }) }, [info.enabled, info.spritesheetBase64, info.scale, info.frameW, info.frameH]) if (!info.enabled || !info.spritesheetBase64) { return null } return (
{ // Click on the transparent backdrop (not the pet/composer) dismisses // the composer. if (composerOpen && e.target === e.currentTarget) { setComposerOpen(false) } }} style={{ alignItems: 'center', background: 'transparent', display: 'flex', flexDirection: 'column', height: '100vh', justifyContent: 'flex-end', paddingBottom: PET_PADDING_BOTTOM, userSelect: 'none', width: '100vw' }} > {composerOpen && ( setDraft(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() send() } else if (e.key === 'Escape') { setComposerOpen(false) } }} placeholder={t.surfaces.pet.message} ref={inputRef} style={{ background: 'var(--ui-bg-elevated)', border: '1px solid var(--ui-stroke-secondary)', borderRadius: 2, boxShadow: '0 6px 18px rgba(0,0,0,0.28)', color: 'var(--foreground)', fontSize: 12, marginBottom: 8, outline: 'none', padding: '4px 8px', width: 184 }} value={draft} /> )}
{/* 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); stopPropagation keeps a click from starting a window drag. */} {unread && ( )}
) }