diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index be89c6c91cf..42f81c38123 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -5154,6 +5154,142 @@ function createNewSessionWindow() { return spawnSecondaryWindow({ newSession: true }) } +// The pet overlay: a single transparent, frameless, always-on-top window that +// hosts ONLY the floating mascot. Shift-clicking the in-window pet "pops it out" +// here so it can leave the app's bounds and stay visible while Hermes is +// minimized (Codex-style task-completion glance). It carries no gateway +// connection of its own — the main renderer is the single source of truth and +// pushes pet state over IPC (hermes:pet-overlay:state); the overlay just renders +// it. Control flows back (pop-in, composer submit) via hermes:pet-overlay:control. +let petOverlayWindow = null + +function petOverlayUrl() { + if (DEV_SERVER) { + return `${DEV_SERVER.endsWith('/') ? DEV_SERVER.slice(0, -1) : DEV_SERVER}/?win=overlay#/` + } + + return `${pathToFileURL(resolveRendererIndex()).toString()}?win=overlay#/` +} + +function spawnPetOverlayWindow(bounds) { + const win = new BrowserWindow({ + width: Math.max(80, Math.round(bounds?.width || 220)), + height: Math.max(80, Math.round(bounds?.height || 220)), + x: Number.isFinite(bounds?.x) ? Math.round(bounds.x) : undefined, + y: Number.isFinite(bounds?.y) ? Math.round(bounds.y) : undefined, + frame: false, + transparent: true, + resizable: false, + movable: true, + minimizable: false, + maximizable: false, + fullscreenable: false, + // Windows/Linux need this so the helper window does not get its own + // taskbar/alt-tab entry. On macOS, cmd-tab is app-level and this can make + // the whole app look like it vanished when the only newly-created visible + // window is a frameless overlay. Use NSPanel + Mission Control hiding below + // instead, leaving the main Hermes app as the Dock/cmd-tab anchor. + skipTaskbar: !IS_MAC, + hasShadow: false, + alwaysOnTop: true, + // macOS panels are non-activating helper windows and can float over full + // screen spaces without becoming the app's main switcher window. + type: IS_MAC ? 'panel' : undefined, + hiddenInMissionControl: IS_MAC, + // Non-activating: the overlay must never become the app's key/main window, + // or it (a frameless, taskbar-skipping panel) becomes the app's switcher + // anchor and the Hermes icon drops out of cmd/alt-tab — especially when the + // main window is minimized. We flip this on only while the composer needs + // the keyboard (see hermes:pet-overlay:set-focusable). + focusable: false, + show: false, + // Fully transparent — the renderer paints only the sprite + bubble. + backgroundColor: '#00000000', + webPreferences: { + preload: path.join(__dirname, 'preload.cjs'), + contextIsolation: true, + sandbox: true, + nodeIntegration: false, + devTools: true, + // Keep the sprite animating + bubble updating while the main window is + // minimized/blurred — the whole point of the overlay. + backgroundThrottling: false + } + }) + + // Float above other apps and follow the user across desktops so the pet is + // always reachable. `floating` + `type: panel` is the macOS NSPanel path; the + // more aggressive `screen-saver` level can interfere with normal app/window + // switching semantics. + win.setAlwaysOnTop(true, IS_MAC ? 'floating' : 'screen-saver') + win.setHiddenInMissionControl?.(true) + try { + // Electron docs: macOS may transform process type on each + // setVisibleOnAllWorkspaces() call unless skipTransformProcessType=true, + // which briefly hides the Dock/cmd-tab presence. Keep Hermes in the normal + // ForegroundApplication class so shift-clicking the pet never drops the app + // out of app switchers. + win.setVisibleOnAllWorkspaces( + true, + IS_MAC ? { visibleOnFullScreen: true, skipTransformProcessType: true } : undefined + ) + } catch { + // Not supported everywhere — best effort. + } + + wireCommonWindowHandlers(win) + + win.once('ready-to-show', () => { + if (!win.isDestroyed()) win.showInactive() + }) + + win.on('closed', () => { + if (petOverlayWindow === win) { + petOverlayWindow = null + } + + // If the overlay went away on its own (e.g. ⌘W), tell the main renderer to + // pop the pet back in so it doesn't stay hidden. Harmless echo when we're + // the ones who closed it (popInPet already cleared the active flag). + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('hermes:pet-overlay:control', { type: 'pop-in' }) + } + }) + + win.loadURL(petOverlayUrl()) + + return win +} + +function openPetOverlay(bounds) { + if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + if (bounds) { + 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)) + }) + } + + petOverlayWindow.showInactive() + + return petOverlayWindow + } + + petOverlayWindow = spawnPetOverlayWindow(bounds) + + return petOverlayWindow +} + +function closePetOverlay() { + if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + petOverlayWindow.close() + } + + petOverlayWindow = null +} + function createWindow() { const icon = getAppIconPath() mainWindow = new BrowserWindow({ @@ -5211,6 +5347,11 @@ function createWindow() { mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false)) mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false)) + // The overlay rides the main window — closing the app's primary window must + // tear it down too (otherwise it strands as an orphan that blocks + // window-all-closed from quitting on Windows/Linux). + mainWindow.on('closed', () => closePetOverlay()) + wireCommonWindowHandlers(mainWindow) mainWindow.webContents.on('render-process-gone', (_event, details) => { @@ -5331,6 +5472,116 @@ ipcMain.handle('hermes:window:openNewSession', async () => { return { ok: true } }) + +// --- Pet overlay (pop-out mascot) ----------------------------------------- +// `request` is `{ bounds, screen }`. A fresh pop-out passes viewport-space +// bounds (screen=false): convert to screen space by adding the main window's +// content origin so the pet lands where it sat in-window. A remembered/dragged +// spot passes screen-space bounds (screen=true) and is used as-is. We return the +// resolved screen bounds so the renderer can persist exactly where it opened. +ipcMain.handle('hermes:pet-overlay:open', async (_event, request) => { + const bounds = request && request.bounds ? request.bounds : request + const isScreen = Boolean(request && request.screen) + let screenBounds = bounds + + try { + if (bounds && !isScreen && mainWindow && !mainWindow.isDestroyed()) { + const content = mainWindow.getContentBounds() + screenBounds = { + x: content.x + (bounds.x || 0), + y: content.y + (bounds.y || 0), + width: bounds.width, + height: bounds.height + } + } + } catch { + // Fall back to raw bounds if the window geometry is unavailable. + } + + openPetOverlay(screenBounds) + + return { ok: true, bounds: screenBounds } +}) +ipcMain.handle('hermes:pet-overlay:close', async () => { + closePetOverlay() + + 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. +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)) + }) +}) +// 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 +// the sprite so transparent margins pass clicks to whatever is behind. +ipcMain.on('hermes:pet-overlay:ignore-mouse', (_event, ignore) => { + if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + petOverlayWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true }) + } +}) +// The overlay is a non-activating panel (focusable:false) so it never steals +// the app's cmd/alt-tab anchor from the main window. But the pop-up composer +// needs the keyboard, so the renderer asks us to flip it focusable + focus it +// while the composer is open, then back to non-activating when it closes. +ipcMain.on('hermes:pet-overlay:set-focusable', (_event, focusable) => { + if (!petOverlayWindow || petOverlayWindow.isDestroyed()) { + return + } + + petOverlayWindow.setFocusable(Boolean(focusable)) + if (focusable) { + petOverlayWindow.focus() + } +}) +// Main renderer → overlay: forward the latest pet state for the overlay to render. +ipcMain.on('hermes:pet-overlay:state', (_event, payload) => { + if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + petOverlayWindow.webContents.send('hermes:pet-overlay:state', payload) + } +}) +// Overlay → main renderer: control messages (pop back in, composer submit). +ipcMain.on('hermes:pet-overlay:control', (_event, payload) => { + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + + // Double-click toggles the app window: hide it away if it's up front, bring it + // back if it's minimized/buried. Pure window control — nothing for the + // renderer to do, so don't forward it. + if (payload && payload.type === 'toggle-app') { + if (mainWindow.isMinimized() || !mainWindow.isVisible()) { + mainWindow.show() + mainWindow.focus() + } else { + mainWindow.minimize() + } + + return + } + + // The mail icon means "take me to the app": raise the main window (it may be + // minimized or buried) before the renderer navigates to the latest thread. + if (payload && payload.type === 'open-app') { + if (mainWindow.isMinimized()) { + mainWindow.restore() + } + + mainWindow.show() + mainWindow.focus() + } + + mainWindow.webContents.send('hermes:pet-overlay:control', payload) +}) ipcMain.handle('hermes:bootstrap:reset', async () => { // Renderer's "Reload and retry" path. Clear the latched failure and // reset connection state so the next startHermes() call restarts the @@ -6535,6 +6786,10 @@ function configureSpellChecker() { } app.on('before-quit', () => { + // The always-on-top overlay isn't a "real" app window; close it so a stray + // pet can't keep the process alive or float over a quit app. + closePetOverlay() + // Quitting mid-install should stop the installer, not orphan it. if (bootstrapAbortController) { try { diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 413abd77b32..93620facdf4 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -7,6 +7,32 @@ contextBridge.exposeInMainWorld('hermesDesktop', { getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile), openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts), openNewSessionWindow: () => ipcRenderer.invoke('hermes:window:openNewSession'), + petOverlay: { + // Main renderer → main process: window lifecycle + drag. `request` is + // `{ bounds, screen }`; resolves with the screen bounds it actually used. + open: request => ipcRenderer.invoke('hermes:pet-overlay:open', request), + close: () => ipcRenderer.invoke('hermes:pet-overlay:close'), + setBounds: bounds => ipcRenderer.send('hermes:pet-overlay:set-bounds', bounds), + setIgnoreMouse: ignore => ipcRenderer.send('hermes:pet-overlay:ignore-mouse', ignore), + // Flip the overlay focusable (and focus it) while the composer needs keys. + setFocusable: focusable => ipcRenderer.send('hermes:pet-overlay:set-focusable', focusable), + // Main renderer → overlay (forwarded by main): push the latest pet state. + pushState: payload => ipcRenderer.send('hermes:pet-overlay:state', payload), + // Overlay → main renderer (forwarded by main): pop back in / composer submit. + control: payload => ipcRenderer.send('hermes:pet-overlay:control', payload), + // Overlay subscribes to state pushes. + onState: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:pet-overlay:state', listener) + return () => ipcRenderer.removeListener('hermes:pet-overlay:state', listener) + }, + // Main renderer subscribes to overlay control messages. + onControl: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:pet-overlay:control', listener) + return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener) + } + }, getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'), getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile), saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload), diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 19ea7976344..d91a6c92756 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -29,6 +29,7 @@ import { Moon, Package, Palette, + PawPrint, Plus, Settings, Settings2, @@ -39,7 +40,7 @@ import { Zap } from '@/lib/icons' import { cn } from '@/lib/utils' -import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette' +import { $commandPaletteOpen, $commandPalettePage, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette' import { $bindings } from '@/store/keybinds' import { luminance } from '@/themes/color' import { type ThemeMode, useTheme } from '@/themes/context' @@ -62,6 +63,7 @@ import { fieldCopyForSchemaKey } from '../settings/field-copy' import { prettyName } from '../settings/helpers' import { MarketplaceThemePage } from './marketplace-theme-page' +import { PetInlineToggle, PetPalettePage } from './pet-palette-page' interface PaletteItem { /** Keybind action id — its live combo renders as a hotkey hint. */ @@ -205,6 +207,7 @@ function themeSupportsMode(name: string, target: 'light' | 'dark'): boolean { export function CommandPalette() { const { t } = useI18n() const open = useStore($commandPaletteOpen) + const pendingPage = useStore($commandPalettePage) const bindings = useStore($bindings) const navigate = useNavigate() const { availableThemes, resolvedMode, setMode, setTheme, themeName } = useTheme() @@ -250,6 +253,14 @@ export function CommandPalette() { } }, [open]) + // Deep-link into a nested page (e.g. `/pet list` → pets picker). + useEffect(() => { + if (open && pendingPage) { + setPage(pendingPage) + $commandPalettePage.set(null) + } + }, [open, pendingPage]) + const go = useCallback((path: string) => () => navigate(path), [navigate]) // Step up one nested page (or back to the root list), clearing the filter so @@ -382,6 +393,13 @@ export function CommandPalette() { keywords: ['appearance', 'color mode', 'brightness', 'dark', 'light', 'system'], label: cc.changeColorMode, to: 'color-mode' + }, + { + icon: PawPrint, + id: 'appearance-pets', + keywords: ['pet', 'petdex', 'mascot', 'pets', '/pet', 'paw'], + label: cc.pets.title, + to: 'pets' } ] }, @@ -550,6 +568,12 @@ export function CommandPalette() { } ] }, + // Server-driven page: browse petdex gallery, adopt/switch, toggle off. + pets: { + title: t.commandCenter.pets.title, + placeholder: t.commandCenter.pets.placeholder, + groups: [] + }, // Server-driven page: items come from the Marketplace, rendered by // (loader + live search + per-row install). 'install-theme': { @@ -624,45 +648,51 @@ export function CommandPalette() { }} onValueChange={setSearch} placeholder={placeholder} + right={page === 'pets' ? : undefined} value={search} /> - {page === 'install-theme' ? ( + {/* Server-driven pages render their own list; the rest show groups. */} + {page === 'pets' ? ( + + ) : page === 'install-theme' ? ( ) : ( - {t.commandCenter.noResults} - )} - {visibleGroups.map((group, index) => ( - - {group.items.map(item => { - const Icon = item.icon - const combo = item.action ? bindings[item.action]?.[0] : undefined + <> + {t.commandCenter.noResults} + {visibleGroups.map((group, index) => ( + + {group.items.map(item => { + const Icon = item.icon + const combo = item.action ? bindings[item.action]?.[0] : undefined - return ( - handleSelect(item)} - value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`} - > - - {item.label} - {combo && } - {item.to && ( - - )} - - ) - })} - - ))} + return ( + handleSelect(item)} + value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`} + > + + {item.label} + {combo && } + {item.to && ( + + )} + + ) + })} + + ))} + + )} diff --git a/apps/desktop/src/app/command-palette/pet-palette-page.tsx b/apps/desktop/src/app/command-palette/pet-palette-page.tsx new file mode 100644 index 00000000000..891637c67cb --- /dev/null +++ b/apps/desktop/src/app/command-palette/pet-palette-page.tsx @@ -0,0 +1,185 @@ +/** + * Cmd-K "Pets…" page — browse the petdex gallery, adopt/switch, toggle off. + * + * A thin view over the `pet-gallery` store: it subscribes to the shared atoms + * and calls the store's actions. The store owns fetching, caching, the thumb + * cache, and optimistic mutations, so reopening this page is instant and a + * toggle never re-pulls the network gallery. + */ + +import { useStore } from '@nanostores/react' +import { useEffect, useMemo } from 'react' + +import { HUD_ITEM, HUD_TEXT } from '@/app/floating-hud' +import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' +import { PetThumb } from '@/components/pet/pet-thumb' +import { useI18n } from '@/i18n' +import { triggerHaptic } from '@/lib/haptics' +import { Check, Loader2, PawPrint } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { + $petBusy, + $petGallery, + $petGalleryError, + $petGalleryStatus, + adoptPet, + loadPetGallery, + loadPetThumb, + rankedGalleryPets, + setPetEnabled +} from '@/store/pet-gallery' + +interface PetPalettePageProps { + search: string +} + +export function PetPalettePage({ search }: PetPalettePageProps) { + const { t } = useI18n() + const copy = t.commandCenter.pets + const { requestGateway } = useGatewayRequest() + + const gallery = useStore($petGallery) + const status = useStore($petGalleryStatus) + const error = useStore($petGalleryError) + const busy = useStore($petBusy) + + useEffect(() => { + void loadPetGallery(requestGateway) + }, [requestGateway]) + + const enabled = gallery?.enabled ?? false + const active = gallery?.active ?? '' + + const shown = useMemo(() => rankedGalleryPets(gallery, search).slice(0, 50), [gallery, search]) + + const adopt = (slug: string) => { + void adoptPet(requestGateway, slug, copy.adoptFailed).then(ok => ok && triggerHaptic('crisp')) + } + + if (status === 'loading' && !gallery) { + return } text={copy.loading} /> + } + + if (status === 'stale') { + return + } + + if (!gallery?.pets.length && error) { + return + } + + const mutating = Boolean(busy) + + return ( +
+ {error &&

{error}

} + + {shown.length === 0 ? ( + + ) : ( + shown.map(pet => { + const isActive = enabled && pet.slug === active + const isBusy = busy === pet.slug + + return ( + + ) + }) + )} +
+ ) +} + +/** + * Single on/off toggle, rendered inline on the palette's search row (see + * `CommandInput`'s `right` slot). The paw lights up when pets are on. Reads the + * same shared gallery atoms, so it stays in sync with the list below. + */ +export function PetInlineToggle() { + const { t } = useI18n() + const copy = t.commandCenter.pets + const { requestGateway } = useGatewayRequest() + const gallery = useStore($petGallery) + const busy = useStore($petBusy) + + if (!gallery) { + return null + } + + const enabled = gallery.enabled + + const toggle = () => { + void setPetEnabled(requestGateway, !enabled, { + noneAvailable: copy.noneAvailable, + fallback: copy.toggleFailed + }).then(ok => ok && triggerHaptic('crisp')) + } + + return ( + + ) +} + +function Status({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) { + return ( +
+ {icon} + {text} +
+ ) +} diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 05dfbbc764f..8ed097e29ee 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -38,6 +38,8 @@ import { unpinSession } from '../store/layout' import { respondToApprovalAction } from '../store/native-notifications' +import { setPetActivity } from '../store/pet' +import { setPetOverlayOpenAppHandler, setPetOverlaySubmitHandler } from '../store/pet-overlay' import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview' import { $activeGatewayProfile, @@ -49,6 +51,7 @@ import { } from '../store/profile' import { $activeSessionId, + $attentionSessionIds, $currentCwd, $freshDraftReady, $gatewayState, @@ -834,6 +837,53 @@ export function DesktopController() { updateSessionState }) + // The popped-out pet drives two actions back into the app: send a prompt, and + // open the most recent thread. Both are registered ONCE through refs that track + // the latest callbacks — re-registering on every `submitText`/`resumeSession` + // identity change left a brief window where the handler was nulled (cleanup + // before re-register), which could drop a submit fired from the overlay (e.g. + // creating a session from the new-session screen). The ref form keeps a stable, + // always-current handler. Primary window only — it owns the overlay. + const submitTextRef = useRef(submitText) + submitTextRef.current = submitText + const resumeSessionRef = useRef(resumeSession) + resumeSessionRef.current = resumeSession + + useEffect(() => { + if (isSecondaryWindow()) { + return + } + + setPetOverlaySubmitHandler(text => void submitTextRef.current(text)) + // 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. + setPetOverlayOpenAppHandler(() => { + const recent = $sessions.get()[0] + + if (recent?.id) { + void resumeSessionRef.current(recent.id) + } + }) + + return () => { + setPetOverlaySubmitHandler(null) + setPetOverlayOpenAppHandler(null) + } + }, []) + + // Mirror "a session is blocked on the user" (clarify/approval) into the pet's + // awaitingInput flag so it shows the `waiting` pose. Lives on $petActivity so + // it rides the same atom the pop-out overlay mirrors — no session list needed + // there. Every window keeps its own in-window pet in sync. + useEffect(() => { + const sync = () => setPetActivity({ awaitingInput: $attentionSessionIds.get().length > 0 }) + + sync() + + return $attentionSessionIds.listen(sync) + }, []) + useGatewayBoot({ handleGatewayEvent: handleDesktopGatewayEvent, onConnectionReady: c => { diff --git a/apps/desktop/src/app/pet-overlay/overlay-root.tsx b/apps/desktop/src/app/pet-overlay/overlay-root.tsx new file mode 100644 index 00000000000..de446bdb6a5 --- /dev/null +++ b/apps/desktop/src/app/pet-overlay/overlay-root.tsx @@ -0,0 +1,38 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' + +import { ErrorBoundary } from '@/components/error-boundary' +import { ThemeProvider } from '@/themes/context' + +import { PetOverlayApp } from './pet-overlay-app' + +/** + * Boot the pet-overlay window. Loaded by the same bundle as the main app but + * via `?win=overlay`, so it shares CSS/atoms while mounting a minimal, transparent + * surface (no app shell, no gateway, no I18n — the bubble strings are inline). + * + * The index.html boot script paints an OPAQUE themed background to avoid a flash + * in normal windows; the overlay must be see-through, so we force every host + * layer transparent with a late, high-specificity style tag. + */ +export function mountPetOverlay(): void { + const style = document.createElement('style') + style.textContent = 'html,body,#root{background:transparent !important;}' + document.head.appendChild(style) + + const root = document.getElementById('root') + + if (!root) { + return + } + + createRoot(root).render( + + + + + + + + ) +} diff --git a/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx new file mode 100644 index 00000000000..1fcd21169f0 --- /dev/null +++ b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx @@ -0,0 +1,345 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useRef, useState } from 'react' + +import { PetBubble } from '@/components/pet/pet-bubble' +import { PetSprite } from '@/components/pet/pet-sprite' +import { Mail } from '@/lib/icons' +import { $petActivity, $petInfo, setPetInfo } from '@/store/pet' +import { setAwaitingResponse, setBusy } from '@/store/session' + +/** + * 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 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) + const petRef = useRef(null) + const inputRef = 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. + useEffect(() => { + const off = window.hermesDesktop?.petOverlay?.onState(payload => { + setPetInfo(payload.info) + $petActivity.set(payload.activity ?? {}) + setBusy(Boolean(payload.busy)) + setAwaitingResponse(Boolean(payload.awaiting)) + setUnread(Boolean(payload.unread)) + }) + + // 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 + }, []) + + // 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. + useEffect(() => { + setIgnore(true) + + const onMove = (ev: MouseEvent) => { + if (dragRef.current || composerOpenRef.current) { + setIgnore(false) + + return + } + + const el = petRef.current + + 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) + } + + 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. + 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' }) + } + + 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: 24, + 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="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} + /> + )} + +
+
+ +
+
+ + + {/* 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 && ( + + )} +
+
+
+ ) +} diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream.ts index 909c1424796..9ae7e13976c 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream.ts @@ -34,6 +34,7 @@ import { $gateway } from '@/store/gateway' import { dispatchNativeNotification } from '@/store/native-notifications' import { notify } from '@/store/notifications' import { requestDesktopOnboarding } from '@/store/onboarding' +import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet' import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts' import { setCurrentBranch, @@ -870,10 +871,18 @@ export function useMessageStream({ if (sessionId) { appendReasoningDelta(sessionId, coerceThinkingText(payload?.text)) } + + if (isActiveEvent) { + setPetActivity({ reasoning: true }) + } } else if (event.type === 'reasoning.available') { if (sessionId) { appendReasoningDelta(sessionId, coerceThinkingText(payload?.text), true) } + + if (isActiveEvent) { + setPetActivity({ reasoning: true }) + } } else if (event.type === 'message.complete') { if (!sessionId) { return @@ -895,6 +904,20 @@ export function useMessageStream({ if (isActiveEvent) { setTurnStartedAt(null) + + // Pet beat: a finished turn always celebrates — go straight to the + // jump, never linger on the run/reason pose. One atom update (clears + // toolRunning/reasoning AND sets celebrate together) so no stray "run" + // frame leaks to the sprite — including the popped-out overlay, which + // mirrors each activity change. The jump runs ~2 loops, then settles. + flashPetActivity({ celebrate: true, reasoning: false, toolRunning: false }, 2200) + + // Light up the pet's mail icon if the user wasn't looking when the turn + // finished — a glanceable "new message" hint on the popped-out overlay. + // Cleared when they open the app via the mail icon or refocus the window. + if (typeof document !== 'undefined' && !document.hasFocus()) { + markPetUnread() + } } if (payload?.usage) { @@ -907,10 +930,19 @@ export function useMessageStream({ flushQueuedDeltas(sessionId) upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running', event.type) + + if (isActiveEvent) { + setPetActivity({ reasoning: false, toolRunning: true }) + } } else if (event.type === 'tool.complete') { if (sessionId) { flushQueuedDeltas(sessionId) upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete', event.type) + + if (isActiveEvent) { + setPetActivity({ toolRunning: false }) + } + // A pending clarify blocks the turn, so the first tool.complete after // one is the clarify resolving — drop the "needs input" flag here so // the sidebar indicator clears as soon as it's answered, not only at @@ -1120,6 +1152,11 @@ export function useMessageStream({ compactedTurnRef.current.delete(sessionId) } + if (isActiveEvent) { + setPetActivity({ reasoning: false, toolRunning: false }) + flashPetActivity({ error: true }) + } + dispatchNativeNotification({ body: errorMessage, kind: 'turnError', diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index 829119f65b4..f1a32771443 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -27,6 +27,7 @@ import { triggerHaptic } from '@/lib/haptics' import { setMutableRef } from '@/lib/mutable-ref' import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' import { setSessionYolo } from '@/lib/yolo-session' +import { openCommandPalettePage } from '@/store/command-palette' import { $composerAttachments, clearComposerAttachments, @@ -38,6 +39,7 @@ import { import { resetSessionBackground } from '@/store/composer-status' import { clearNotifications, notify, notifyError } from '@/store/notifications' import { requestDesktopOnboarding } from '@/store/onboarding' +import { setPetScale } from '@/store/pet-gallery' import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' import { $busy, @@ -57,8 +59,8 @@ import { clearSessionSubagents } from '@/store/subagents' import { clearSessionTodos } from '@/store/todos' import type { - ClientSessionState, BrowserManageResponse, + ClientSessionState, FileAttachResponse, HandoffFailResponse, HandoffRequestResponse, @@ -1143,6 +1145,35 @@ export function usePromptActions({ renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) } }, + pet: async ctx => { + const [sub = '', rawValue = ''] = ctx.arg.trim().split(/\s+/) + const lower = sub.toLowerCase() + + if (lower === 'list' || lower === 'gallery' || lower === 'browse' || lower === 'all') { + openCommandPalettePage('pets') + + return + } + + // `/pet scale ` resizes the floating pet locally (instant) and + // persists via the store — no round-trip to the slash worker. + if (lower === 'scale') { + const value = Number(rawValue) + + if (!rawValue || Number.isNaN(value)) { + const resolved = await withSlashOutput(ctx) + resolved?.render('usage: /pet scale (e.g. /pet scale 0.5)') + + return + } + + setPetScale(requestGateway, value) + + return + } + + await runExec(ctx) + }, // /browser connect|disconnect|status manages the live CDP connection on // the gateway host, mirroring the TUI's browser.manage RPC. It mutates // BROWSER_CDP_URL (and may launch Chrome) in the gateway process — only @@ -1359,6 +1390,7 @@ export function usePromptActions({ const cancelRun = useCallback(async () => { const sessionId = activeSessionId || activeSessionIdRef.current + const releaseBusy = () => { setMutableRef(busyRef, false) setBusy(false) diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index 80b74090f33..36fb9e91687 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -1,30 +1,31 @@ import { useStore } from '@nanostores/react' -import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useEffect, useState } from 'react' import { LanguageSwitcher } from '@/components/language-switcher' import { SegmentedControl } from '@/components/ui/segmented-control' +import type { DesktopMarketplaceSearchItem } from '@/global' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons' +import { selectableCardClass } from '@/lib/selectable-card' import { cn } from '@/lib/utils' import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile' import { $toolViewMode, setToolViewMode } from '@/store/tool-view' import { $translucency, setTranslucency } from '@/store/translucency' -import { useTheme } from '@/themes/context' +import { getBaseColors, useTheme } from '@/themes/context' import { installVscodeThemeFromMarketplace } from '@/themes/install' -import { isUserTheme, removeUserTheme, resolveTheme } from '@/themes/user-themes' +import { isUserTheme, removeUserTheme } from '@/themes/user-themes' import { MODE_OPTIONS } from './constants' +import { PetSettings } from './pet-settings' import { ListRow, SectionHeading, SettingsContent } from './primitives' -function ThemePreview({ name }: { name: string }) { - const t = resolveTheme(name) - - if (!t) { - return null - } - - const c = t.colors +function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' }) { + // Preview in the *current* mode: the dark palette in Dark, and the light + // palette in Light — synthesizing one for dark-only themes — so every card + // tracks the Light/Dark toggle, exactly like the app itself does. + const c = getBaseColors(name, mode) return (
(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value) + + useEffect(() => { + const handle = setTimeout(() => setDebounced(value), delayMs) + + return () => clearTimeout(handle) + }, [value, delayMs]) + + return debounced +} + +const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 }) + +/** + * Live VS Code Marketplace theme search (the same backend as the Cmd-K "Install + * theme…" page). Renders below the local grid when there's a query: each row + * downloads + converts + installs via `installVscodeThemeFromMarketplace` and + * activates it. Extensions already imported locally are marked installed. + */ +function MarketplaceThemeResults({ + query, + installedExtIds, + onInstalled +}: { + query: string + installedExtIds: Set + onInstalled: (name: string) => void +}) { const { t } = useI18n() - const { setTheme } = useTheme() - const a = t.settings.appearance - const [id, setId] = useState('') - const [busy, setBusy] = useState(false) - const [status, setStatus] = useState<{ kind: 'error' | 'success'; text: string } | null>(null) + const copy = t.commandCenter.installTheme + const debounced = useDebounced(query.trim(), 300) + const [installingId, setInstallingId] = useState(null) + const [installedHere, setInstalledHere] = useState>({}) + const [error, setError] = useState(null) - const install = async () => { - const trimmed = id.trim() + const search = useQuery({ + enabled: debounced.length > 0, + queryFn: () => window.hermesDesktop?.themes?.searchMarketplace(debounced) ?? Promise.resolve([]), + queryKey: ['marketplace-themes-settings', debounced], + staleTime: 5 * 60 * 1000 + }) - if (!trimmed || busy) { + const install = async (item: DesktopMarketplaceSearchItem) => { + if (installingId) { return } - setBusy(true) - setStatus(null) + setInstallingId(item.extensionId) + setError(null) try { - const theme = await installVscodeThemeFromMarketplace(trimmed) + const theme = await installVscodeThemeFromMarketplace(item.extensionId) triggerHaptic('crisp') - setTheme(theme.name) - setStatus({ kind: 'success', text: a.installed(theme.label) }) - setId('') - } catch (error) { - setStatus({ kind: 'error', text: error instanceof Error ? error.message : a.installError }) + setInstalledHere(prev => ({ ...prev, [item.extensionId]: true })) + onInstalled(theme.name) + } catch (e) { + setError(e instanceof Error ? e.message : copy.error) } finally { - setBusy(false) + setInstallingId(null) } } - return ( -
-
- { - setId(event.target.value) - setStatus(null) - }} - onKeyDown={event => { - if (event.key === 'Enter') { - void install() - } - }} - placeholder={a.installPlaceholder} - spellCheck={false} - value={id} - /> - -
- {status && ( -

- {status.text} + if (!debounced) { + return null + } + + const header = ( +

+ From the VS Code Marketplace +

+ ) + + if (search.isLoading) { + return ( + <> + {header} +

+ + {copy.loading}

- )} -
+ + ) + } + + if (search.isError) { + return ( + <> + {header} +

{copy.error}

+ + ) + } + + const results = search.data ?? [] + + if (results.length === 0) { + return ( + <> + {header} +

{copy.empty}

+ + ) + } + + return ( + <> + {header} + {error &&

{error}

} +
+ {results.map(item => { + const busy = installingId === item.extensionId + const done = installedHere[item.extensionId] || installedExtIds.has(item.extensionId) + + return ( + + ) + })} +
+ ) } export function AppearanceSettings() { const { t, isSavingLocale } = useI18n() - const { themeName, mode, availableThemes, setTheme, setMode } = useTheme() + const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme() const toolViewMode = useStore($toolViewMode) const translucency = useStore($translucency) const profiles = useStore($profiles) const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile)) const a = t.settings.appearance + const [query, setQuery] = useState('') + + // One box does double duty: filter installed themes live (below), and run a + // name search against the VS Code Marketplace (the Cmd-K "Install theme…" + // backend) for anything not already installed. + const needle = query.trim().toLowerCase() + + const filteredThemes = availableThemes + .filter( + theme => + !needle || + theme.label.toLowerCase().includes(needle) || + theme.name.toLowerCase().includes(needle) || + theme.description.toLowerCase().includes(needle) + ) + // Active theme first; stable sort keeps the rest in their original order. + .sort((a, b) => Number(b.name === themeName) - Number(a.name === themeName)) + + // Marketplace imports describe themselves as "VS Code · "; + // pull those ids back out so search results already imported show as installed. + const MARKETPLACE_DESC_PREFIX = 'VS Code · ' + + const installedExtIds = new Set( + availableThemes + .map(theme => + theme.description.startsWith(MARKETPLACE_DESC_PREFIX) + ? theme.description.slice(MARKETPLACE_DESC_PREFIX.length) + : '' + ) + .filter(Boolean) + ) + // Themes save per profile. Surface that only when the user actually has more // than one profile (single-profile installs never see the distinction). const showProfileNote = profiles.length > 1 @@ -163,7 +274,7 @@ export function AppearanceSettings() { {a.intro}

-
+
} description={isSavingLocale ? t.language.saving : t.language.description} @@ -171,18 +282,107 @@ export function AppearanceSettings() { /> { - triggerHaptic('crisp') - setMode(id) - }} - options={modeOptions} - value={mode} - /> + below={ + <> + {/* One search box: filters your installed themes (the grid) + and live-searches the VS Code Marketplace below. */} +
+ setQuery(event.target.value)} + placeholder="Search your themes or the VS Code Marketplace…" + spellCheck={false} + value={query} + /> +
+ + {/* Fixed-height scroll area so the (growing) theme list never + runs the page long; the grid scrolls inside it. */} +
+ {filteredThemes.length === 0 ? ( + needle ? ( +

+ No installed themes match "{query.trim()}". +

+ ) : null + ) : ( +
+ {filteredThemes.map(theme => { + const active = themeName === theme.name + const removable = isUserTheme(theme.name) + + return ( +
+ + {removable && ( + + )} +
+ ) + })} +
+ )} + setTheme(name)} + query={query} + /> +
+ {showProfileNote && ( +

+ {a.themeProfileNote(activeProfileName)} +

+ )} + } - description={a.colorModeDesc} - title={a.colorMode} + description={a.themeDesc} + title={ +
+ {a.themeTitle} + { + triggerHaptic('crisp') + setMode(id) + }} + options={modeOptions} + value={mode} + /> +
+ } + wide /> - -
- {availableThemes.map(theme => { - const active = themeName === theme.name - const removable = isUserTheme(theme.name) - - return ( -
- - {removable && ( - - )} -
- ) - })} -
- - {showProfileNote && ( -

- {a.themeProfileNote(activeProfileName)} -

- )} - - } - description={a.themeDesc} - title={a.themeTitle} - wide - /> -
+ +
+ +
) } diff --git a/apps/desktop/src/app/settings/pet-settings.tsx b/apps/desktop/src/app/settings/pet-settings.tsx new file mode 100644 index 00000000000..e9b0e925ce1 --- /dev/null +++ b/apps/desktop/src/app/settings/pet-settings.tsx @@ -0,0 +1,231 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useState } from 'react' + +import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' +import { PetThumb } from '@/components/pet/pet-thumb' +import { SegmentedControl } from '@/components/ui/segmented-control' +import { useI18n } from '@/i18n' +import { triggerHaptic } from '@/lib/haptics' +import { Loader2, PawPrint, Trash2 } from '@/lib/icons' +import { selectableCardClass } from '@/lib/selectable-card' +import { cn } from '@/lib/utils' +import { $petInfo } from '@/store/pet' +import { + $petBusy, + $petGallery, + $petGalleryError, + $petGalleryStatus, + adoptPet, + loadPetGallery, + loadPetThumb, + PET_SCALE_DEFAULT, + PET_SCALE_MAX, + PET_SCALE_MIN, + rankedGalleryPets, + removePet as removePetAction, + setPetEnabled, + setPetScale +} from '@/store/pet-gallery' +import { $gatewayState } from '@/store/session' + +import { ListRow, SectionHeading } from './primitives' + +/** + * Appearance opt-in for the floating petdex mascot. A thin view over the shared + * `pet-gallery` store — it subscribes to the atoms and calls the store actions, + * so the gallery is fetched once + cached and adopt/toggle/remove patch local + * state instead of re-pulling the network gallery. The floating mascot polls + * `pet.info`, so picking a pet here lights it up within a couple seconds. + */ +export function PetSettings() { + const { t } = useI18n() + const copy = t.settings.appearance.pet + const { requestGateway } = useGatewayRequest() + const gatewayState = useStore($gatewayState) + const gallery = useStore($petGallery) + const status = useStore($petGalleryStatus) + const error = useStore($petGalleryError) + const busySlug = useStore($petBusy) + const petInfo = useStore($petInfo) + const [query, setQuery] = useState('') + const scale = petInfo.scale ?? PET_SCALE_DEFAULT + + useEffect(() => { + if (gatewayState !== 'open') { + return + } + + void loadPetGallery(requestGateway) + }, [gatewayState, requestGateway]) + + const enabled = gallery?.enabled ?? false + const active = gallery?.active ?? '' + const pets = gallery?.pets ?? [] + const staleBackend = status === 'stale' + + const selectPet = (slug: string) => { + void adoptPet(requestGateway, slug, copy.adoptFailed(slug)).then(ok => ok && triggerHaptic('crisp')) + } + + const removePet = (slug: string) => { + void removePetAction(requestGateway, slug, copy.uninstallFailed(slug)).then(ok => ok && triggerHaptic('crisp')) + } + + const toggle = (on: boolean) => { + void setPetEnabled(requestGateway, on, { + noneAvailable: copy.noneAvailable, + fallback: on ? copy.turnOnFailed : copy.turnOffFailed + }).then(ok => ok && triggerHaptic('crisp')) + } + + // The petdex catalog is thousands of entries, so rank + cap how many render. + const RENDER_CAP = 60 + const sorted = rankedGalleryPets(gallery, query) + const shown = sorted.slice(0, RENDER_CAP) + + return ( +
+ +

