Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions apps/desktop/electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6031,19 +6031,32 @@ ipcMain.handle('hermes:pet-overlay:close', async () => {

return { ok: true }
})
// Drag: the overlay reports a new absolute screen position (it already knows the
// pointer's screen coords), we just move the window.
// Drag/resize: the overlay reports new absolute screen bounds (it already knows
// the pointer's screen coords). Drag keeps the size constant; the wheel-to-scale
// gesture grows/shrinks it so the sprite is never cropped by the window edge.
// The window is created non-resizable (no stray edge-drag on the transparent
// frameless panel), which on Windows/Linux also blocks programmatic setBounds
// sizing — so briefly flip resizable on whenever the size actually changes.
ipcMain.on('hermes:pet-overlay:set-bounds', (_event, bounds) => {
if (!petOverlayWindow || petOverlayWindow.isDestroyed() || !bounds) {
return
}

petOverlayWindow.setBounds({
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.max(80, Math.round(bounds.width)),
height: Math.max(80, Math.round(bounds.height))
})
const win = petOverlayWindow
const width = Math.max(80, Math.round(bounds.width))
const height = Math.max(80, Math.round(bounds.height))
const [curW, curH] = win.getSize()
const resizing = width !== curW || height !== curH

if (resizing && !win.isResizable()) {
win.setResizable(true)
}

win.setBounds({ x: Math.round(bounds.x), y: Math.round(bounds.y), width, height })

if (resizing) {
win.setResizable(false)
}
})
// Click-through: the overlay window is a full rectangle but only the pet pixels
// should be interactive. The renderer toggles this as the cursor enters/leaves
Expand Down
9 changes: 8 additions & 1 deletion apps/desktop/src/app/desktop-controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ import {
import { $paneOpen } from '../store/panes'
import { respondToApprovalAction } from '../store/native-notifications'
import { setPetActivity } from '../store/pet'
import { setPetOverlayOpenAppHandler, setPetOverlaySubmitHandler } from '../store/pet-overlay'
import { setPetScale } from '../store/pet-gallery'
import { setPetOverlayOpenAppHandler, setPetOverlayScaleHandler, setPetOverlaySubmitHandler } from '../store/pet-overlay'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
import {
$activeGatewayProfile,
Expand Down Expand Up @@ -939,13 +940,18 @@ export function DesktopController() {
submitTextRef.current = submitText
const resumeSessionRef = useRef(resumeSession)
resumeSessionRef.current = resumeSession
const requestGatewayRef = useRef(requestGateway)
requestGatewayRef.current = requestGateway

useEffect(() => {
if (isSecondaryWindow()) {
return
}

setPetOverlaySubmitHandler(text => void submitTextRef.current(text))
// Alt+wheel resize from the popped-out pet — persist it through this
// window's gateway (the overlay has none) so it survives restart.
setPetOverlayScaleHandler(scale => setPetScale(requestGatewayRef.current, scale))
// Mail icon: $sessions is ordered most-recent-first; the pet is global (not
// per session) so "most recent" is the right target. main.cjs already raised
// the window before forwarding this.
Expand All @@ -960,6 +966,7 @@ export function DesktopController() {
return () => {
setPetOverlaySubmitHandler(null)
setPetOverlayOpenAppHandler(null)
setPetOverlayScaleHandler(null)
}
}, [])

Expand Down
139 changes: 125 additions & 14 deletions apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
import { useStore } from '@nanostores/react'
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'

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 { 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.
Expand Down Expand Up @@ -51,6 +67,9 @@ export function PetOverlayApp() {
const [unread, setUnread] = useState(false)

const dragRef = useRef<DragState | null>(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<PetZoomAnchor | null>(null)
const petRef = useRef<HTMLDivElement | null>(null)
const inputRef = useRef<HTMLInputElement | null>(null)
const ignoreRef = useRef(true)
Expand Down Expand Up @@ -81,28 +100,61 @@ export function PetOverlayApp() {
return off
}, [])

// Click-through: make only the sprite (or an open composer) interactive. With
// ignore+forward, the renderer still receives mousemove so we can re-enable
// hit-testing the moment the cursor returns to the pet.
// 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)

const onMove = (ev: MouseEvent) => {
if (dragRef.current || composerOpenRef.current) {
setIgnore(false)
// 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)

return
if (!pet || !target || !pet.contains(target)) {
return false
}

const el = petRef.current
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)

if (!el) {
return
}

const r = el.getBoundingClientRect()
const over = ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom
setIgnore(!over)
setIgnore(!isInteractiveAt(ev.clientX, ev.clientY))
}

window.addEventListener('mousemove', onMove)
Expand Down Expand Up @@ -231,6 +283,65 @@ export function PetOverlayApp() {
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.
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
}
Expand All @@ -251,7 +362,7 @@ export function PetOverlayApp() {
flexDirection: 'column',
height: '100vh',
justifyContent: 'flex-end',
paddingBottom: 24,
paddingBottom: PET_PADDING_BOTTOM,
userSelect: 'none',
width: '100vw'
}}
Expand Down
Loading
Loading