+ {copy.intro} +

+ + {staleBackend && ( +

+ {copy.restartHint} +

+ )} + +
+ + setQuery(event.target.value)} + placeholder={copy.searchPlaceholder} + spellCheck={false} + value={query} + /> + {/* Fixed-height scroll area so filtering never grows/shrinks the + page (no layout thrash); the grid scrolls inside it. */} +
+ {pets.length === 0 ? ( +

+ {copy.unreachable} +

+ ) : shown.length === 0 ? ( +

+ {copy.noMatch(query)} +

+ ) : ( +
+ {shown.map(pet => { + const isActive = enabled && active === pet.slug + const isBusy = busySlug === pet.slug + + return ( +
+ + {pet.installed && !isBusy && ( + + )} +
+ ) + })} +
+ )} +
+ {/* Always-present status line so its appearance never shifts layout. */} +

+ {error ? ( + {error} + ) : sorted.length > RENDER_CAP ? ( + copy.countCapped(RENDER_CAP, sorted.length) + ) : ( + copy.count(sorted.length) + )} +

+ + } + description={copy.chooseDesc} + title={ +
+ {copy.chooseTitle} + void toggle(id === 'on')} + options={[ + { id: 'off', label: copy.off }, + { id: 'on', label: copy.on } + ]} + value={enabled ? 'on' : 'off'} + /> +
+ } + wide + /> + + {enabled && ( + + { + triggerHaptic('selection') + setPetScale(requestGateway, Number(event.target.value)) + }} + step={0.05} + style={{ accentColor: 'var(--dt-primary)' }} + type="range" + value={scale} + /> + + {`${Math.round(scale * 100)}%`} + +
+ } + description={copy.scaleDesc} + title={copy.scaleTitle} + /> + )} +
+
+ ) +} diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx index 7cbcaacfb41..b0981681c6c 100644 --- a/apps/desktop/src/app/shell/app-shell.tsx +++ b/apps/desktop/src/app/shell/app-shell.tsx @@ -4,6 +4,7 @@ import { useSyncExternalStore } from 'react' import { NotificationStack } from '@/components/notifications' import { PaneShell } from '@/components/pane-shell' +import { FloatingPet } from '@/components/pet/floating-pet' import { SidebarProvider } from '@/components/ui/sidebar' import { useMediaQuery } from '@/hooks/use-media-query' import { @@ -202,6 +203,10 @@ export function AppShell({ {/* Mounted at the shell root (after overlays) so success/error toasts surface above every route and overlay — not just the chat view. */} + + {/* Petdex floating mascot — in-window, always-on-top, reactive to agent + activity. Renders nothing unless a pet is installed + enabled. */} + ) } diff --git a/apps/desktop/src/components/pet/floating-pet.tsx b/apps/desktop/src/components/pet/floating-pet.tsx new file mode 100644 index 00000000000..d69c35ab6b6 --- /dev/null +++ b/apps/desktop/src/components/pet/floating-pet.tsx @@ -0,0 +1,313 @@ +import { useStore } from '@nanostores/react' +import { useCallback, useEffect, useRef, useState } from 'react' + +import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' +import { persistString, storedString } from '@/lib/storage' +import { $petInfo, clearPetUnread, type PetInfo, petProfile, setPetInfo } from '@/store/pet' +import { resetPetGallery } from '@/store/pet-gallery' +import { $petOverlayActive, initPetOverlayBridge, popOutPet, restorePetOverlay } from '@/store/pet-overlay' +import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' +import { $gatewayState } from '@/store/session' +import { isSecondaryWindow } from '@/store/windows' +import { useTheme } from '@/themes/context' + +import { PetSprite } from './pet-sprite' + +// v2: positions are now top/left anchored (v1 stored bottom-anchored values, +// which dragged inverted). Bumping the key discards stale v1 coordinates. +const POSITION_KEY = 'hermes.desktop.pet-position.v2' + +interface Point { + x: number + y: number +} + +function clampToViewport({ x, y }: Point): Point { + const maxX = Math.max(0, (window.innerWidth || 800) - 80) + const maxY = Math.max(0, (window.innerHeight || 600) - 80) + + return { x: Math.min(Math.max(0, x), maxX), y: Math.min(Math.max(0, y), maxY) } +} + +// The sprite art faces left by default, so mirror it when the pet's center sits +// on the left half of the window — it always faces inward, toward the content. +function facing(leftX: number, petW: number): string { + return leftX + petW / 2 < (window.innerWidth || 800) / 2 ? 'scaleX(-1)' : 'none' +} + +function loadPosition(): Point { + try { + const raw = storedString(POSITION_KEY) + + if (raw) { + const parsed = JSON.parse(raw) as Point + + if (typeof parsed.x === 'number' && typeof parsed.y === 'number') { + return clampToViewport(parsed) + } + } + } catch { + // fall through to default + } + + // Default: lower-left corner (top/left anchored). + return clampToViewport({ x: 24, y: (window.innerHeight || 600) - 220 }) +} + +/** + * In-window floating petdex mascot. Always-on-top within the app, draggable, + * and reactive to agent activity via `$petState`. Fetches the active pet via + * the shared `pet.info` RPC; renders nothing until a pet is installed + + * enabled. + * + * Adopting a pet is fully in-app: type `/pet boba` in the composer. That + * writes `display.pet.*` from the slash worker, so we keep polling `pet.info` + * while no pet is active and the mascot pops in within a few seconds — no + * reload, no CLI. Once a pet is live we stop polling. + * + * Promotion to a separate frameless OS-level window is a follow-up — the + * sprite + state logic here is reused as-is, only the host changes. + */ +const PET_POLL_MS = 3000 + +export function FloatingPet() { + const { requestGateway } = useGatewayRequest() + const { resolvedMode } = useTheme() + const gatewayState = useStore($gatewayState) + const info = useStore($petInfo) + const overlayActive = useStore($petOverlayActive) + + const [position, setPosition] = useState(loadPosition) + const containerRef = useRef(null) + // The facing mirror lives on the sprite wrapper, not the container, so the + // speech bubble (a container child) never renders flipped/backwards. + const spriteWrapRef = useRef(null) + const petW = (info.frameW ?? 192) * (info.scale ?? 0.33) + // Soft contact shadow, sized off the pet so every scale/species grounds the + // same way (cf. lairp's per-actor feet ellipse). Lighter on light backgrounds. + const shadowW = Math.round(petW * 0.55) + const shadowH = Math.max(3, Math.round(shadowW * 0.28)) + const shadowAlpha = resolvedMode === 'light' ? 0.2 : 0.55 + // Live drag offset (pointer → element top-left). Drag updates the DOM + // directly to avoid a React re-render (and canvas reflow) per pointermove — + // state is only committed on release. + const dragRef = useRef<{ dx: number; dy: number; x: number; y: number } | null>(null) + + // Fetch pet.info on connect, then keep polling while no pet is active so an + // in-app `/pet ` shows up live. Stops polling once a pet is enabled. + const active = info.enabled && Boolean(info.spritesheetBase64) + useEffect(() => { + if (gatewayState !== 'open' || active) { + return + } + + let cancelled = false + + const pull = async () => { + try { + const next = await requestGateway('pet.info', { profile: petProfile() }) + + if (!cancelled && next) { + setPetInfo(next) + } + } catch { + // cosmetic feature — never surface gateway errors + } + } + + void pull() + const timer = window.setInterval(() => void pull(), PET_POLL_MS) + + return () => { + cancelled = true + window.clearInterval(timer) + } + }, [gatewayState, active, requestGateway]) + + // Pets are per-profile. When the active profile changes, drop the previous + // profile's mascot + gallery cache so the poll above refetches the new + // profile's pet (its config + pets dir resolve per-profile on the backend). + const profileRef = useRef(normalizeProfileKey($activeGatewayProfile.get())) + useEffect( + () => + $activeGatewayProfile.subscribe(next => { + const key = normalizeProfileKey(next) + + if (key === profileRef.current) { + return + } + + profileRef.current = key + setPetInfo({ enabled: false }) + resetPetGallery() + }), + [] + ) + + // Wire the overlay control channel once, only in the primary window — the + // pop-out overlay belongs to it (main.cjs positions it against the main + // window and routes control messages back to it). + useEffect(() => { + if (isSecondaryWindow()) { + return + } + + return initPetOverlayBridge() + }, []) + + // Returning to the app (by any route, not just the mail icon) clears the pet's + // "new message" hint — you've seen it now. + useEffect(() => { + if (isSecondaryWindow()) { + return + } + + const onFocus = () => clearPetUnread() + window.addEventListener('focus', onFocus) + + return () => window.removeEventListener('focus', onFocus) + }, []) + + // Restore a popped-out pet on boot, once the pet has loaded (so we never spawn + // an empty overlay window). Primary window only; runs at most once. + const restoredRef = useRef(false) + useEffect(() => { + if (isSecondaryWindow() || restoredRef.current || !active) { + return + } + + restoredRef.current = true + restorePetOverlay() + }, [active]) + + // A window resize must never strand the pet off-screen — re-clamp the + // committed position (and persist it) whenever the viewport shrinks. + useEffect(() => { + const onResize = () => + setPosition(prev => { + const next = clampToViewport(prev) + + if (next.x === prev.x && next.y === prev.y) { + return prev + } + + persistString(POSITION_KEY, JSON.stringify(next)) + + return next + }) + + window.addEventListener('resize', onResize) + + return () => window.removeEventListener('resize', onResize) + }, []) + + const onPointerDown = useCallback((e: React.PointerEvent) => { + const el = containerRef.current + + if (!el) { + return + } + + const rect = el.getBoundingClientRect() + + // Shift-click pops the pet out into a free-floating desktop overlay (it can + // leave the window and stays visible while Hermes is minimized) instead of + // starting an in-window drag. Primary window only — the overlay is anchored + // to it. + if (e.shiftKey && !isSecondaryWindow()) { + popOutPet({ height: rect.height, width: rect.width, x: rect.left, y: rect.top }) + + return + } + + dragRef.current = { dx: e.clientX - rect.left, dy: e.clientY - rect.top, x: rect.left, y: rect.top } + el.setPointerCapture(e.pointerId) + el.style.cursor = 'grabbing' + }, []) + + const onPointerMove = useCallback( + (e: React.PointerEvent) => { + const drag = dragRef.current + const el = containerRef.current + + if (!drag || !el) { + return + } + + const next = clampToViewport({ x: e.clientX - drag.dx, y: e.clientY - drag.dy }) + drag.x = next.x + drag.y = next.y + // Mutate the DOM directly — no setState, so no re-render while dragging. The + // mirror follows the pointer across the midline for the same reason; it + // rides the sprite wrapper so the bubble stays upright. + el.style.left = `${next.x}px` + el.style.top = `${next.y}px` + + if (spriteWrapRef.current) { + spriteWrapRef.current.style.transform = facing(next.x, petW) + } + }, + [petW] + ) + + const onPointerUp = useCallback((e: React.PointerEvent) => { + const drag = dragRef.current + + if (drag) { + dragRef.current = null + const committed = { x: drag.x, y: drag.y } + setPosition(committed) + persistString(POSITION_KEY, JSON.stringify(committed)) + } + + const el = containerRef.current + + if (el) { + el.style.cursor = 'grab' + el.releasePointerCapture?.(e.pointerId) + } + }, []) + + // While popped out, the desktop overlay window owns the mascot — hide the + // in-window one so there aren't two. + if (!info.enabled || !info.spritesheetBase64 || overlayActive) { + return null + } + + return ( +
+
+
+ +
+
+ ) +} diff --git a/apps/desktop/src/components/pet/pet-bubble.tsx b/apps/desktop/src/components/pet/pet-bubble.tsx new file mode 100644 index 00000000000..3d3c8109681 --- /dev/null +++ b/apps/desktop/src/components/pet/pet-bubble.tsx @@ -0,0 +1,142 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useState } from 'react' + +import { AlertCircle, Clock, type IconComponent } from '@/lib/icons' +import { $petActivity, $petState, type PetState } from '@/store/pet' + +/** + * Speech bubble + status glyph for the popped-out pet overlay — the + * "notification" half of the mascot. It externalizes what the agent is doing + * (Codex-style) so a glance at the desktop pet replaces switching back to the + * window. The in-window pet doesn't show it (the app itself is the surface); + * only the overlay renders it. + * + * Text is derived purely from the same `$petState` / `$petActivity` the sprite + * already reacts to, so it never drifts from the animation. The bubble is shown + * only when there's something worth saying (working / reviewing / a transient + * done/error beat / waiting on the user) and is hidden at plain idle. + */ + +type Tone = 'error' | 'wait' + +interface Spec { + lines: string[] + glyph?: IconComponent + tone?: Tone +} + +// Phrasings per mood, picked at random (no immediate repeat) for a bit of life. +// Keep them short — the bubble is tiny and never wraps. +const SPECS: Partial> = { + run: { + lines: ['working…', 'on it…', 'crunching…', 'tinkering…', 'cooking…', 'in the weeds…', 'wiring it up…', 'making moves…', 'heads down…', 'hammering away…'] + }, + review: { + lines: ['thinking…', 'reading…', 'reviewing…', 'pondering…', 'connecting dots…', 'sizing it up…', 'tracing it…', 'mulling…', 'scheming…', 'hmm…'] + }, + failed: { + glyph: AlertCircle, + lines: ['hit a snag', 'welp', 'that broke', 'oof', 'snagged'], + tone: 'error' + }, + waiting: { + glyph: Clock, + lines: ['your turn', 'all yours', 'over to you', 'ball’s in your court', 'awaiting orders'], + tone: 'wait' + } +} + +const TONE_COLOR: Record = { + error: 'var(--ui-red)', + wait: 'var(--ui-yellow)' +} + +// Random pick that avoids repeating the line we're already showing. +function pick(lines: string[], prev: string): string { + if (lines.length <= 1) { + return lines[0] ?? '' + } + + let next = prev + + while (next === prev) { + next = lines[Math.floor(Math.random() * lines.length)] + } + + return next +} + +export function PetBubble() { + const state = useStore($petState) + const activity = useStore($petActivity) + const [line, setLine] = useState('') + + // Finish beats are carried by the sprite/mail icon; idle only speaks up when + // it's actually the user's turn. Everything else maps to a mood spec. + const specKey: null | PetState = + state in SPECS ? state : state === 'idle' && activity.awaitingInput ? 'waiting' : null + const rotating = specKey === 'run' || specKey === 'review' + + // Pick a fresh line on every mood change, then keep rotating (random, no + // repeat) only while the agent is actively working/thinking. + useEffect(() => { + const spec = specKey ? SPECS[specKey] : null + + if (!spec) { + setLine('') + + return + } + + setLine(prev => pick(spec.lines, prev)) + + if (!rotating || spec.lines.length <= 1) { + return + } + + const id = window.setInterval(() => setLine(prev => pick(spec.lines, prev)), 2600) + + return () => window.clearInterval(id) + }, [specKey, rotating]) + + const spec = specKey ? SPECS[specKey] : null + + if (!spec) { + return null + } + + const Glyph = spec.glyph + const text = line || spec.lines[0] + const hasText = Boolean(text) + + return ( +
+ {Glyph && ( + + + + )} + {text} +
+ ) +} diff --git a/apps/desktop/src/components/pet/pet-sprite.tsx b/apps/desktop/src/components/pet/pet-sprite.tsx new file mode 100644 index 00000000000..753c6f34af2 --- /dev/null +++ b/apps/desktop/src/components/pet/pet-sprite.tsx @@ -0,0 +1,178 @@ +import { memo, useEffect, useMemo, useRef } from 'react' + +import { $petState, type PetInfo, type PetState } from '@/store/pet' + +const DEFAULT_FRAME_W = 192 +const DEFAULT_FRAME_H = 208 +const DEFAULT_FRAMES = 6 +const DEFAULT_LOOP_MS = 1100 +// Mirrors agent.pet.constants.DEFAULT_SCALE — fallback only; the gateway sends +// the configured scale. +const DEFAULT_SCALE = 0.33 +// Mirrors agent.pet.constants.CODEX_STATE_ROWS (Petdex current taxonomy). +const DEFAULT_STATE_ROWS = [ + 'idle', + 'running-right', + 'running-left', + 'waving', + 'jumping', + 'failed', + 'waiting', + 'running', + 'review' +] + +const STATE_ALIASES: Record = { + idle: ['idle'], + wave: ['wave', 'waving'], + jump: ['jump', 'jumping'], + run: ['run', 'running'], + failed: ['failed'], + review: ['review'], + waiting: ['waiting'] +} + +interface PetSpriteProps { + info: PetInfo + /** On-screen scale multiplier applied on top of the pet's native scale. */ + zoom?: number +} + +/** + * Canvas renderer for a petdex spritesheet — the one piece that must be + * TypeScript (the engine's decode/encode is Python). Draws the row matching the + * live `$petState`, stepping `framesPerState` frames across a `loopMs` loop. + * + * State is read from `$petState` via a ref + subscription rather than a prop, + * so the frequent activity-driven state changes during an agent turn update the + * canvas (inside its RAF loop) WITHOUT triggering a React re-render. Combined + * with `memo`, this component effectively never re-renders after mount until + * the pet itself changes. + */ +function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) { + const canvasRef = useRef(null) + const stateRef = useRef($petState.get()) + + const frameW = info.frameW ?? DEFAULT_FRAME_W + const frameH = info.frameH ?? DEFAULT_FRAME_H + const frames = info.framesPerState ?? DEFAULT_FRAMES + const framesByState = info.framesByState + const loopMs = info.loopMs ?? DEFAULT_LOOP_MS + const scale = (info.scale ?? DEFAULT_SCALE) * zoom + const rows = info.stateRows ?? DEFAULT_STATE_ROWS + + const drawW = Math.round(frameW * scale) + const drawH = Math.round(frameH * scale) + + const image = useMemo(() => { + if (!info.spritesheetBase64) { + return null + } + + const img = new Image() + img.src = `data:${info.mime ?? 'image/webp'};base64,${info.spritesheetBase64}` + + return img + }, [info.spritesheetBase64, info.mime]) + + useEffect(() => { + const canvas = canvasRef.current + + if (!canvas || !image) { + return + } + + const ctx = canvas.getContext('2d') + + if (!ctx) { + return + } + + // Track state via subscription, not a prop — no re-render on activity ticks. + stateRef.current = $petState.get() + + const unsubState = $petState.listen(next => { + stateRef.current = next + }) + + let raf = 0 + let frame = 0 + let lastStep = performance.now() + let drawnFrame = -1 + let drawnRow = -1 + + const rowIndexForState = (s: PetState): number => { + for (const key of STATE_ALIASES[s] ?? [s]) { + const idx = rows.indexOf(key) + if (idx >= 0) { + return idx + } + } + return 0 + } + + // Resolve a state to the row it draws and its real frame count. A state + // with no real frames (ragged sheet, empty row) falls back to idle rather + // than flashing blank padding. + const resolve = (s: PetState): { row: number; count: number } => { + const real = framesByState?.[s] ?? frames + if (real > 0) { + return { row: rowIndexForState(s), count: real } + } + + return { row: rowIndexForState('idle'), count: Math.max(1, framesByState?.idle ?? frames) } + } + + const render = (now: number) => { + const { row, count } = resolve(stateRef.current) + // Per-state step keeps every state's loop ~loopMs even when frame counts + // differ; counts vary per row so derive the cadence here, not once. + const stepMs = loopMs / count + + if (now - lastStep >= stepMs) { + frame += 1 + lastStep = now + } + + frame %= count + + // Only touch the canvas when the visible cell actually changes. The RAF + // ticks at ~60Hz but the sprite only steps ~5Hz, so this skips ~90% of + // the clear+draw work and keeps the main thread free. + if ((frame !== drawnFrame || row !== drawnRow) && image.complete && image.naturalWidth > 0) { + const sx = frame * frameW + const sy = row * frameH + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.imageSmoothingEnabled = false + ctx.drawImage(image, sx, sy, frameW, frameH, 0, 0, drawW, drawH) + drawnFrame = frame + drawnRow = row + } + + raf = requestAnimationFrame(render) + } + + raf = requestAnimationFrame(render) + + return () => { + cancelAnimationFrame(raf) + unsubState() + } + }, [image, frameW, frameH, frames, framesByState, loopMs, drawW, drawH, rows]) + + return ( + + ) +} + +/** + * Memoized so a parent re-render (e.g. a position commit on drag-end) doesn't + * re-run the canvas setup. Props change only when the pet itself changes. + */ +export const PetSprite = memo(PetSpriteImpl) diff --git a/apps/desktop/src/components/pet/pet-thumb.tsx b/apps/desktop/src/components/pet/pet-thumb.tsx new file mode 100644 index 00000000000..088514c0804 --- /dev/null +++ b/apps/desktop/src/components/pet/pet-thumb.tsx @@ -0,0 +1,79 @@ +import { useEffect, useRef, useState } from 'react' + +import { PawPrint } from '@/lib/icons' + +// petdex frames are a fixed 192×208 grid; the box matches that aspect. +const THUMB_W = 40 +const THUMB_H = Math.round((THUMB_W * 208) / 192) + +export type PetThumbLoader = (slug: string, url?: string) => Promise + +/** + * Idle-frame preview for one pet. The backend crops + caches the frame and + * returns it as a same-origin data URI (`pet.thumb`), which dodges the renderer + * CSP / R2 hotlink rules that break a direct ``. + */ +export function PetThumb({ + slug, + url, + alt, + load, + size = THUMB_W +}: { + slug: string + url?: string + alt: string + load: PetThumbLoader + /** Width in px; height follows the petdex frame aspect. */ + size?: number +}) { + const [src, setSrc] = useState(null) + const boxRef = useRef(null) + const height = Math.round((size * 208) / 192) + + useEffect(() => { + const el = boxRef.current + + if (!el || src) { + return + } + + const observer = new IntersectionObserver( + entries => { + if (entries.some(entry => entry.isIntersecting)) { + observer.disconnect() + void load(slug, url).then(uri => { + if (uri) { + setSrc(uri) + } + }) + } + }, + { rootMargin: '120px' } + ) + + observer.observe(el) + + return () => observer.disconnect() + }, [slug, url, src, load]) + + return ( + + {src ? ( + {alt} + ) : ( + + )} + + ) +} diff --git a/apps/desktop/src/components/ui/command.tsx b/apps/desktop/src/components/ui/command.tsx index dbbc655d690..4324c8e8e68 100644 --- a/apps/desktop/src/components/ui/command.tsx +++ b/apps/desktop/src/components/ui/command.tsx @@ -17,7 +17,12 @@ function Command({ className, ...props }: React.ComponentProps) { +interface CommandInputProps extends React.ComponentProps { + /** Inline trailing slot, rendered on the right of the search row. */ + right?: React.ReactNode +} + +function CommandInput({ className, right, ...props }: CommandInputProps) { return (
@@ -29,6 +34,7 @@ function CommandInput({ className, ...props }: React.ComponentProps + {right}
) } diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index c615ad2d61a..5e41d3e7423 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -1,3 +1,10 @@ +import type { + PetOverlayBounds, + PetOverlayControl, + PetOverlayOpenRequest, + PetOverlayStatePayload +} from './store/pet-overlay' + export {} declare global { @@ -26,6 +33,20 @@ declare global { openSessionWindow: (sessionId: string, opts?: { watch?: boolean }) => Promise<{ ok: boolean; error?: string }> // Open (or focus) a compact secondary window on the new-session draft. openNewSessionWindow: () => Promise<{ ok: boolean; error?: string }> + // The pop-out pet overlay: a transparent always-on-top window hosting only + // the mascot. The main renderer drives it (open/close/drag + state push); + // the overlay sends control messages back (pop-in, composer submit). + petOverlay: { + open: (request: PetOverlayOpenRequest) => Promise<{ ok: boolean; bounds?: PetOverlayBounds }> + close: () => Promise<{ ok: boolean }> + setBounds: (bounds: PetOverlayBounds) => void + setIgnoreMouse: (ignore: boolean) => void + setFocusable: (focusable: boolean) => void + pushState: (payload: PetOverlayStatePayload) => void + control: (payload: PetOverlayControl) => void + onState: (callback: (payload: PetOverlayStatePayload) => void) => () => void + onControl: (callback: (payload: PetOverlayControl) => void) => () => void + } getBootProgress: () => Promise getConnectionConfig: (profile?: null | string) => Promise saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 3c1a7ec3879..e8be5a6dec8 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -366,7 +366,32 @@ export const en: Translations = { installError: 'Could not install that theme.', installed: name => `Installed “${name}”.`, removeTheme: 'Remove theme', - importedBadge: 'Imported' + importedBadge: 'Imported', + pet: { + title: 'Pet', + intro: + 'Adopt an animated petdex mascot that floats over the app and reacts to what Hermes is doing — running while tools execute, celebrating on success, sulking on errors.', + restartHint: + 'Pets need a quick restart — the running app started before this feature was added. Quit and reopen Hermes, then come back here.', + on: 'On', + off: 'Off', + scaleTitle: 'Size', + scaleDesc: 'Resize the floating mascot. Applies everywhere instantly.', + chooseTitle: 'Choose a pet', + chooseDesc: 'Picking one installs it (if needed) and makes it active.', + searchPlaceholder: 'Search pets…', + unreachable: "Couldn't reach the petdex gallery. Check your connection and reopen this page.", + noMatch: query => `No pets match "${query}".`, + installedTag: 'installed', + countCapped: (cap, total) => `Showing ${cap} of ${total} — type to narrow it down.`, + count: n => `${n} pet${n === 1 ? '' : 's'}.`, + uninstall: name => `Uninstall ${name}`, + adoptFailed: slug => `Could not adopt ${slug}`, + uninstallFailed: slug => `Could not uninstall ${slug}`, + noneAvailable: 'No pets available to turn on right now.', + turnOnFailed: 'Could not turn the pet on.', + turnOffFailed: 'Could not turn the pet off.' + } }, fieldLabels: FIELD_LABELS, fieldDescriptions: FIELD_DESCRIPTIONS, @@ -714,8 +739,22 @@ export const en: Translations = { commandCenter: 'Command Center', appearance: 'Appearance', settings: 'Settings', - changeTheme: 'Change theme...', + changeTheme: 'Change theme', changeColorMode: 'Change color mode...', + pets: { + title: 'Pets', + placeholder: 'Search pets…', + loading: 'Loading petdex gallery…', + error: 'Could not reach the petdex gallery.', + staleBackend: 'Restart Hermes to use pets — the backend predates this feature.', + empty: 'No matching pets.', + turnOff: 'Turn off', + turnOn: 'Turn on', + installed: 'Installed', + adoptFailed: 'Could not adopt that pet.', + toggleFailed: 'Could not toggle the pet.', + noneAvailable: 'No pets available — pick one below to install.' + }, installTheme: { title: 'Install theme...', placeholder: 'Search the VS Code Marketplace...', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 904e4b25c53..3a28b50aac3 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -281,7 +281,32 @@ export const ja = defineLocale({ installError: 'そのテーマをインストールできませんでした。', installed: name => `「${name}」をインストールしました。`, removeTheme: 'テーマを削除', - importedBadge: 'インポート済み' + importedBadge: 'インポート済み', + pet: { + title: 'ペット', + intro: + 'アプリ上に浮かぶ petdex のアニメーションマスコットを採用しましょう。ツール実行中は走り、成功すると喜び、エラーでしょんぼりと、Hermes の状態に反応します。', + restartHint: + 'ペット機能には再起動が必要です。この機能が追加される前に起動したアプリが動作中です。Hermes を終了して再度開き、このページに戻ってください。', + scaleTitle: 'サイズ', + scaleDesc: '浮遊マスコットの大きさを変更します。すべての画面に即時反映されます。', + on: 'オン', + off: 'オフ', + chooseTitle: 'ペットを選ぶ', + chooseDesc: '選ぶと(必要に応じて)インストールされ、アクティブになります。', + searchPlaceholder: 'ペットを検索…', + unreachable: 'petdex ギャラリーに接続できませんでした。接続を確認してこのページを開き直してください。', + noMatch: query => `「${query}」に一致するペットがありません。`, + installedTag: 'インストール済み', + countCapped: (cap, total) => `${total} 件中 ${cap} 件を表示中——入力して絞り込めます。`, + count: n => `${n} 件のペット。`, + uninstall: name => `${name} をアンインストール`, + adoptFailed: slug => `${slug} を採用できませんでした`, + uninstallFailed: slug => `${slug} をアンインストールできませんでした`, + noneAvailable: 'オンにできるペットがありません。', + turnOnFailed: 'ペットをオンにできませんでした。', + turnOffFailed: 'ペットをオフにできませんでした。' + } }, fieldLabels: defineFieldCopy({ model: 'デフォルトモデル', @@ -834,8 +859,22 @@ export const ja = defineLocale({ commandCenter: 'コマンドセンター', appearance: '外観', settings: '設定', - changeTheme: 'テーマを変更...', + changeTheme: 'テーマを変更', changeColorMode: 'カラーモードを変更...', + pets: { + title: 'ペット', + placeholder: 'ペットを検索…', + loading: 'petdex ギャラリーを読み込み中…', + error: 'petdex ギャラリーに接続できません。', + staleBackend: 'ペット機能を使うには Hermes を再起動してください。', + empty: '一致するペットがありません。', + turnOff: 'オフ', + turnOn: 'オン', + installed: 'インストール済み', + adoptFailed: 'ペットを採用できませんでした。', + toggleFailed: 'ペットを切り替えできませんでした。', + noneAvailable: '利用可能なペットがありません。' + }, installTheme: { title: 'テーマをインストール...', placeholder: 'VS Code Marketplace を検索...', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index dcf1028fb4b..70807da8bf7 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -265,6 +265,29 @@ export interface Translations { installed: (name: string) => string removeTheme: string importedBadge: string + pet: { + title: string + intro: string + restartHint: string + on: string + off: string + scaleTitle: string + scaleDesc: string + chooseTitle: string + chooseDesc: string + searchPlaceholder: string + unreachable: string + noMatch: (query: string) => string + installedTag: string + countCapped: (cap: number, total: number) => string + count: (n: number) => string + uninstall: (name: string) => string + adoptFailed: (slug: string) => string + uninstallFailed: (slug: string) => string + noneAvailable: string + turnOnFailed: string + turnOffFailed: string + } } fieldLabels: Record fieldDescriptions: Record @@ -594,6 +617,20 @@ export interface Translations { settings: string changeTheme: string changeColorMode: string + pets: { + title: string + placeholder: string + loading: string + error: string + staleBackend: string + empty: string + turnOff: string + turnOn: string + installed: string + adoptFailed: string + toggleFailed: string + noneAvailable: string + } installTheme: { title: string placeholder: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 8f208aff341..3e1420d3414 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -271,7 +271,30 @@ export const zhHant = defineLocale({ installError: '無法安裝該主題。', installed: name => `已安裝「${name}」。`, removeTheme: '移除主題', - importedBadge: '已匯入' + importedBadge: '已匯入', + pet: { + title: '寵物', + intro: '領養一隻懸浮在應用上的 petdex 動畫寵物,它會根據 Hermes 的狀態做出反應——工具執行時奔跑、成功時歡呼、出錯時沮喪。', + restartHint: '寵物功能需要重新啟動——目前執行的應用在此功能加入前啟動。請結束並重新開啟 Hermes,然後回到此處。', + scaleTitle: '大小', + scaleDesc: '調整懸浮寵物的大小,所有介面即時生效。', + on: '開啟', + off: '關閉', + chooseTitle: '選擇寵物', + chooseDesc: '選擇後會自動安裝(如需)並設為目前寵物。', + searchPlaceholder: '搜尋寵物…', + unreachable: '無法連線至 petdex 畫廊。請檢查網路連線並重新開啟此頁面。', + noMatch: query => `沒有符合「${query}」的寵物。`, + installedTag: '已安裝', + countCapped: (cap, total) => `顯示 ${total} 個中的 ${cap} 個——輸入關鍵字以縮小範圍。`, + count: n => `${n} 個寵物。`, + uninstall: name => `解除安裝 ${name}`, + adoptFailed: slug => `無法領養 ${slug}`, + uninstallFailed: slug => `無法解除安裝 ${slug}`, + noneAvailable: '目前沒有可開啟的寵物。', + turnOnFailed: '無法開啟寵物。', + turnOffFailed: '無法關閉寵物。' + } }, fieldLabels: defineFieldCopy({ model: '預設模型', @@ -807,8 +830,22 @@ export const zhHant = defineLocale({ commandCenter: '命令中心', appearance: '外觀', settings: '設定', - changeTheme: '變更主題...', + changeTheme: '變更主題', changeColorMode: '變更色彩模式...', + pets: { + title: '寵物', + placeholder: '搜尋寵物…', + loading: '正在載入 petdex 畫廊…', + error: '無法連線至 petdex 畫廊。', + staleBackend: '請重新啟動 Hermes 以使用寵物功能。', + empty: '沒有符合的寵物。', + turnOff: '關閉', + turnOn: '開啟', + installed: '已安裝', + adoptFailed: '無法領養該寵物。', + toggleFailed: '無法切換寵物顯示。', + noneAvailable: '尚無可用寵物——請在下方選擇一個安裝。' + }, installTheme: { title: '安裝主題...', placeholder: '搜尋 VS Code Marketplace...', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index f368d3585ca..34ddd474359 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -359,7 +359,30 @@ export const zh: Translations = { installError: '无法安装该主题。', installed: name => `已安装「${name}」。`, removeTheme: '移除主题', - importedBadge: '已导入' + importedBadge: '已导入', + pet: { + title: '宠物', + intro: '领养一只悬浮在应用上的 petdex 动画宠物,它会根据 Hermes 的状态做出反应——工具执行时奔跑、成功时欢呼、出错时沮丧。', + restartHint: '宠物功能需要重启——当前运行的应用在此功能加入前启动。请退出并重新打开 Hermes,然后回到此处。', + scaleTitle: '大小', + scaleDesc: '调整悬浮宠物的大小,所有界面即时生效。', + on: '开启', + off: '关闭', + chooseTitle: '选择宠物', + chooseDesc: '选择后会自动安装(如需)并设为当前宠物。', + searchPlaceholder: '搜索宠物…', + unreachable: '无法连接到 petdex 画廊。请检查网络连接并重新打开此页面。', + noMatch: query => `没有匹配「${query}」的宠物。`, + installedTag: '已安装', + countCapped: (cap, total) => `显示 ${total} 个中的 ${cap} 个——输入关键词以缩小范围。`, + count: n => `${n} 个宠物。`, + uninstall: name => `卸载 ${name}`, + adoptFailed: slug => `无法领养 ${slug}`, + uninstallFailed: slug => `无法卸载 ${slug}`, + noneAvailable: '当前没有可开启的宠物。', + turnOnFailed: '无法开启宠物。', + turnOffFailed: '无法关闭宠物。' + } }, fieldLabels: defineFieldCopy({ model: '默认模型', @@ -904,8 +927,22 @@ export const zh: Translations = { commandCenter: '命令中心', appearance: '外观', settings: '设置', - changeTheme: '更改主题...', + changeTheme: '更改主题', changeColorMode: '更改颜色模式...', + pets: { + title: '宠物', + placeholder: '搜索宠物…', + loading: '正在加载 petdex 画廊…', + error: '无法连接到 petdex 画廊。', + staleBackend: '请重启 Hermes 以使用宠物功能——当前后端版本过旧。', + empty: '没有匹配的宠物。', + turnOff: '关闭', + turnOn: '开启', + installed: '已安装', + adoptFailed: '无法领养该宠物。', + toggleFailed: '无法切换宠物显示。', + noneAvailable: '暂无可用宠物——请在下方选择一个安装。' + }, installTheme: { title: '安装主题...', placeholder: '搜索 VS Code Marketplace...', diff --git a/apps/desktop/src/lib/desktop-slash-commands.test.ts b/apps/desktop/src/lib/desktop-slash-commands.test.ts index 54f5a6f89df..8e30e5bfcfb 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.test.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.test.ts @@ -52,6 +52,16 @@ describe('desktop slash command curation', () => { expect(desktopSlashUnavailableMessage('/personality')).toBeNull() }) + it('routes /pet through the desktop action handler and drops /pets', () => { + expect(resolveDesktopCommand('/pet')?.surface).toEqual({ kind: 'action', action: 'pet' }) + expect(resolveDesktopCommand('/pet')?.args).toBe(true) + expect(isDesktopSlashSuggestion('/pet')).toBe(true) + expect(isDesktopSlashCommand('/pet')).toBe(true) + expect(resolveDesktopCommand('/pets')?.surface).toEqual({ kind: 'unavailable', reason: 'settings' }) + expect(isDesktopSlashSuggestion('/pets')).toBe(false) + expect(isDesktopSlashCommand('/pets')).toBe(false) + }) + it('treats /browser as an executable action command (local-gateway connect)', () => { // /browser used to be terminal-only; it now resolves to a desktop action // handler that routes browser.manage RPC when the gateway is local. diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index f9ae934edf4..e1a0f2d773c 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -34,6 +34,7 @@ export type DesktopActionId = | 'handoff' | 'help' | 'new' + | 'pet' | 'profile' | 'skin' | 'title' @@ -128,6 +129,7 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ { name: '/debug', description: 'Create a debug report', surface: exec() }, { name: '/goal', description: 'Manage the standing goal for this session', surface: exec() }, { name: '/personality', description: 'Switch personality for this session', surface: exec(), args: true }, + { name: '/pet', description: 'Toggle or adopt a petdex mascot (/pet, /pet list, /pet boba)', surface: action('pet'), args: true }, { name: '/queue', description: 'Queue a prompt for the next turn', aliases: ['/q'], surface: exec() }, { name: '/retry', description: 'Retry the last user message', surface: exec() }, { name: '/rollback', description: 'List or restore filesystem checkpoints', surface: exec() }, @@ -155,7 +157,7 @@ const NO_DESKTOP_SURFACE: Record = '/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose' ], messaging: ['/approve', '/deny'], - settings: ['/skills'], + settings: ['/skills', '/pets'], advanced: ['/curator', '/fast', '/insights', '/kanban', '/reasoning', '/voice'] } diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index a2cd4ec7b0b..9e07f529ce6 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -51,6 +51,7 @@ import { IconLoader2 as Loader2Icon, IconLock as Lock, IconLogin as LogIn, + IconMail as Mail, IconMessageCircle as MessageCircle, IconMessage2 as MessageSquareText, IconMicrophone as Mic, @@ -67,6 +68,7 @@ import { IconLayoutBottombar as PanelBottom, IconLayoutSidebar as PanelLeftIcon, IconPlayerPause as Pause, + IconPaw as PawPrint, IconPencil as Pencil, IconPencil as PencilIcon, IconPencil as PencilLine, @@ -153,6 +155,7 @@ export { Loader2Icon, Lock, LogIn, + Mail, MessageCircle, MessageSquareText, Mic, @@ -169,6 +172,7 @@ export { PanelBottom, PanelLeftIcon, Pause, + PawPrint, Pencil, PencilIcon, PencilLine, diff --git a/apps/desktop/src/lib/selectable-card.ts b/apps/desktop/src/lib/selectable-card.ts new file mode 100644 index 00000000000..617898b7e23 --- /dev/null +++ b/apps/desktop/src/lib/selectable-card.ts @@ -0,0 +1,31 @@ +import { cn } from '@/lib/utils' + +export interface SelectableCardState { + /** Currently selected / active — the strongest emphasis. */ + active?: boolean + /** + * Configured / installed / "you have this" — solid surface + border. When + * false the card renders muted (transparent, dimmed) until hovered, so the + * eye lands on what you already have. Ignored when `active` is set. + */ + prominent?: boolean +} + +/** + * Shared emphasis for selectable list cards across settings surfaces (theme + * picker, pet picker, Marketplace results, provider rows…). Three tiers: + * active > prominent > muted. Keeps the "installed = solid, not-installed = + * quiet" pattern consistent everywhere instead of each picker rolling its own. + * + * Callers own layout (padding, flex, width); this owns only border + surface. + */ +export function selectableCardClass({ active, prominent }: SelectableCardState): string { + return cn( + 'rounded-lg border transition-colors', + active + ? 'border-primary bg-primary/[0.06] ring-2 ring-primary/20' + : prominent + ? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) hover:bg-(--chrome-action-hover)' + : 'border-transparent bg-transparent text-(--ui-text-tertiary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-bg-quinary)' + ) +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index b78c583264a..5b7621aa015 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -26,20 +26,27 @@ if (import.meta.env.MODE !== 'production') { import('./app/chat/perf-probe') } -createRoot(document.getElementById('root')!).render( - - - - - - - - - - - - - - - -) +// The pet overlay rides this same bundle (`?win=overlay`) but mounts a tiny, +// transparent, gateway-less surface instead of the full app. Branch before any +// app-shell work so the overlay window stays cheap. +if (new URLSearchParams(window.location.search).get('win') === 'overlay') { + void import('./app/pet-overlay/overlay-root').then(({ mountPetOverlay }) => mountPetOverlay()) +} else { + createRoot(document.getElementById('root')!).render( + + + + + + + + + + + + + + + + ) +} diff --git a/apps/desktop/src/store/command-palette.ts b/apps/desktop/src/store/command-palette.ts index d214d1c2f26..490e3ee1eac 100644 --- a/apps/desktop/src/store/command-palette.ts +++ b/apps/desktop/src/store/command-palette.ts @@ -3,16 +3,30 @@ import { atom } from 'nanostores' /** Whether the global command palette (Cmd/Ctrl+K) is currently open. */ export const $commandPaletteOpen = atom(false) +/** Optional nested page to open when the palette next opens (e.g. `pets`). */ +export const $commandPalettePage = atom(null) + export function openCommandPalette(): void { $commandPaletteOpen.set(true) } +/** Open the palette directly on a nested page (`theme`, `pets`, …). */ +export function openCommandPalettePage(page: string): void { + $commandPalettePage.set(page) + $commandPaletteOpen.set(true) +} + export function closeCommandPalette(): void { $commandPaletteOpen.set(false) + $commandPalettePage.set(null) } export function setCommandPaletteOpen(open: boolean): void { $commandPaletteOpen.set(open) + + if (!open) { + $commandPalettePage.set(null) + } } export function toggleCommandPalette(): void { diff --git a/apps/desktop/src/store/pet-gallery.ts b/apps/desktop/src/store/pet-gallery.ts new file mode 100644 index 00000000000..a9a23734b8b --- /dev/null +++ b/apps/desktop/src/store/pet-gallery.ts @@ -0,0 +1,322 @@ +import { atom } from 'nanostores' + +import { $petInfo, type PetInfo, petProfile, setPetInfo } from '@/store/pet' + +/** + * Feature store for the petdex gallery picker (Cmd+K "Pets…" + Settings). + * + * Why this exists: `pet.gallery` does a *network* manifest fetch on the gateway, + * so re-pulling it after every adopt/toggle made the picker feel laggy and made + * two components (palette + settings) each carry their own copy of the same + * fetch / thumb-cache / optimistic-mutation logic. This store centralizes it: + * + * - The gallery is fetched once and cached; reopening the picker is instant. + * - Mutations (adopt / enable / remove) patch local state and only re-pull the + * cheap, local `pet.info` — never the network manifest again. + * - Thumbnails are deduped in a process-global cache (the backend disk-caches + * too, so a slug is fetched at most once per session). + * + * Consumers just `useStore($petGallery)` and call the actions; no component + * owns gallery state anymore. + */ + +export interface GalleryPet { + slug: string + displayName: string + installed: boolean + spritesheetUrl?: string + /** petdex's hand-picked set — used only to rank "popular" pets first. */ + curated?: boolean +} + +export interface PetGallery { + enabled: boolean + active: string + pets: GalleryPet[] +} + +export type PetGalleryStatus = 'idle' | 'loading' | 'ready' | 'stale' | 'error' + +/** The recovering `requestGateway` from `useGatewayRequest` — passed in so the + * store reuses the hook's reconnect/reauth handling instead of duplicating it. */ +export type GatewayRequest = (method: string, params?: Record) => Promise + +/** Profile-scoped pet RPC. Pets are per-profile, so every call carries the active + * profile (the gateway no-ops it for the launch profile). One chokepoint so no + * call site can forget it. */ +const petRpc = (request: GatewayRequest, method: string, params: Record = {}): Promise => + request(method, { ...params, profile: petProfile() }) + +/** A JSON-RPC "method not found" — the backend predates the pet RPCs. */ +function isMissingMethod(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + + return /method not found|-32601|unknown method|no such method/i.test(message) +} + +export const $petGallery = atom(null) +export const $petGalleryStatus = atom('idle') +export const $petGalleryError = atom(null) + +// Which action is in flight, so rows/buttons can show a spinner. A slug for a +// per-pet mutation; the `TOGGLE_*` sentinels for the on/off switch. +export const TOGGLE_ON = '\u0000on' +export const TOGGLE_OFF = '\u0000off' +export const $petBusy = atom(null) + +// Process-global caches (survive component unmount → instant reopen). +const thumbCache = new Map>() +let galleryLoad: Promise | null = null + +/** + * Drop the cached gallery, thumbnails, and in-flight load so the next open + * refetches against the now-active profile's backend. Called on a profile switch + * (pets are per-profile) — the floating pet's own `pet.info` poll repaints the + * new profile's mascot, and the picker reloads its gallery on next mount. + */ +export function resetPetGallery(): void { + galleryLoad = null + thumbCache.clear() + $petGallery.set(null) + $petGalleryStatus.set('idle') + $petGalleryError.set(null) + $petBusy.set(null) +} + +export function loadPetThumb(request: GatewayRequest, slug: string, url?: string): Promise { + let pending = thumbCache.get(slug) + + if (!pending) { + pending = petRpc<{ ok: boolean; dataUri?: string }>(request, 'pet.thumb', { slug, url: url ?? '' }) + .then(result => (result?.ok && result.dataUri ? result.dataUri : null)) + .catch(() => null) + thumbCache.set(slug, pending) + } + + return pending +} + +/** + * Fetch the gallery once and cache it. Subsequent calls are no-ops while a + * ready snapshot is held; pass `{ force: true }` to bypass the cache (e.g. a + * manual refresh). Concurrent callers share a single in-flight request. + */ +export function loadPetGallery(request: GatewayRequest, options: { force?: boolean } = {}): Promise { + if (!options.force && $petGallery.get() && $petGalleryStatus.get() === 'ready') { + return Promise.resolve() + } + + if (galleryLoad) { + return galleryLoad + } + + galleryLoad = (async () => { + if (!$petGallery.get()) { + $petGalleryStatus.set('loading') + } + + try { + const [next, info] = await Promise.all([ + petRpc(request, 'pet.gallery'), + petRpc(request, 'pet.info') + ]) + + if (next) { + $petGallery.set(next) + $petGalleryStatus.set('ready') + $petGalleryError.set(null) + } + + if (info) { + setPetInfo(info) + } + } catch (e) { + if (isMissingMethod(e)) { + $petGalleryStatus.set('stale') + } else if (!$petGallery.get()) { + // Only surface a hard error when we have nothing to show; a transient + // hiccup mid-session leaves the cached gallery intact. + $petGalleryStatus.set('error') + $petGalleryError.set(e instanceof Error ? e.message : 'Could not reach the petdex gallery.') + } + } finally { + galleryLoad = null + } + })() + + return galleryLoad +} + +// Push the live mascot state (cheap, local config read) without re-pulling the +// network gallery — the floating pet repaints, the picker keeps its cache. +async function syncInfo(request: GatewayRequest): Promise { + try { + const info = await petRpc(request, 'pet.info') + + if (info) { + setPetInfo(info) + } + } catch { + // The mutation already succeeded; a stale mascot self-heals on its poll. + } +} + +/** + * Filter (drop the internal `clawd*` pets + apply a search query) and rank the + * gallery for a picker. Ranking has no popularity data, so it leans on the + * signals we do have: active pet first, then installed, then curated. Shared by + * the Cmd-K palette and the Settings grid so the two can't drift — each caller + * applies its own cap and reads `.length` for the total. + */ +export function rankedGalleryPets(gallery: PetGallery | null, query = ''): GalleryPet[] { + if (!gallery) { + return [] + } + + const needle = query.trim().toLowerCase() + + const rank = (p: GalleryPet) => + Number(gallery.enabled && p.slug === gallery.active) * 4 + Number(p.installed) * 2 + Number(p.curated) + + return gallery.pets + .filter( + p => + !/^clawd(-|$)/i.test(p.slug) && + (!needle || p.slug.toLowerCase().includes(needle) || p.displayName.toLowerCase().includes(needle)) + ) + .sort((a, b) => rank(b) - rank(a)) +} + +function patchGallery(fn: (gallery: PetGallery) => PetGallery): void { + const current = $petGallery.get() + + if (current) { + $petGallery.set(fn(current)) + } +} + +/** Shared mutation wrapper: spin, fire, patch on success, surface failures. */ +async function mutate( + busyKey: string, + fallback: string, + request: GatewayRequest, + run: () => Promise +): Promise { + $petBusy.set(busyKey) + $petGalleryError.set(null) + + try { + await run() + await syncInfo(request) + + return true + } catch (e) { + if (isMissingMethod(e)) { + $petGalleryStatus.set('stale') + } else { + $petGalleryError.set(e instanceof Error ? e.message : fallback) + } + + return false + } finally { + $petBusy.set(null) + } +} + +/** Install (if needed) + activate a pet. Optimistically marks it active. */ +export function adoptPet(request: GatewayRequest, slug: string, fallback: string): Promise { + return mutate(slug, fallback, request, async () => { + await petRpc(request, 'pet.select', { slug }) + patchGallery(g => ({ + ...g, + enabled: true, + active: slug, + pets: g.pets.map(p => (p.slug === slug ? { ...p, installed: true } : p)) + })) + }) +} + +/** + * Turn the floating mascot on/off. On enable, activates the current pet (or the + * first installed one). Returns false without firing if there's nothing to show. + */ +export function setPetEnabled( + request: GatewayRequest, + on: boolean, + copy: { noneAvailable: string; fallback: string } +): Promise { + const gallery = $petGallery.get() + + if (!on && !(gallery?.enabled ?? false)) { + return Promise.resolve(true) + } + + let slug = gallery?.active || '' + + if (on) { + slug = slug || gallery?.pets.find(p => p.installed)?.slug || '' + + if (!slug) { + $petGalleryError.set(copy.noneAvailable) + + return Promise.resolve(false) + } + } + + return mutate(on ? TOGGLE_ON : TOGGLE_OFF, copy.fallback, request, async () => { + if (on) { + await petRpc(request, 'pet.select', { slug }) + } else { + await petRpc(request, 'pet.disable') + } + + patchGallery(g => ({ ...g, enabled: on, active: on ? slug : g.active })) + }) +} + +// Pet scale bounds — mirror `agent/pet/constants.py` (MIN_SCALE / MAX_SCALE) so +// the slider and the server clamp to the same range. +export const PET_SCALE_MIN = 0.1 +export const PET_SCALE_MAX = 3.0 +export const PET_SCALE_DEFAULT = 0.33 +export const clampPetScale = (n: number) => Math.max(PET_SCALE_MIN, Math.min(PET_SCALE_MAX, n)) + +let scalePersist: ReturnType | undefined + +/** + * Resize the floating pet. Updates `$petInfo` synchronously so the on-screen pet + * (and the slider) react on the same frame, then debounce-persists to + * `display.pet.scale` so a slider drag fires one RPC, not one per pixel. No poll + * or event needed — the pet already renders from `$petInfo.scale`. + */ +export function setPetScale(request: GatewayRequest, scale: number): void { + const next = clampPetScale(scale) + + setPetInfo({ ...$petInfo.get(), scale: next }) + + clearTimeout(scalePersist) + scalePersist = setTimeout(() => { + petRpc<{ ok: boolean; scale?: number }>(request, 'pet.scale', { scale: next }) + .then(result => { + // Reconcile with the server's clamp (cheap; only matters at the bounds). + if (typeof result?.scale === 'number' && result.scale !== $petInfo.get().scale) { + setPetInfo({ ...$petInfo.get(), scale: result.scale }) + } + }) + .catch(() => { + // Cosmetic — the pet already resized; persistence self-heals next write. + }) + }, 200) +} + +/** Uninstall a pet; turns the mascot off if it was the active one. */ +export function removePet(request: GatewayRequest, slug: string, fallback: string): Promise { + return mutate(slug, fallback, request, async () => { + await petRpc(request, 'pet.remove', { slug }) + patchGallery(g => ({ + ...g, + enabled: g.active === slug ? false : g.enabled, + pets: g.pets.map(p => (p.slug === slug ? { ...p, installed: false } : p)) + })) + }) +} diff --git a/apps/desktop/src/store/pet-overlay.ts b/apps/desktop/src/store/pet-overlay.ts new file mode 100644 index 00000000000..3fda5e83b3c --- /dev/null +++ b/apps/desktop/src/store/pet-overlay.ts @@ -0,0 +1,260 @@ +import { atom } from 'nanostores' + +import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage' +import { $petActivity, $petInfo, $petUnread, clearPetUnread, type PetActivity, type PetInfo } from '@/store/pet' +import { $awaitingResponse, $busy } from '@/store/session' + +/** + * Controller for the pop-out pet overlay (main-renderer side). + * + * Shift-clicking the in-window pet "pops it out" into a transparent, + * always-on-top OS window (created in electron/main.cjs) that can leave the + * app's bounds and stays visible while Hermes is minimized. That window carries + * NO gateway connection — this renderer remains the single source of truth and + * pushes the live pet state to it over IPC. Control flows back (pop the pet back + * in, submit a composer message) via `onControl`. + * + * The overlay renders the same `PetSprite` / `PetBubble` as the in-window pet by + * mirroring the four reactive inputs of `$petState` (`$petInfo`, `$petActivity`, + * `$busy`, `$awaitingResponse`) into its own copies of those atoms — so the + * popped-out mascot is pixel-identical and needs zero bespoke render logic. + */ + +export interface PetOverlayBounds { + x: number + y: number + width: number + height: number +} + +/** + * Request to open the overlay window. `screen` says whether `bounds` are already + * in absolute screen coordinates (a remembered/dragged spot) or in the main + * window's viewport space (a fresh shift-click pop-out, which main.cjs converts + * by adding the content origin). + */ +export interface PetOverlayOpenRequest { + bounds: PetOverlayBounds + screen?: boolean +} + +/** Everything the overlay needs to reproduce the live mascot. */ +export interface PetOverlayStatePayload { + info: PetInfo + activity: PetActivity + busy: boolean + awaiting: boolean + /** Drives the overlay's mail icon: a finish landed while you were away. */ + unread: boolean +} + +export type PetOverlayControl = + | { type: 'pop-in' } + | { type: 'ready' } + | { type: 'submit'; text: string } + | { type: 'bounds'; bounds: PetOverlayBounds } + | { type: 'open-app' } + | { type: 'toggle-app' } + +// Persisted across restarts: was the pet popped out, and where on the desktop +// did the user leave it. Keyed v1; bump if the bounds shape ever changes. +const OVERLAY_ACTIVE_KEY = 'hermes.desktop.pet-overlay-active.v1' +const OVERLAY_BOUNDS_KEY = 'hermes.desktop.pet-overlay-bounds.v1' + +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)) + +function loadSavedBounds(): null | PetOverlayBounds { + try { + const raw = storedString(OVERLAY_BOUNDS_KEY) + + if (!raw) { + return null + } + + const parsed = JSON.parse(raw) as Partial + + if ( + typeof parsed.x === 'number' && + typeof parsed.y === 'number' && + typeof parsed.width === 'number' && + typeof parsed.height === 'number' + ) { + return { height: parsed.height, width: parsed.width, x: parsed.x, y: parsed.y } + } + } catch { + // fall through to null + } + + return null +} + +function saveBounds(bounds: PetOverlayBounds): void { + persistString(OVERLAY_BOUNDS_KEY, JSON.stringify(bounds)) +} + +// The overlay window is padded around the sprite so the bubble (above), the +// drag area, and the pop-up composer all have room; the pet sits near the +// bottom and the rest of the rectangle is transparent + click-through. +const OVERLAY_PAD_X = 100 +const OVERLAY_PAD_Y = 200 +const OVERLAY_MIN_W = 240 +const OVERLAY_MIN_H = 300 + +let stateUnsubs: Array<() => void> = [] +let controlUnsub: (() => void) | null = null +let submitHandler: ((text: string) => void) | null = null +let openAppHandler: (() => void) | null = null + +function currentPayload(): PetOverlayStatePayload { + return { + info: $petInfo.get(), + activity: $petActivity.get(), + busy: $busy.get(), + awaiting: $awaitingResponse.get(), + unread: $petUnread.get() + } +} + +function pushNow(): void { + window.hermesDesktop?.petOverlay?.pushState(currentPayload()) +} + +/** + * Open the overlay window and start mirroring live state into it. The main + * process echoes back the actual screen bounds it used, which we persist so the + * pet reopens exactly where the user left it. + */ +function openOverlay(request: PetOverlayOpenRequest): void { + const api = window.hermesDesktop?.petOverlay + + if (!api || stateUnsubs.length) { + return + } + + $petOverlayActive.set(true) + void api.open(request).then(res => { + if (res?.bounds) { + saveBounds(res.bounds) + } + + pushNow() + }) + + // Mirror live state into the overlay. subscribe() fires immediately, so the + // overlay also gets a first frame the moment it's ready (it asks via 'ready'). + stateUnsubs = [ + $petInfo.subscribe(pushNow), + $petActivity.subscribe(pushNow), + $busy.subscribe(pushNow), + $awaitingResponse.subscribe(pushNow), + $petUnread.subscribe(pushNow) + ] +} + +/** + * Pop the pet out of the window. `petRect` is the in-window sprite's viewport + * rect; we grow it to the padded overlay size and center the window on the + * pet's old spot (main.cjs adds the window's screen origin). If the user has + * popped out before, reopen at that remembered desktop spot instead. + */ +export function popOutPet(petRect: PetOverlayBounds): void { + if ($petOverlayActive.get() || stateUnsubs.length) { + return + } + + const saved = loadSavedBounds() + + if (saved) { + openOverlay({ bounds: saved, screen: true }) + + return + } + + const width = Math.max(OVERLAY_MIN_W, Math.round(petRect.width + OVERLAY_PAD_X)) + const height = Math.max(OVERLAY_MIN_H, Math.round(petRect.height + OVERLAY_PAD_Y)) + const x = Math.round(petRect.x - (width - petRect.width) / 2) + const y = Math.round(petRect.y - (height - petRect.height) / 2) + + openOverlay({ bounds: { height, width, x, y }, screen: false }) +} + +/** + * Restore the overlay on boot if the pet was popped out when the app last + * closed. Requires a remembered desktop spot — without one we fall back to the + * in-window pet rather than spawning an orphan window at the origin. + */ +export function restorePetOverlay(): void { + if (!window.hermesDesktop?.petOverlay || !$petOverlayActive.get() || stateUnsubs.length) { + return + } + + const saved = loadSavedBounds() + + if (!saved) { + $petOverlayActive.set(false) + + return + } + + openOverlay({ bounds: saved, screen: true }) +} + +/** Pop the pet back into the window (closes the overlay window). */ +export function popInPet(): void { + for (const off of stateUnsubs) { + off() + } + + stateUnsubs = [] + $petOverlayActive.set(false) + void window.hermesDesktop?.petOverlay?.close() +} + +/** Register the handler that turns an overlay composer submit into a real send. */ +export function setPetOverlaySubmitHandler(fn: ((text: string) => void) | null): void { + submitHandler = fn +} + +/** Register the handler that opens the app to the most recent thread (mail icon). */ +export function setPetOverlayOpenAppHandler(fn: (() => void) | null): void { + openAppHandler = fn +} + +/** + * Wire the overlay→renderer control channel once. Returns a disposer. Idempotent + * — a second call while already wired is a no-op. + */ +export function initPetOverlayBridge(): () => void { + const api = window.hermesDesktop?.petOverlay + + if (!api || controlUnsub) { + return () => {} + } + + controlUnsub = api.onControl(payload => { + if (payload?.type === 'pop-in') { + popInPet() + } else if (payload?.type === 'ready') { + // The overlay just mounted — hand it the current frame. + pushNow() + } else if (payload?.type === 'submit' && typeof payload.text === 'string') { + submitHandler?.(payload.text) + } else if (payload?.type === 'bounds' && payload.bounds) { + // The user dragged the overlay to a new desktop spot — remember it. + saveBounds(payload.bounds) + } else if (payload?.type === 'open-app') { + // Mail icon: surface the app on the most recent thread (main.cjs already + // focused the window before forwarding this) and mark it read. + clearPetUnread() + openAppHandler?.() + } + }) + + return () => { + controlUnsub?.() + controlUnsub = null + } +} diff --git a/apps/desktop/src/store/pet.test.ts b/apps/desktop/src/store/pet.test.ts new file mode 100644 index 00000000000..2837334ab37 --- /dev/null +++ b/apps/desktop/src/store/pet.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' + +import { $petActivity, $petState, derivePetState, flashPetActivity, setPetActivity } from './pet' + +describe('derivePetState', () => { + it('rests at idle by default and uses waiting when awaiting input', () => { + expect(derivePetState({})).toBe('idle') + expect(derivePetState({ awaitingInput: true })).toBe('waiting') + }) + + it('runs when busy or a tool is executing', () => { + expect(derivePetState({ busy: true })).toBe('run') + expect(derivePetState({ toolRunning: true })).toBe('run') + }) + + it('reviews while reasoning (below tool, above bare busy)', () => { + expect(derivePetState({ reasoning: true })).toBe('review') + expect(derivePetState({ reasoning: true, busy: true })).toBe('review') + expect(derivePetState({ reasoning: true, toolRunning: true })).toBe('run') + }) + + it('waits (blocked on the user) above the in-flight signals', () => { + expect(derivePetState({ awaitingInput: true, toolRunning: true, busy: true })).toBe('waiting') + // but a finish beat still wins over waiting + expect(derivePetState({ justCompleted: true, awaitingInput: true })).toBe('wave') + }) + + it('honors the full priority chain: error > celebrate > complete > tool', () => { + expect(derivePetState({ error: true, celebrate: true, busy: true })).toBe('failed') + expect(derivePetState({ celebrate: true, justCompleted: true, toolRunning: true })).toBe('jump') + expect(derivePetState({ justCompleted: true, toolRunning: true })).toBe('wave') + }) +}) + +describe('flashPetActivity', () => { + it('clears stale sibling beats so a completion never inherits a prior error', () => { + // A turn errors (sad), then the next turn finishes cleanly. The celebrate + // beat must win — error is highest priority, so a merge-only flash would + // keep the pet on the failed pose. + setPetActivity({ error: true }) + flashPetActivity({ celebrate: true }) + + expect($petActivity.get().error).toBe(false) + expect($petState.get()).toBe('jump') + + setPetActivity({}) + }) +}) diff --git a/apps/desktop/src/store/pet.ts b/apps/desktop/src/store/pet.ts new file mode 100644 index 00000000000..e4863f45712 --- /dev/null +++ b/apps/desktop/src/store/pet.ts @@ -0,0 +1,160 @@ +import { atom, computed } from 'nanostores' + +import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' +import { $busy } from '@/store/session' + +/** + * Petdex mascot state for the desktop floating pet. + * + * The spritesheet payload comes from the gateway `pet.info` RPC (shared with + * the TUI). The animation *state* is derived here from the same activity + * signals the chat already tracks, mirroring the priority order documented in + * `agent/pet/state.py` so the Python and TS surfaces never drift. + */ + +export type PetState = 'idle' | 'wave' | 'run' | 'failed' | 'review' | 'jump' | 'waiting' + +export interface PetInfo { + enabled: boolean + slug?: string + displayName?: string + mime?: string + spritesheetBase64?: string + frameW?: number + frameH?: number + framesPerState?: number + // Real (padding-trimmed) frame count per state row, from the engine. Lets the + // canvas step only frames that exist instead of a fixed framesPerState, which + // would animate into the transparent padding of ragged sheets (blank flash). + framesByState?: Record + loopMs?: number + scale?: number + stateRows?: string[] +} + +export interface PetActivity { + busy?: boolean + awaitingInput?: boolean + toolRunning?: boolean + reasoning?: boolean + error?: boolean + justCompleted?: boolean + celebrate?: boolean +} + +/** + * Resolve the animation state from coarse activity signals. + * + * Priority (highest first) mirrors `agent.pet.state.derive_pet_state`: + * error → celebrate → justCompleted → awaitingInput → toolRunning → reasoning → + * busy → idle. `awaitingInput` (a clarify/approval blocking on the user) outranks + * the in-flight signals because the turn is paused on you, not working. + */ +export function derivePetState(activity: PetActivity): PetState { + if (activity.error) { + return 'failed' + } + + if (activity.celebrate) { + return 'jump' + } + + if (activity.justCompleted) { + return 'wave' + } + + if (activity.awaitingInput) { + return 'waiting' + } + + if (activity.toolRunning) { + return 'run' + } + + if (activity.reasoning) { + return 'review' + } + + if (activity.busy) { + return 'run' + } + + return 'idle' +} + +export const $petInfo = atom({ enabled: false }) +export const $petActivity = atom({}) + +/** + * 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 + * HERMES_HOME — so every pet RPC carries this. The gateway no-ops it for the + * launch profile (own-profile backends already resolve it) and rebinds for any + * other profile, which is what makes per-profile pets work in app-global remote + * mode (one backend serving every profile). + */ +export function petProfile(): string { + return normalizeProfileKey($activeGatewayProfile.get()) +} + +/** + * Pet-local "you have a new message" flag, surfaced as the overlay's mail icon. + * Deliberately not real unread tracking: it flips on when a turn finishes while + * the app isn't focused, and off when the user opens the app via the mail icon + * (or returns to the window). No persistence — it's a glance hint, not state. + */ +export const $petUnread = atom(false) +export const markPetUnread = () => $petUnread.set(true) +export const clearPetUnread = () => $petUnread.set(false) + +/** Steady activity flags (toolRunning / reasoning) set + cleared by the stream. */ +export const setPetActivity = (next: Partial) => + $petActivity.set({ ...$petActivity.get(), ...next }) + +let flashTimer: ReturnType | undefined + +/** Fire a transient reaction beat (error / celebrate / justCompleted) that + * decays back to the steady state after `ms`. + * + * Each beat first clears its siblings so a stale one can't win the priority + * race: without this, a completion beat (`celebrate`) would merge on top of a + * lingering `error`, and `derivePetState` checks `error` first — so a clean + * finish would render the sad/failed pose. */ +export const flashPetActivity = (next: Partial, ms = 1600) => { + setPetActivity({ celebrate: false, error: false, justCompleted: false, ...next }) + clearTimeout(flashTimer) + flashTimer = setTimeout( + () => setPetActivity({ celebrate: false, error: false, justCompleted: false }), + ms + ) +} + +export const setPetInfo = (info: PetInfo) => $petInfo.set(info) + +/** + * The live pet state. Derives from the dedicated activity atom, falling back to + * the always-present `$busy` chat signal so the pet reacts out of the box. + * + * `awaitingInput` (a clarify/approval blocking on the user) is an explicit flag + * on `$petActivity` — set by the controller from `$attentionSessionIds` and + * mirrored to the pop-out overlay through the same atom, so both surfaces agree + * without the overlay needing the session list. + */ +export const $petState = computed( + [$petActivity, $busy], + (activity, busy): PetState => { + const live = activity.busy ?? busy + + return derivePetState({ + busy: live, + awaitingInput: activity.awaitingInput, + // Steady flags only count mid-turn — ignore stale ones once at rest so an + // interrupted turn can't pin the pet on `run`/`review`. + toolRunning: live && activity.toolRunning, + reasoning: live && activity.reasoning, + error: activity.error, + justCompleted: activity.justCompleted, + celebrate: activity.celebrate + }) + } +)