diff --git a/docs/design/pet-overlay.md b/docs/design/pet-overlay.md new file mode 100644 index 00000000000..a9764217253 --- /dev/null +++ b/docs/design/pet-overlay.md @@ -0,0 +1,98 @@ +# Design: Pet Overlay (v1) + +**Branch:** `add-a-pet-for-orca` +**Status:** Draft — scoped for fast ship + +## Problem + +Orca has no idle-time personality surface. A small animated pet in the bottom-right corner gives the app a recognizable mascot and is the kind of detail users screenshot and share. It needs to be opt-in (some users find mascots distracting) and cheap when disabled. + +## Goals (v1) + +- Floating pet overlay pinned to bottom-right, above terminal panes and below modals. +- Toggled by an experimental flag in Settings → Experimental. +- "Hide pet" menu from a status-bar segment for quick dismissal without disabling the feature. +- Visually 3D / animated, not a flat SVG. +- Zero cost when disabled — no renderer weight, no dangling listeners. + +## Non-goals (v1) + +- Tint, accessories, full Pet Studio — follow-up. +- Drag-to-reposition — follow-up. Position is fixed bottom-right. +- Reactions to app state beyond a single idle loop — follow-up. +- Pet-to-pet multiplayer, voice/audio, reacting to code or AI output. + +**In v1:** a model picker across three bundled GLBs (gremlin, blue demon, green blob) **plus user-uploaded custom GLBs**. Selection lives in `petModelId` on `PersistedUIState`, flipped from a Model submenu in the status-bar pet dropdown. Custom models live in `userData/pets/custom/.glb` on disk and are listed alongside bundled models in the submenu, each with an inline Remove button. Upload goes through a `pet:import` IPC that opens a native file picker, validates size/extension, and copies the file into userData; the renderer fetches bytes via `pet:read` and renders them via a `blob:` URL so `webSecurity=true` and `sandbox=true` can stay enabled. Kept tiny on purpose — no separate pane, no per-model settings; this is the customization surface that fits on the existing dropdown without introducing a Pet Studio. + +## Format decision: glTF (GLB) via three.js + +Considered options: + +| Format | 3D? | Size | Notes | +|---|---|---|---| +| SVG / Lottie | No | Tiny | Can't do realistic shading | +| Transparent WebM (VP9 alpha) | Pre-rendered | Medium | Baked pixels, no runtime variation | +| Sprite sheet (PNG frames) | No | Large | Rigid, no angle changes | +| **GLB + three.js** | Yes | ~500KB model + ~150KB gz (~600KB raw) for three + fiber subset | Real 3D, room to grow into customization | + +**Decision: GLB + three.js.** Even without v1 customization, GLB is the only option that leaves the door open for the follow-up features without a format migration. + +Stack: `three` + `@react-three/fiber`. All imports live behind a dynamic `import()` gated on the experimental flag so disabled users pay zero bytes. + +## UI integration + +### Experimental flag + +Add to `GlobalSettings` in `src/shared/types.ts`: + +```ts +/** Experimental: floating animated pet overlay in the bottom-right corner. + * Opt-in because it loads three.js (~150KB gz) and a GLB model; users who + * don't want it should pay zero cost. When false, the overlay component + * is never mounted and three.js is never imported. */ +experimentalPet: boolean +``` + +Default `false`. Same pattern as `experimentalAgentDashboard` at `src/shared/types.ts:1047`, with one deliberate difference: `experimentalAgentDashboard` requires a relaunch because it gates hook installation in the main process; `experimentalPet` is purely renderer-side, so toggling it mounts/unmounts `PetOverlay` immediately and triggers the dynamic `import()` on first enable within a session. + +### Status bar segment + +`src/renderer/src/components/status-bar/StatusBar.tsx` gets a new `PetStatusSegment` (only rendered when `experimentalPet` is on). It does **not** participate in the user-configurable `statusBarItems` array (`src/shared/types.ts:1090`) in v1 — that list is for always-available provider/system segments, and gating pet visibility via two independent toggles (experimental flag + status-bar checkbox + in-menu "Hide pet") would be confusing. Revisit if/when pet promotes out of experimental. + +Clicking the segment opens a menu with: + +- **Hide pet** — flips a `petVisible` boolean stored on the ui slice. Kept separate from `experimentalPet` so a user can re-show without re-enabling the feature. +- **Pet settings…** — opens Settings → Experimental (just scrolls to the flag in v1; Pet Studio is follow-up). + +### Persistence + +`petVisible` must survive reload. Follow the existing pattern used by every other persisted UI field: + +1. Add `petVisible: boolean` to `PersistedUIState` in `src/shared/types.ts:1098`. +2. Hydrate it in `hydratePersistedUI` in `src/renderer/src/store/slices/ui.ts` (default `true` when absent, so existing users see the pet the first time they enable the flag). +3. Include it in whatever the ui slice emits to the main-process persistence writer. No new persistence channel is needed — this is the same path `statusBarVisible`, `sidebarWidth`, etc. take. + +`experimentalPet` itself lives on `GlobalSettings` and is persisted through the settings pipeline, not `PersistedUIState`. + +### Overlay component + +`src/renderer/src/components/pet/PetOverlay.tsx`: + +- Mounted once at the app root, conditional on `experimentalPet && petVisible`. +- Fixed bottom-right canvas, `pointer-events: none` on the whole canvas. All interaction goes through the status-bar segment: a pet that eats clicks in the bottom-right would silently block terminal-pane interaction near the corner, and hit-testing a transparent GLB against actual mesh bounds (not the canvas rect) is non-trivial. Revisit once drag-to-reposition lands. +- Transparent background; `z-index` just under the modal layer. +- Single idle animation clip on loop. +- Pause the animation loop when `document.visibilityState !== 'visible'` to avoid burning battery in the background. +- Respect `prefers-reduced-motion` via `window.matchMedia('(prefers-reduced-motion: reduce)')`: when reduced, render a static first frame and skip the animation loop. +- If WebGL context creation fails (some Linux/VM setups), unmount silently — do not crash the app root. Log once. + +### Architectural boundary + +No pet-specific IPC and no main-process pet logic. Rendering, animation loop, visibility-state handling, and the `petVisible` toggle all live in the renderer. The only thing that crosses IPC is the generic `PersistedUIState` write-back that `petVisible` rides on, alongside every other persisted ui-slice field — this is existing infrastructure, not new plumbing. This matches the boundary documented in `CLAUDE.md`: cosmetic per-window state stays in the renderer. + +## Rollout + +1. Land experimental flag + overlay + status-bar segment behind `experimentalPet=false`, with one bundled gremlin GLB in `resources/pets/`. +2. Dogfood internally; watch for WebGL init failures across macOS/Windows/Linux. +3. Follow-up PRs: drag-to-reposition (expands position to all four corners), tint + accessories, additional models, custom GLB loading, state-reactive animation clips. +4. Promote out of experimental once no WebGL-init crash reports surface from dogfood and external bug channels across macOS/Windows/Linux. There is no in-app telemetry pipeline (grep confirms), so the bar is "no reported failures" rather than a measured success rate — log WebGL-init failures to the renderer console with a stable prefix (`[pet-overlay]`) so users who hit it can surface the string when reporting, and so dogfooders can grep their own logs. diff --git a/docs/design/pet-overlay.mock.html b/docs/design/pet-overlay.mock.html new file mode 100644 index 00000000000..c7a6a5564cb --- /dev/null +++ b/docs/design/pet-overlay.mock.html @@ -0,0 +1,387 @@ + + + + +Pet Overlay — UI mock + + + +
+ + +
+
+
orca
+
add-a-pet-for-orca
+
share-text-search
+
+ + + + + +
+
+
~/orca/add-a-pet-for-orca $ pnpm dev
+
› Local: http://localhost:5173
+
› ready in 412ms
+
 
+
$ _
+
+
+
~/orca $ git status
+
On branch add-a-pet-for-orca
+
Untracked files:
+
  docs/design/pet-overlay.md
+
  docs/design/pet-overlay.mock.html
+
 
+
$ _
+
+ + + + +
GLB canvas — pointer-events: none, z-index: var(--pet-z)
+
PetStatusSegment ↓ (only when experimentalPet=true)
+ + +
+
Zero-cost-when-disabled
+
three.js never imported when experimentalPet=false. Component unmounts = no listeners, no RAF loop.
+
+
+
+
+
+
+ + +
+ claude · 78% + codex · 42% + ● 3 sessions + ssh + mem 412MB + + + + Pet + + + + v1.3.26-rc.0 +
+
+ + diff --git a/package.json b/package.json index 6f128277669..3e18f27696d 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,8 @@ "@linear/sdk": "^82.1.0", "@monaco-editor/react": "^4.7.0", "@parcel/watcher": "^2.5.6", + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.6.1", "@tanstack/react-virtual": "^3.13.23", "@tiptap/extension-code-block-lowlight": "^3.22.4", "@tiptap/extension-image": "^3.22.4", @@ -106,6 +108,7 @@ "sonner": "^2.0.7", "ssh2": "^1.17.0", "tailwind-merge": "^3.5.0", + "three": "^0.184.0", "tw-animate-css": "^1.4.0", "ws": "^8.20.0", "zod": "^4.3.6", @@ -122,6 +125,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/ssh2": "^1.15.5", + "@types/three": "^0.184.0", "@types/ws": "^8.18.1", "@typescript/native-preview": "7.0.0-dev.20260406.1", "@vitejs/plugin-react": "^5.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2335eb9b1f..2fb1be4c829 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,6 +40,12 @@ importers: '@parcel/watcher': specifier: ^2.5.6 version: 2.5.6 + '@react-three/drei': + specifier: ^10.7.7 + version: 10.7.7(@react-three/fiber@9.6.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.184.0))(@types/react@19.2.14)(@types/three@0.184.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.184.0) + '@react-three/fiber': + specifier: ^9.6.1 + version: 9.6.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.184.0) '@tanstack/react-virtual': specifier: ^3.13.23 version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -211,6 +217,9 @@ importers: tailwind-merge: specifier: ^3.5.0 version: 3.5.0 + three: + specifier: ^0.184.0 + version: 0.184.0 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -254,6 +263,9 @@ importers: '@types/ssh2': specifier: ^1.15.5 version: 1.15.5 + '@types/three': + specifier: ^0.184.0 + version: 0.184.0 '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -453,6 +465,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -487,6 +503,9 @@ packages: resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} engines: {node: '>= 8.9.0'} + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -995,6 +1014,9 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} + '@mediapipe/tasks-vision@0.10.17': + resolution: {integrity: sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==} + '@medv/finder@4.0.2': resolution: {integrity: sha512-RraNY9SCcx4KZV0Dh6BEW6XEW2swkqYca74pkFFRw6hHItSHiy+O/xMnpbofjYbzXj0tSpBGthUF1hHTsr3vIQ==} @@ -1021,6 +1043,11 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@monogrid/gainmap-js@3.4.0': + resolution: {integrity: sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==} + peerDependencies: + three: '>= 0.159.0' + '@mswjs/interceptors@0.41.3': resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} engines: {node: '>=18'} @@ -2176,6 +2203,42 @@ packages: resolution: {integrity: sha512-IE4bTeH0mCq0FBRaYRUtdiIfvO7NCv14lHS4TiTY8YNG5tPYwHnLZZtniud0ElmLIWGP95Kk6E7A6J2uDmOY+Q==} hasBin: true + '@react-three/drei@10.7.7': + resolution: {integrity: sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==} + peerDependencies: + '@react-three/fiber': ^9.0.0 + react: ^19 + react-dom: ^19 + three: '>=0.159' + peerDependenciesMeta: + react-dom: + optional: true + + '@react-three/fiber@9.6.1': + resolution: {integrity: sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==} + peerDependencies: + expo: '>=43.0' + expo-asset: '>=8.4' + expo-file-system: '>=11.0' + expo-gl: '>=11.0' + react: '>=19 <19.3' + react-dom: '>=19 <19.3' + react-native: '>=0.78' + three: '>=0.156' + peerDependenciesMeta: + expo: + optional: true + expo-asset: + optional: true + expo-file-system: + optional: true + expo-gl: + optional: true + react-dom: + optional: true + react-native: + optional: true + '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} @@ -2666,6 +2729,9 @@ packages: '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2786,6 +2852,9 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/draco3d@1.4.10': + resolution: {integrity: sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -2825,6 +2894,9 @@ packages: '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@types/offscreencanvas@2019.7.3': + resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==} + '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} @@ -2850,9 +2922,15 @@ packages: '@types/ssh2@1.15.5': resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/three@0.184.0': + resolution: {integrity: sha512-4mY2tZAu0y0B0567w7013BBXSpsP0+Z48NJvmNo4Y/Pf76yCyz6Jw4P3tUVs10WuYNXXZ+wmHyGWpCek3amJxA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -2871,6 +2949,9 @@ packages: '@types/verror@1.10.11': resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -2922,6 +3003,14 @@ packages: '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@use-gesture/core@10.3.1': + resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==} + + '@use-gesture/react@10.3.1': + resolution: {integrity: sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==} + peerDependencies: + react: '>= 16.8.0' + '@vitejs/plugin-react@5.2.0': resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3139,6 +3228,9 @@ packages: resolution: {integrity: sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==} engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -3186,6 +3278,9 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + buildcheck@0.0.7: resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} engines: {node: '>=10.0.0'} @@ -3233,6 +3328,12 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camera-controls@3.1.2: + resolution: {integrity: sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==} + engines: {node: '>=22.0.0', npm: '>=10.5.1'} + peerDependencies: + three: '>=0.126.1' + caniuse-lite@1.0.30001780: resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==} @@ -3447,6 +3548,11 @@ packages: cross-dirname@0.1.0: resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} + cross-env@7.0.3: + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -3696,6 +3802,9 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-gpu@5.0.70: + resolution: {integrity: sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -3743,6 +3852,9 @@ packages: resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} engines: {node: '>=12'} + draco3d@1.5.7: + resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -3993,6 +4105,12 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + fflate@0.6.10: + resolution: {integrity: sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==} + + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -4148,6 +4266,9 @@ packages: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} + glsl-noise@0.0.0: + resolution: {integrity: sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -4237,6 +4358,9 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} + hls.js@1.6.16: + resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==} + hono@4.12.8: resolution: {integrity: sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==} engines: {node: '>=16.9.0'} @@ -4314,6 +4438,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -4418,6 +4545,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-promise@2.2.2: + resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -4464,6 +4594,11 @@ packages: resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} engines: {node: '>=18'} + its-fine@2.0.0: + resolution: {integrity: sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==} + peerDependencies: + react: ^19.0.0 + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -4557,6 +4692,9 @@ packages: lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -4703,6 +4841,12 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + maath@0.10.8: + resolution: {integrity: sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==} + peerDependencies: + '@types/three': '>=0.134.0' + three: '>=0.134.0' + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -4808,6 +4952,14 @@ packages: mermaid@11.14.0: resolution: {integrity: sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==} + meshline@3.3.1: + resolution: {integrity: sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==} + peerDependencies: + three: '>=0.137' + + meshoptimizer@1.1.1: + resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -5303,6 +5455,9 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + potpack@1.0.2: + resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} + powershell-utils@0.1.0: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} @@ -5329,6 +5484,9 @@ packages: resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} engines: {node: '>=10'} + promise-worker-transferable@1.0.4: + resolution: {integrity: sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -5476,6 +5634,15 @@ packages: '@types/react': optional: true + react-use-measure@2.1.7: + resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==} + peerDependencies: + react: '>=16.13' + react-dom: '>=16.13' + peerDependenciesMeta: + react-dom: + optional: true + react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -5796,6 +5963,15 @@ packages: state-local@1.0.7: resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + stats-gl@2.4.2: + resolution: {integrity: sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==} + peerDependencies: + '@types/three': '*' + three: '*' + + stats.js@0.17.0: + resolution: {integrity: sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -5881,6 +6057,11 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + suspend-react@0.1.3: + resolution: {integrity: sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==} + peerDependencies: + react: '>=17.0' + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -5913,6 +6094,19 @@ packages: resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} engines: {node: '>=6.0.0'} + three-mesh-bvh@0.8.3: + resolution: {integrity: sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==} + peerDependencies: + three: '>= 0.159.0' + + three-stdlib@2.36.1: + resolution: {integrity: sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==} + peerDependencies: + three: '>=0.128.0' + + three@0.184.0: + resolution: {integrity: sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==} + tiny-async-pool@1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} @@ -5973,6 +6167,19 @@ packages: trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + troika-three-text@0.52.4: + resolution: {integrity: sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==} + peerDependencies: + three: '>=0.125.0' + + troika-three-utils@0.52.4: + resolution: {integrity: sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==} + peerDependencies: + three: '>=0.125.0' + + troika-worker-utils@0.52.0: + resolution: {integrity: sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==} + trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} @@ -5996,6 +6203,9 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tunnel-rat@0.1.2: + resolution: {integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==} + tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -6122,6 +6332,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utility-types@3.11.0: + resolution: {integrity: sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==} + engines: {node: '>= 4'} + uuid@11.1.0: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true @@ -6255,6 +6469,12 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + webgl-constants@1.1.1: + resolution: {integrity: sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==} + + webgl-sdf-generator@1.1.1: + resolution: {integrity: sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -6367,6 +6587,21 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + zustand@5.0.12: resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} engines: {node: '>=12.20.0'} @@ -6577,6 +6812,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/runtime@7.29.2': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -6622,6 +6859,8 @@ snapshots: ajv: 6.14.0 ajv-keywords: 3.5.2(ajv@6.14.0) + '@dimforge/rapier3d-compat@0.12.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.4)': dependencies: react: 19.2.4 @@ -7051,6 +7290,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@mediapipe/tasks-vision@0.10.17': {} + '@medv/finder@4.0.2': {} '@mermaid-js/parser@1.1.0': @@ -7090,6 +7331,11 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + '@monogrid/gainmap-js@3.4.0(three@0.184.0)': + dependencies: + promise-worker-transferable: 1.0.4 + three: 0.184.0 + '@mswjs/interceptors@0.41.3': dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -8129,6 +8375,59 @@ snapshots: prompts: 2.4.2 smol-toml: 1.6.0 + '@react-three/drei@10.7.7(@react-three/fiber@9.6.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.184.0))(@types/react@19.2.14)(@types/three@0.184.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.184.0)': + dependencies: + '@babel/runtime': 7.29.2 + '@mediapipe/tasks-vision': 0.10.17 + '@monogrid/gainmap-js': 3.4.0(three@0.184.0) + '@react-three/fiber': 9.6.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.184.0) + '@use-gesture/react': 10.3.1(react@19.2.4) + camera-controls: 3.1.2(three@0.184.0) + cross-env: 7.0.3 + detect-gpu: 5.0.70 + glsl-noise: 0.0.0 + hls.js: 1.6.16 + maath: 0.10.8(@types/three@0.184.0)(three@0.184.0) + meshline: 3.3.1(three@0.184.0) + react: 19.2.4 + stats-gl: 2.4.2(@types/three@0.184.0)(three@0.184.0) + stats.js: 0.17.0 + suspend-react: 0.1.3(react@19.2.4) + three: 0.184.0 + three-mesh-bvh: 0.8.3(three@0.184.0) + three-stdlib: 2.36.1(three@0.184.0) + troika-three-text: 0.52.4(three@0.184.0) + tunnel-rat: 0.1.2(@types/react@19.2.14)(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.4) + utility-types: 3.11.0 + zustand: 5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + - '@types/three' + - immer + + '@react-three/fiber@9.6.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.184.0)': + dependencies: + '@babel/runtime': 7.29.2 + '@types/webxr': 0.5.24 + base64-js: 1.5.1 + buffer: 6.0.3 + its-fine: 2.0.0(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-use-measure: 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + scheduler: 0.27.0 + suspend-react: 0.1.3(react@19.2.4) + three: 0.184.0 + use-sync-external-store: 1.6.0(react@19.2.4) + zustand: 5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + - immer + '@rolldown/pluginutils@1.0.0-rc.3': {} '@rollup/rollup-android-arm-eabi@4.59.1': @@ -8542,6 +8841,8 @@ snapshots: minimatch: 10.2.4 path-browserify: 1.0.1 + '@tweenjs/tween.js@23.1.3': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.2 @@ -8702,6 +9003,8 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/draco3d@1.4.10': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -8744,6 +9047,8 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/offscreencanvas@2019.7.3': {} + '@types/plist@3.0.5': dependencies: '@types/node': 25.5.0 @@ -8772,8 +9077,19 @@ snapshots: dependencies: '@types/node': 18.19.130 + '@types/stats.js@0.17.4': {} + '@types/statuses@2.0.6': {} + '@types/three@0.184.0': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + fflate: 0.8.2 + meshoptimizer: 1.1.1 + '@types/trusted-types@2.0.7': optional: true @@ -8788,6 +9104,8 @@ snapshots: '@types/verror@1.10.11': optional: true + '@types/webxr@0.5.24': {} + '@types/ws@8.18.1': dependencies: '@types/node': 25.5.0 @@ -8835,6 +9153,13 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) + '@use-gesture/core@10.3.1': {} + + '@use-gesture/react@10.3.1(react@19.2.4)': + dependencies: + '@use-gesture/core': 10.3.1 + react: 19.2.4 + '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))': dependencies: '@babel/core': 7.29.0 @@ -9068,6 +9393,10 @@ snapshots: bindings: 1.5.0 prebuild-install: 7.1.3 + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 @@ -9136,6 +9465,11 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + buildcheck@0.0.7: optional: true @@ -9214,6 +9548,10 @@ snapshots: callsites@3.1.0: {} + camera-controls@3.1.2(three@0.184.0): + dependencies: + three: 0.184.0 + caniuse-lite@1.0.30001780: {} ccount@2.0.1: {} @@ -9397,6 +9735,10 @@ snapshots: cross-dirname@0.1.0: optional: true + cross-env@7.0.3: + dependencies: + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -9652,6 +9994,10 @@ snapshots: dequal@2.0.3: {} + detect-gpu@5.0.70: + dependencies: + webgl-constants: 1.1.1 + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} @@ -9711,6 +10057,8 @@ snapshots: dotenv@17.3.1: {} + draco3d@1.5.7: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -10079,6 +10427,10 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + fflate@0.6.10: {} + + fflate@0.8.2: {} + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -10257,6 +10609,8 @@ snapshots: gopd: 1.2.0 optional: true + glsl-noise@0.0.0: {} + gopd@1.2.0: {} got@11.8.6: @@ -10420,6 +10774,8 @@ snapshots: highlight.js@11.11.1: {} + hls.js@1.6.16: {} + hono@4.12.8: {} hosted-git-info@4.1.0: @@ -10491,6 +10847,8 @@ snapshots: ignore@7.0.5: {} + immediate@3.0.6: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -10562,6 +10920,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-promise@2.2.2: {} + is-promise@4.0.0: {} is-regexp@3.1.0: {} @@ -10588,6 +10948,13 @@ snapshots: isexe@3.1.5: {} + its-fine@2.0.0(@types/react@19.2.14)(react@19.2.4): + dependencies: + '@types/react-reconciler': 0.28.9(@types/react@19.2.14) + react: 19.2.4 + transitivePeerDependencies: + - '@types/react' + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -10670,6 +11037,10 @@ snapshots: lazy-val@1.0.5: {} + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lightningcss-android-arm64@1.32.0: optional: true @@ -10795,6 +11166,11 @@ snapshots: dependencies: react: 19.2.4 + maath@0.10.8(@types/three@0.184.0)(three@0.184.0): + dependencies: + '@types/three': 0.184.0 + three: 0.184.0 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -11043,6 +11419,12 @@ snapshots: ts-dedent: 2.2.0 uuid: 11.1.0 + meshline@3.3.1(three@0.184.0): + dependencies: + three: 0.184.0 + + meshoptimizer@1.1.1: {} + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 @@ -11693,6 +12075,8 @@ snapshots: commander: 9.5.0 optional: true + potpack@1.0.2: {} + powershell-utils@0.1.0: {} prebuild-install@7.1.3: @@ -11723,6 +12107,11 @@ snapshots: err-code: 2.0.3 retry: 0.12.0 + promise-worker-transferable@1.0.4: + dependencies: + is-promise: 2.2.2 + lie: 3.3.0 + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -11967,6 +12356,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + react-use-measure@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + react@19.2.4: {} read-binary-file-arch@1.0.6: @@ -12435,6 +12830,13 @@ snapshots: state-local@1.0.7: {} + stats-gl@2.4.2(@types/three@0.184.0)(three@0.184.0): + dependencies: + '@types/three': 0.184.0 + three: 0.184.0 + + stats.js@0.17.0: {} + statuses@2.0.2: {} std-env@4.0.0: {} @@ -12519,6 +12921,10 @@ snapshots: dependencies: has-flag: 4.0.0 + suspend-react@0.1.3(react@19.2.4): + dependencies: + react: 19.2.4 + tagged-tag@1.0.0: {} tailwind-merge@3.5.0: {} @@ -12560,6 +12966,22 @@ snapshots: mkdirp: 0.5.6 rimraf: 2.6.3 + three-mesh-bvh@0.8.3(three@0.184.0): + dependencies: + three: 0.184.0 + + three-stdlib@2.36.1(three@0.184.0): + dependencies: + '@types/draco3d': 1.4.10 + '@types/offscreencanvas': 2019.7.3 + '@types/webxr': 0.5.24 + draco3d: 1.5.7 + fflate: 0.6.10 + potpack: 1.0.2 + three: 0.184.0 + + three@0.184.0: {} + tiny-async-pool@1.3.0: dependencies: semver: 5.7.2 @@ -12607,6 +13029,20 @@ snapshots: trim-lines@3.0.1: {} + troika-three-text@0.52.4(three@0.184.0): + dependencies: + bidi-js: 1.0.3 + three: 0.184.0 + troika-three-utils: 0.52.4(three@0.184.0) + troika-worker-utils: 0.52.0 + webgl-sdf-generator: 1.1.1 + + troika-three-utils@0.52.4(three@0.184.0): + dependencies: + three: 0.184.0 + + troika-worker-utils@0.52.0: {} + trough@2.2.0: {} truncate-utf8-bytes@1.0.2: @@ -12632,6 +13068,14 @@ snapshots: dependencies: safe-buffer: 5.2.1 + tunnel-rat@0.1.2(@types/react@19.2.14)(react@19.2.4): + dependencies: + zustand: 4.5.7(@types/react@19.2.14)(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + - immer + - react + tw-animate-css@1.4.0: {} tweetnacl@0.14.5: {} @@ -12753,6 +13197,8 @@ snapshots: util-deprecate@1.0.2: {} + utility-types@3.11.0: {} + uuid@11.1.0: {} validate-npm-package-name@7.0.2: {} @@ -12850,6 +13296,10 @@ snapshots: web-streams-polyfill@3.3.3: {} + webgl-constants@1.1.1: {} + + webgl-sdf-generator@1.1.1: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -12943,6 +13393,13 @@ snapshots: zod@4.3.6: {} + zustand@4.5.7(@types/react@19.2.14)(react@19.2.4): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.4 + zustand@5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): optionalDependencies: '@types/react': 19.2.14 diff --git a/resources/pets/demon.glb b/resources/pets/demon.glb new file mode 100644 index 00000000000..c217798832d Binary files /dev/null and b/resources/pets/demon.glb differ diff --git a/resources/pets/dinosaur.glb b/resources/pets/dinosaur.glb new file mode 100644 index 00000000000..a2a7e7f26af Binary files /dev/null and b/resources/pets/dinosaur.glb differ diff --git a/resources/pets/potted-plant.glb b/resources/pets/potted-plant.glb new file mode 100644 index 00000000000..602ce814864 Binary files /dev/null and b/resources/pets/potted-plant.glb differ diff --git a/resources/pets/sloth.glb b/resources/pets/sloth.glb new file mode 100644 index 00000000000..9bcdf3028a3 Binary files /dev/null and b/resources/pets/sloth.glb differ diff --git a/scripts/compress-pet-glb.sh b/scripts/compress-pet-glb.sh new file mode 100755 index 00000000000..28e9a2efaa3 --- /dev/null +++ b/scripts/compress-pet-glb.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Compress a GLB for use as a bundled pet model. +# +# Why two passes: the first `optimize` run with `--compress draco` must decode +# any existing Draco compression before it can downscale textures; textures +# dominate file size for most source models, so the real win comes from the +# second pass after textures are shrunk + re-encoded as WebP at 512px. +# +# Typical result: 20–30 MB source → <1 MB bundled. Output lands at +# resources/pets/.glb unless --out is given. +# +# Usage: +# scripts/compress-pet-glb.sh [--out ] [--size 512] +# +# Example: +# scripts/compress-pet-glb.sh ~/Downloads/some_model.glb --out resources/pets/cat.glb + +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "usage: $0 [--out ] [--size ]" >&2 + exit 1 +fi + +INPUT="$1" +shift + +SIZE=512 +OUT="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --out) + OUT="$2" + shift 2 + ;; + --size) + SIZE="$2" + shift 2 + ;; + *) + echo "unknown flag: $1" >&2 + exit 1 + ;; + esac +done + +if [[ ! -f "$INPUT" ]]; then + echo "input not found: $INPUT" >&2 + exit 1 +fi + +if [[ -z "$OUT" ]]; then + BASENAME="$(basename "$INPUT" .glb)" + # Why: produce a kebab-case slug so the filename matches the PetModelId + # convention (lowercase, hyphen-separated) without manual renaming. + SLUG="$(echo "$BASENAME" | tr '[:upper:] _' '[:lower:]--' | tr -cd 'a-z0-9-' | sed 's/--*/-/g;s/^-//;s/-$//')" + OUT="resources/pets/${SLUG}.glb" +fi + +echo "==> compressing: $INPUT" +echo " -> $OUT (textures: ${SIZE}px, webp + draco)" + +# Why: write to a temp file first so a failed second pass doesn't leave a +# half-optimized GLB at the destination. +TMP="$(mktemp -t pet-glb.XXXXXX).glb" +trap 'rm -f "$TMP"' EXIT + +npx --yes @gltf-transform/cli optimize "$INPUT" "$TMP" \ + --compress draco \ + --texture-compress webp \ + --texture-size "$SIZE" + +# Why: second optimize pass. After the first pass decoded Draco and shrank +# textures, re-running draco + webp compresses the now-smaller assets far +# more aggressively (observed: 8.8 MB → 0.8 MB on tiny_planet dinosaur). +npx --yes @gltf-transform/cli optimize "$TMP" "$OUT" \ + --compress draco \ + --texture-compress webp \ + --texture-size "$SIZE" + +SIZE_BYTES=$(wc -c <"$OUT" | tr -d ' ') +SIZE_KB=$(( SIZE_BYTES / 1024 )) +echo "==> done: $OUT (${SIZE_KB} KB)" diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 2dfb2b32e89..aaaccfc9990 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -92,6 +92,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, experimentalAgentDashboard: false, + experimentalPet: false, terminalWindowsShell: 'powershell.exe', enableGitHubAttribution: true, ...overrides diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 492bb00074a..4033c8a95a5 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -86,6 +86,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, experimentalAgentDashboard: false, + experimentalPet: false, terminalWindowsShell: 'powershell.exe', enableGitHubAttribution: true, ...overrides diff --git a/src/main/ipc/pet.ts b/src/main/ipc/pet.ts new file mode 100644 index 00000000000..8c6c4f4e861 --- /dev/null +++ b/src/main/ipc/pet.ts @@ -0,0 +1,183 @@ +import { app, BrowserWindow, dialog, ipcMain } from 'electron' +import { copyFile, mkdir, readFile, rm, stat } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import { basename, extname, join, normalize, sep } from 'node:path' +import type { CustomPetKind, CustomPetModel } from '../../shared/types' + +// Why: map from extension → (kind, mime). Images are static + animated variants +// rendered by ; GLB goes through three.js. Keeping this table here so the +// main process is the single source of truth for which formats are accepted. +const IMAGE_FORMATS: Record = { + '.png': 'image/png', + '.apng': 'image/apng', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml' +} + +function classifyFile(src: string): { kind: CustomPetKind; mimeType: string } | null { + const ext = extname(src).toLowerCase() + if (ext === '.glb') { + return { kind: 'glb', mimeType: 'model/gltf-binary' } + } + const mime = IMAGE_FORMATS[ext] + if (mime) { + return { kind: 'image', mimeType: mime } + } + return null +} + +// Why: custom user-uploaded GLBs live in a dedicated folder under userData so +// they persist across app updates but are scoped to the Orca install. We never +// trust paths the renderer hands us — the renderer only ever knows the opaque +// CustomPetModel.id; main resolves it to an absolute path inside this folder. +function getPetsDir(): string { + return join(app.getPath('userData'), 'pets', 'custom') +} + +const MAX_GLB_BYTES = 256 * 1024 * 1024 // 256 MB — generous cap; still bounded so a user can't point at a multi-GB file and OOM the renderer when it builds a Blob URL. + +function isSafeId(id: string): boolean { + // UUIDs only; blocks path traversal and unexpected characters. + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) +} + +function resolvePetFile(id: string): string | null { + if (!isSafeId(id)) { + return null + } + // Why: normalize() + prefix check defends against any edge case where the id + // slipped the regex. The file is always `.glb` inside the pets dir. + const filePath = normalize(join(getPetsDir(), `${id}.glb`)) + if (!filePath.startsWith(normalize(getPetsDir()) + sep)) { + return null + } + return filePath +} + +export function registerPetHandlers(): void { + ipcMain.handle('pet:import', async (event): Promise => { + // Why: parent the file picker to the sender window. Without a parent, on + // macOS the dialog opens as an app-level sheet that can land behind the + // main window (appears as "nothing happened" when the user clicks + // Upload GLB…). fromWebContents is the reliable way to resolve the + // sender — focused window isn't, because DropdownMenu's modal layer can + // momentarily steal focus before the handler runs. + const senderWindow = + BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow() + const options: Electron.OpenDialogOptions = { + title: 'Choose a pet', + properties: ['openFile'], + // Why: single filter and no `apng` extension. macOS file dialogs map + // filter extensions to UTIs; `apng` has no registered UTI, so including + // it can cause macOS to drop sibling extensions (notably `webp`) from + // the allowed set — users saw `.webp` files greyed out even though the + // filter list declared them. APNG files carry the `.png` extension and + // are detected from magic bytes by the renderer, so dropping the + // `apng` extension here costs nothing. Multiple filters also surface a + // macOS dropdown whose last selection persists per-app; collapsing to + // one filter removes that footgun. + filters: [ + { + name: 'Pet (GLB or image)', + extensions: ['glb', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'] + } + ] + } + const result = senderWindow + ? await dialog.showOpenDialog(senderWindow, options) + : await dialog.showOpenDialog(options) + if (result.canceled || result.filePaths.length === 0) { + return null + } + const src = result.filePaths[0] + // Why: classify before stat so a wrong-format file shows a clean error + // rather than "too large". Each supported format maps to its MIME so the + // renderer can build a correct Blob (SVG won't render with the wrong + // Content-Type, and GLBs load faster when three.js sees the right MIME). + const classified = classifyFile(src) + if (!classified) { + throw new Error('Unsupported file. Pick a GLB (3D) or PNG / APNG / JPG / GIF / WebP / SVG.') + } + let srcStat: Awaited> + try { + srcStat = await stat(src) + } catch { + // Why: surface a user-facing message instead of leaking ENOENT/EACCES. + throw new Error('Could not read the selected file.') + } + if (!srcStat.isFile()) { + throw new Error('Selected path is not a file') + } + if (srcStat.size > MAX_GLB_BYTES) { + // Why: unbounded reads would let a user point at a multi-GB file and + // effectively DoS the renderer when it tries to build a Blob URL. + throw new Error( + `File is too large (${(srcStat.size / (1024 * 1024)).toFixed(1)} MB). Max is ${MAX_GLB_BYTES / (1024 * 1024)} MB.` + ) + } + + const dir = getPetsDir() + await mkdir(dir, { recursive: true }) + const id = randomUUID() + // Why: filename extension is purely a storage convention — pet:read + // returns raw bytes and the renderer uses CustomPetModel.mimeType to + // build the Blob. Keeping the on-disk name as `.glb` regardless of + // kind means resolvePetFile stays ext-free and legacy installs keep + // working without a migration. The real format lives in CustomPetModel. + const dest = join(dir, `${id}.glb`) + try { + await copyFile(src, dest) + } catch { + // Why: clean up partial copy so a later list/read doesn't surface a + // half-written file that would fail to parse in the renderer. + await rm(dest, { force: true }).catch(() => {}) + throw new Error('Could not save the pet.') + } + + const rawLabel = basename(src, extname(src)).trim() + const label = rawLabel.length > 0 ? rawLabel.slice(0, 40) : 'Custom pet' + return { + id, + label, + fileName: `${id}.glb`, + kind: classified.kind, + mimeType: classified.mimeType + } + }) + + ipcMain.handle('pet:read', async (_event, id: string): Promise => { + const filePath = resolvePetFile(id) + if (!filePath) { + return null + } + try { + const buf = await readFile(filePath) + // Why: return as ArrayBuffer so the renderer can build a Blob + objectURL + // directly. File-protocol hand-off is blocked by webSecurity=true and + // sandbox=true, and we don't want to weaken either for this one feature. + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) + } catch (error) { + // Why: log for diagnostics — silent null made it indistinguishable + // from "not found" vs real fs failures during renderer debugging. + console.warn('[pet-overlay] pet:read failed', error) + return null + } + }) + + ipcMain.handle('pet:delete', async (_event, id: string): Promise => { + const filePath = resolvePetFile(id) + if (!filePath) { + return + } + try { + await rm(filePath, { force: true }) + } catch (error) { + // Why: swallowing EACCES silently would leave a ghost file that still + // appears in listings; log so we can see it in diagnostics. + console.warn('[pet-overlay] pet:delete failed', error) + } + }) +} diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index e268255f376..9c1fa03dd4b 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -13,6 +13,7 @@ const { registerDeveloperPermissionHandlersMock, registerSettingsHandlersMock, registerShellHandlersMock, + registerPetHandlersMock, registerSessionHandlersMock, registerUIHandlersMock, registerFilesystemHandlersMock, @@ -43,6 +44,7 @@ const { registerDeveloperPermissionHandlersMock: vi.fn(), registerSettingsHandlersMock: vi.fn(), registerShellHandlersMock: vi.fn(), + registerPetHandlersMock: vi.fn(), registerSessionHandlersMock: vi.fn(), registerUIHandlersMock: vi.fn(), registerFilesystemHandlersMock: vi.fn(), @@ -114,6 +116,10 @@ vi.mock('./shell', () => ({ registerShellHandlers: registerShellHandlersMock })) +vi.mock('./pet', () => ({ + registerPetHandlers: registerPetHandlersMock +})) + vi.mock('./session', () => ({ registerSessionHandlers: registerSessionHandlersMock })) @@ -185,6 +191,7 @@ describe('registerCoreHandlers', () => { registerDeveloperPermissionHandlersMock.mockReset() registerSettingsHandlersMock.mockReset() registerShellHandlersMock.mockReset() + registerPetHandlersMock.mockReset() registerSessionHandlersMock.mockReset() registerUIHandlersMock.mockReset() registerFilesystemHandlersMock.mockReset() @@ -229,6 +236,7 @@ describe('registerCoreHandlers', () => { expect(registerCodexUsageHandlersMock).toHaveBeenCalledWith(codexUsage) expect(registerCodexAccountHandlersMock).toHaveBeenCalledWith(codexAccounts) expect(registerAgentHookHandlersMock).toHaveBeenCalled() + expect(registerPetHandlersMock).toHaveBeenCalled() expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts) expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits) expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats) diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 7df662b70a9..55f46546b3b 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -24,6 +24,7 @@ import { registerSettingsHandlers } from './settings' import { registerBrowserHandlers } from './browser' import { browserSessionRegistry } from '../browser/browser-session-registry' import { registerShellHandlers } from './shell' +import { registerPetHandlers } from './pet' import { registerUIHandlers } from './ui' import { registerCodexAccountHandlers } from './codex-accounts' import { registerAgentHookHandlers } from './agent-hooks' @@ -89,6 +90,7 @@ export function registerCoreHandlers( browserSessionRegistry.applyPendingCookieImport() browserSessionRegistry.restorePersistedUserAgent() registerShellHandlers() + registerPetHandlers() registerSessionHandlers(store) registerUIHandlers(store) registerFilesystemHandlers(store) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 0c5af49e584..4f96d74fd6f 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -9,6 +9,7 @@ import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState, CreateWorktreeResult, + CustomPetModel, DirEntry, FsChangedPayload, GhosttyImportPreview, @@ -574,6 +575,11 @@ export type PreloadApi = { pickDirectory: (args: { defaultPath?: string }) => Promise copyFile: (args: { srcPath: string; destPath: string }) => Promise } + pet: { + importModel: () => Promise + readModel: (id: string) => Promise + deleteModel: (id: string) => Promise + } browser: BrowserApi hooks: { check: (args: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 1b6b9ae8f75..6ea52d9d292 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,6 +9,7 @@ import type { CliInstallStatus } from '../shared/cli-install-types' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' import type { BaseRefDefaultResult, + CustomPetModel, FsChangedPayload, GitHubAssignableUser, GitHubCommentResult, @@ -697,6 +698,12 @@ const api = { ipcRenderer.invoke('shell:copyFile', args) }, + pet: { + importModel: (): Promise => ipcRenderer.invoke('pet:import'), + readModel: (id: string): Promise => ipcRenderer.invoke('pet:read', id), + deleteModel: (id: string): Promise => ipcRenderer.invoke('pet:delete', id) + }, + browser: { registerGuest: (args: { browserPageId: string diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 634de343e1d..7837db669f8 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -52,6 +52,10 @@ const Settings = lazy(() => import('./components/settings/Settings')) const QuickOpen = lazy(() => import('./components/QuickOpen')) const WorktreeJumpPalette = lazy(() => import('./components/WorktreeJumpPalette')) const NewWorkspaceComposerModal = lazy(() => import('./components/NewWorkspaceComposerModal')) +// Why: pet overlay is the only entrypoint that imports three.js. Gating +// it behind React.lazy + the experimentalPet+petVisible conditional means +// users who leave the feature off never download the three.js chunk. +const PetOverlay = lazy(() => import('./components/pet/PetOverlay')) function isEditableTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) { @@ -146,6 +150,8 @@ function App(): React.JSX.Element { // subscriptions (agentStatusByPaneKey, agentStatusEpoch, etc.) instead of // keeping them alive behind an early-return inside the hook bodies. const agentDashboardEnabled = useAppStore((s) => s.settings?.experimentalAgentDashboard === true) + const petEnabled = useAppStore((s) => s.settings?.experimentalPet === true) + const petVisible = useAppStore((s) => s.petVisible) const canGoBackWorktree = useAppStore(canGoBackWorktreeHistory) const canGoForwardWorktree = useAppStore(canGoForwardWorktreeHistory) const titlebarLeftControlsRef = useRef(null) @@ -1084,6 +1090,15 @@ function App(): React.JSX.Element { {mountedLazyModalIds.has('quick-open') ? : null} {mountedLazyModalIds.has('worktree-palette') ? : null} + {/* Why: mount PetOverlay only when the experimental flag is on AND + the user hasn't hit "Hide pet" in the status-bar menu. Both + conditions must be true — see design doc (pet-overlay.md) on why + the two toggles are kept independent. */} + {petEnabled && petVisible ? ( + + + + ) : null} diff --git a/src/renderer/src/components/pet/PetOverlay.tsx b/src/renderer/src/components/pet/PetOverlay.tsx new file mode 100644 index 00000000000..2d3208a2fab --- /dev/null +++ b/src/renderer/src/components/pet/PetOverlay.tsx @@ -0,0 +1,145 @@ +/* eslint-disable max-lines -- Why: keeping the lazy impl, WebGL capability + probe, and visibility/reduced-motion handling in one module makes the + boundary between "imports three.js" and "never imports three.js" explicit + — the lazy() call below is the single three.js entrypoint. */ +import { lazy, Suspense, useEffect, useState } from 'react' +import { usePetModelUrl } from './usePetModelUrl' + +// Why: dynamic import is the whole point of gating three.js behind the +// experimental flag. Users who never enable `experimentalPet` pay zero bytes +// because React.lazy does not fetch the chunk until the component is +// actually rendered — and the PetOverlay is only mounted when the flag is +// on (see App.tsx). Changing this to a static import would defeat the +// design goal stated in pet-overlay.md (zero cost when disabled). +const PetScene = lazy(() => import('./PetScene')) + +// Why: probed once per renderer. If WebGL is unavailable (some Linux/VM +// setups) we unmount silently rather than crash the app root. The design +// doc requires a single stable log prefix so dogfooders can grep their own +// console output — there is no in-app telemetry pipeline. +const LOG_PREFIX = '[pet-overlay]' + +function detectWebGL(): boolean { + try { + const canvas = document.createElement('canvas') + const gl = + canvas.getContext('webgl2') ?? + canvas.getContext('webgl') ?? + canvas.getContext('experimental-webgl') + return gl !== null + } catch { + return false + } +} + +function useDocumentVisible(): boolean { + const [visible, setVisible] = useState(() => + typeof document === 'undefined' ? true : document.visibilityState === 'visible' + ) + useEffect(() => { + const onChange = (): void => { + setVisible(document.visibilityState === 'visible') + } + document.addEventListener('visibilitychange', onChange) + return () => document.removeEventListener('visibilitychange', onChange) + }, []) + return visible +} + +function usePrefersReducedMotion(): boolean { + const [reduced, setReduced] = useState(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return false + } + return window.matchMedia('(prefers-reduced-motion: reduce)').matches + }) + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return + } + const mq = window.matchMedia('(prefers-reduced-motion: reduce)') + const onChange = (event: MediaQueryListEvent): void => setReduced(event.matches) + mq.addEventListener('change', onChange) + return () => mq.removeEventListener('change', onChange) + }, []) + return reduced +} + +export function PetOverlay(): React.JSX.Element | null { + const [webGLOk, setWebGLOk] = useState(null) + const documentVisible = useDocumentVisible() + const reducedMotion = usePrefersReducedMotion() + const { url: modelUrl, kind } = usePetModelUrl() + + useEffect(() => { + // Why: WebGL probe is only meaningful for GLB pets. For image pets we + // can skip three.js entirely, so we don't want a WebGL-less environment + // to disable images too. + if (kind !== 'glb') { + return + } + const ok = detectWebGL() + if (!ok) { + // Why: design doc requires logging WebGL init failures under a stable + // prefix so users who hit the fallback can surface the string when + // reporting, and dogfooders can grep their own logs. + console.warn(`${LOG_PREFIX} WebGL unavailable — pet overlay disabled for this session`) + } + setWebGLOk(ok) + }, [kind]) + + if (kind === 'glb' && (webGLOk === null || webGLOk === false)) { + return null + } + + return ( + // Why: wrapper is pointer-events-none so the app chrome underneath the + // pet's bounding box remains interactive. For GLB pets the Canvas child + // re-enables pointer events so r3f can raycast drags. For image pets we + // leave everything pointer-events-none since there's no interaction (no + // drag-to-rotate on 2D) — the child-selector that re-enables events is + // scoped to `kind === 'glb'` so 2D pets don't silently eat clicks on the + // terminal/chrome underneath. z-index sits just under typical modal layers. +
div]:pointer-events-auto' + : 'pointer-events-none fixed bottom-4 right-0 z-40 h-[140px] w-[140px]' + } + > + {kind === 'image' ? ( + + ) : ( + + + + )} +
+ ) +} + +// Why: tiny inline component for 2D pets — static and animated formats +// (apng/gif/webp/svg) all render natively via , no extra runtime. Bob +// uses CSS keyframes instead of the useFrame loop; reduced-motion or a +// backgrounded tab simply pauses the animation via `animation-play-state`. +function ImagePet({ src, animate }: { src: string; animate: boolean }): React.JSX.Element { + return ( +
+ + +
+ ) +} + +export default PetOverlay diff --git a/src/renderer/src/components/pet/PetScene.tsx b/src/renderer/src/components/pet/PetScene.tsx new file mode 100644 index 00000000000..eaaa72133b8 --- /dev/null +++ b/src/renderer/src/components/pet/PetScene.tsx @@ -0,0 +1,282 @@ +import { Suspense, useEffect, useLayoutEffect, useMemo, useRef } from 'react' +import { Canvas, type ThreeEvent, useFrame, useThree } from '@react-three/fiber' +import { useGLTF } from '@react-three/drei' +import * as THREE from 'three' +import { clone as cloneSkinned } from 'three/examples/jsm/utils/SkeletonUtils.js' + +const LOG_PREFIX = '[pet-overlay]' + +// Why: target half-height of the fitted model in world units. The camera is +// positioned so this extent exactly fills the viewport minus a padding +// factor, giving identical framing for every model regardless of its native +// scale or pivot. 1.0 is a round number — all downstream sizing derives from +// it deterministically. +const TARGET_HALF_EXTENT = 1 + +// Why: leave visible breathing room on top/bottom so the model never touches +// the viewport edges and the bob animation (see `BOB_AMPLITUDE`) stays fully +// in-frame. 0.72 = model fills ~72% of viewport height. +const FIT_FACTOR = 0.72 + +// Why: matches the previous idle-animation amplitude. Kept small so the fit +// padding above easily absorbs it without reintroducing clipping. +const BOB_AMPLITUDE = 0.04 + +function FittedPetModel({ animate, url }: { animate: boolean; url: string }): React.JSX.Element { + const bobRef = useRef(null) + const spinRef = useRef(null) + const fitRef = useRef(null) + const { camera, invalidate } = useThree() + // Why: useGLTF suspends until the model is fetched and parsed; wrapped by + // the parent so the fallback is "render nothing" rather than a + // spinner. Use SkeletonUtils.clone (not Object3D.clone) because the bundled + // GLBs contain SkinnedMesh rigs — a naive clone duplicates meshes + bones + // but leaves mesh.skeleton.bones pointing at the *original* bone tree, so + // the cloned mesh renders against one pose while Box3 measures against + // another. That mismatch is what produced the "math says fits, pixels say + // clipped" bug. + const gltf = useGLTF(url) + const scene = useMemo(() => cloneSkinned(gltf.scene) as THREE.Object3D, [gltf.scene]) + + // Why: cloneSkinned allocates new geometries + skeletons; without disposing + // them every model switch leaks GPU buffers. Materials are intentionally left + // alone — SkeletonUtils.clone shares material refs with the original cache, + // so disposing them would break re-mounts using the same GLB. + useEffect(() => { + return () => { + scene.traverse((obj) => { + const mesh = obj as THREE.Mesh & { isMesh?: boolean; isSkinnedMesh?: boolean } + if (mesh.isMesh || mesh.isSkinnedMesh) { + mesh.geometry?.dispose() + if (mesh.isSkinnedMesh) { + const skinned = mesh as unknown as THREE.SkinnedMesh + const skeleton = skinned.skeleton as + | (THREE.Skeleton & { dispose?: () => void }) + | undefined + skeleton?.dispose?.() + } + } + }) + } + }, [scene]) + + useLayoutEffect(() => { + const fitGroup = fitRef.current + if (!fitGroup) { + return + } + // Why: reset any prior fit transforms before measuring so switching + // models doesn't accumulate scale/translation from the last fit. + fitGroup.position.set(0, 0, 0) + fitGroup.scale.set(1, 1, 1) + fitGroup.updateMatrixWorld(true) + + // Why: manual Box3 fit rather than drei . fits a sphere + // (wastes vertical space on tall thin models like the gremlin) and also + // fought our parent-group `useFrame` position writes via its `observe` + // mode, re-fitting every frame and drifting. Measuring the box once at + // mount gives a deterministic, model-agnostic fit. + // Why: force world matrices to be up-to-date before measuring so + // Box3.setFromObject sees geometry in its actual local-space extent + // rather than a partially-initialized graph. Without this call, models + // whose GLB has parent transforms (rigged meshes with bone offsets) get + // under-measured and the fit scales them too large, clipping the head. + scene.updateMatrixWorld(true) + // Why: force skinned meshes to refresh their bone matrices + per-mesh + // bounding boxes, then measure with precise=true so the AABB is built + // from posed vertex positions (getVertexPosition applies bone transforms + // for SkinnedMesh) rather than bind-pose geometry.boundingBox. Without + // this, rigs whose bind pose is shorter than the rest pose (demon horns + // rotate upward when the skeleton binds) were reported as fitting while + // the rendered silhouette clipped at the top. + scene.traverse((obj) => { + const mesh = obj as THREE.SkinnedMesh + if ((mesh as THREE.Object3D & { isSkinnedMesh?: boolean }).isSkinnedMesh) { + mesh.skeleton?.update() + mesh.computeBoundingBox() + } + }) + const box = new THREE.Box3().setFromObject(scene, true) + const size = new THREE.Vector3() + const center = new THREE.Vector3() + box.getSize(size) + box.getCenter(center) + + // Why: the model spins around the y axis, so its on-screen silhouette + // sweeps a cylinder of radius sqrt(x² + z²)/2 — not the AABB. Using the + // AABB diagonal here means the widest frame during rotation (typically + // around 45°) stays within the viewport instead of clipping the + // shoulders/horns on tall-thin models like the demon. + const horizontalDiameter = Math.hypot(size.x, size.z) + const maxExtent = Math.max(horizontalDiameter, size.y) + if (maxExtent === 0 || !Number.isFinite(maxExtent)) { + return + } + + // Why: scale so whichever is larger — the model's y extent or its + // rotation-swept horizontal diameter — becomes 2×TARGET_HALF_EXTENT in + // world units. Every bundled model ends up the same on-screen size and + // never grows past the frame during the idle spin. + const scale = (TARGET_HALF_EXTENT * 2) / maxExtent + fitGroup.scale.setScalar(scale) + // Why: translate so the model's geometric center sits at the spin + // group's origin. Centering here instead of via
avoids a second + // scene-graph wrapper and keeps the rotation pivot on the model's + // midpoint rather than its GLB origin (which may be at the feet). + fitGroup.position.set(-center.x * scale, -center.y * scale, -center.z * scale) + + // Why: compute camera z from the vertical half-extent and the camera's + // vertical FOV so the fitted model exactly fills FIT_FACTOR of the + // viewport height. The canvas is square (220×220), so horizontal fit is + // equivalent — but we always use the vertical calc because that's where + // the observed clipping happened. + const perspective = camera as THREE.PerspectiveCamera + const fovRad = (perspective.fov * Math.PI) / 180 + const distance = TARGET_HALF_EXTENT / FIT_FACTOR / Math.tan(fovRad / 2) + perspective.position.set(0, 0, distance) + perspective.near = Math.max(0.1, distance - TARGET_HALF_EXTENT * 4) + perspective.far = distance + TARGET_HALF_EXTENT * 4 + perspective.lookAt(0, 0, 0) + perspective.updateProjectionMatrix() + invalidate() + }, [scene, camera, invalidate, url]) + + const dragRef = useRef<{ active: boolean; pointerId: number; lastX: number; lastY: number }>({ + active: false, + pointerId: -1, + lastX: 0, + lastY: 0 + }) + + useFrame(() => { + if (!animate) { + return + } + const bob = bobRef.current + if (bob) { + // Why: subtle idle bob only — continuous y-spin was disorienting and + // made the pet feel like a screensaver. Rotation is user-driven via + // pointer drag (onPointerDown/Move/Up below). + bob.position.y = Math.sin(performance.now() / 600) * BOB_AMPLITUDE + } + }) + + const onPointerDown = (event: ThreeEvent): void => { + // Why: r3f routes DOM pointer events through raycasting, so this only + // fires when the ray hits the model mesh — not the empty canvas around + // it. That means the surrounding transparent area still passes clicks + // through to the app (pointer-events-auto on the canvas wrapper is + // safe). + event.stopPropagation() + const native = event.nativeEvent + const target = native.target as Element | null + target?.setPointerCapture?.(native.pointerId) + dragRef.current = { + active: true, + pointerId: native.pointerId, + lastX: native.clientX, + lastY: native.clientY + } + } + const onPointerMove = (event: ThreeEvent): void => { + const drag = dragRef.current + if (!drag.active) { + return + } + const native = event.nativeEvent + const dx = native.clientX - drag.lastX + const dy = native.clientY - drag.lastY + drag.lastX = native.clientX + drag.lastY = native.clientY + const spin = spinRef.current + if (spin) { + // Why: horizontal drag → yaw, vertical drag → pitch. 0.03 rad/px — + // ~two full turns per canvas-width of travel so a short flick produces + // visible rotation on the 140px overlay. + spin.rotation.y += dx * 0.03 + spin.rotation.x += dy * 0.03 + } + invalidate() + } + const onPointerUp = (event: ThreeEvent): void => { + // Why: multi-touch or a spurious pointerup from a different pointer + // shouldn't terminate the active drag — only the pointer that started + // the drag can end it. + if (event.nativeEvent.pointerId !== dragRef.current.pointerId) { + return + } + const native = event.nativeEvent + const target = native.target as Element | null + target?.releasePointerCapture?.(dragRef.current.pointerId) + dragRef.current.active = false + } + + return ( + + + + + + + + ) +} + +export function PetScene({ + animate, + modelUrl +}: { + animate: boolean + modelUrl: string +}): React.JSX.Element { + useEffect(() => { + // Why: only preload the currently-active model — drei dedupes by URL, and + // other models load on demand when the user picks them. Eagerly warming + // every bundled GLB burned bandwidth + decode time on mount for models the + // user may never select. + useGLTF.preload(modelUrl) + }, [modelUrl]) + + return ( + { + gl.setClearColor(0x000000, 0) + }} + onError={(error) => { + // Why: stable prefix per the design doc rollout step 4 so users can + // surface the string when reporting WebGL-init failures across + // platforms. + console.warn(`${LOG_PREFIX} canvas error`, error) + }} + > + + + + + {/* Why: key forces a fresh mount (and therefore re-measurement) when + the user switches models, since useLayoutEffect deps on `scene` + identity — the key guarantees the prior fit's camera/scale + cannot leak across model swaps. */} + + + + ) +} + +export default PetScene diff --git a/src/renderer/src/components/pet/pet-blob-cache.ts b/src/renderer/src/components/pet/pet-blob-cache.ts new file mode 100644 index 00000000000..fc245cd1daf --- /dev/null +++ b/src/renderer/src/components/pet/pet-blob-cache.ts @@ -0,0 +1,43 @@ +// Why: isolated module so the store slice can call revokeCustomPetBlobUrl +// without importing usePetModelUrl (which itself imports the store). Keeps +// the dependency graph acyclic. + +// Why: sandbox=true + webSecurity=true block the renderer from reading user +// files directly. For custom pet models we fetch the bytes over IPC and turn +// them into a `blob:` URL that three.js can load like any other GLB. A small +// in-memory cache means switching back and forth between models in the same +// session doesn't re-fetch from main. +export const blobUrlCache = new Map() + +export async function loadCustomBlobUrl( + id: string, + mimeType: string = 'model/gltf-binary' +): Promise { + const cached = blobUrlCache.get(id) + if (cached) { + return cached + } + const buffer = await window.api.pet.readModel(id) + if (!buffer) { + return null + } + // Why: MIME comes from CustomPetModel.mimeType — required for images + // (especially SVG, which browsers refuse to render from a blob URL with + // the wrong Content-Type). GLB bytes work either way but we pass the + // right one for correctness. + const blob = new Blob([buffer], { type: mimeType }) + const url = URL.createObjectURL(blob) + blobUrlCache.set(id, url) + return url +} + +// Why: the store invokes this on removeCustomPetModel so the underlying Blob +// is released; otherwise the blob: URL keeps it alive for the rest of the +// session, wasting megabytes per imported model. +export function revokeCustomPetBlobUrl(id: string): void { + const url = blobUrlCache.get(id) + if (url) { + URL.revokeObjectURL(url) + blobUrlCache.delete(id) + } +} diff --git a/src/renderer/src/components/pet/pet-models.ts b/src/renderer/src/components/pet/pet-models.ts new file mode 100644 index 00000000000..d38551e9214 --- /dev/null +++ b/src/renderer/src/components/pet/pet-models.ts @@ -0,0 +1,43 @@ +// Why: bundled GLB URLs are Vite ?url asset imports so the renderer's asset +// pipeline hashes them at build time — same approach as the original single- +// model PetScene. Keeping them behind a single registry lets PetScene, +// PetOverlay, and the status-bar picker all agree on the available set without +// drifting. +import demonUrl from '../../../../../resources/pets/demon.glb?url' +import dinosaurUrl from '../../../../../resources/pets/dinosaur.glb?url' +import pottedPlantUrl from '../../../../../resources/pets/potted-plant.glb?url' +import slothUrl from '../../../../../resources/pets/sloth.glb?url' + +export type PetModelId = 'gremlin' | 'dinosaur' | 'potted-plant' | 'sloth' + +export type PetModel = { + id: PetModelId + label: string + url: string +} + +// Why: declare the registry as a readonly tuple keyed on PetModelId so TS +// catches any id/url drift at compile time and so consumers can iterate for +// menus without a separate source of truth. +export const PET_MODELS: readonly PetModel[] = [ + { id: 'gremlin', label: 'Gremlin', url: demonUrl }, + { id: 'dinosaur', label: 'Dinosaur', url: dinosaurUrl }, + { id: 'potted-plant', label: 'Potted plant', url: pottedPlantUrl }, + { id: 'sloth', label: 'Sloth', url: slothUrl } +] as const + +export const DEFAULT_PET_MODEL_ID: PetModelId = 'gremlin' + +/** Resolves a bundled pet model by id. For custom models (arbitrary UUID + * strings), returns null — the caller should fall back to the custom-model + * list on the ui slice. */ +export function findBundledPetModel(id: string | undefined): PetModel | null { + return PET_MODELS.find((m) => m.id === id) ?? null +} + +/** Back-compat: any call site that needs a guaranteed bundled PetModel (e.g. + * the overlay during initial render before a custom blob URL resolves) can + * use this. Unknown ids fall back to the default bundled gremlin. */ +export function resolvePetModel(id: string | undefined): PetModel { + return findBundledPetModel(id) ?? PET_MODELS[0] +} diff --git a/src/renderer/src/components/pet/usePetModelUrl.ts b/src/renderer/src/components/pet/usePetModelUrl.ts new file mode 100644 index 00000000000..dcbf4b9e25d --- /dev/null +++ b/src/renderer/src/components/pet/usePetModelUrl.ts @@ -0,0 +1,76 @@ +import { useEffect, useRef, useState } from 'react' +import { useAppStore } from '../../store' +import { findBundledPetModel, PET_MODELS } from './pet-models' +import { blobUrlCache, loadCustomBlobUrl } from './pet-blob-cache' + +// Re-export so existing callers (the store slice) that point at this module +// keep working without knowing about the cache module split. +export { revokeCustomPetBlobUrl } from './pet-blob-cache' + +/** Resolve the active pet model to a URL the overlay can render. + * + * For bundled models (always GLB) this is synchronous. For custom models we + * issue an IPC read and build a blob: URL with the correct MIME; until that + * resolves, we fall back to the default bundled GLB so the overlay is never + * empty. `kind` tells the overlay whether to render 3D (three.js) or 2D + * (). + */ +export function usePetModelUrl(): { url: string; ready: boolean; kind: 'glb' | 'image' } { + const petModelId = useAppStore((s) => s.petModelId) + const customModels = useAppStore((s) => s.customPetModels) + const bundled = findBundledPetModel(petModelId) + const customMeta = bundled ? null : customModels.find((m) => m.id === petModelId) + + const [customUrl, setCustomUrl] = useState(() => + customMeta ? (blobUrlCache.get(customMeta.id) ?? null) : null + ) + // Why: track the last id we started loading so a rapid switch between + // custom models doesn't let a slower earlier response clobber the newer + // state. + const pendingRef = useRef(null) + + const customId = customMeta?.id ?? null + // Why: back-compat — models imported before image support lack both fields; + // treat them as GLB (the only kind that existed then) with the standard + // glTF MIME so existing users don't have to re-import. + const customMime = customMeta?.mimeType ?? 'model/gltf-binary' + useEffect(() => { + if (!customId) { + setCustomUrl(null) + return + } + const cached = blobUrlCache.get(customId) + if (cached) { + setCustomUrl(cached) + return + } + // Why: clear the previous custom blob URL before awaiting the new one so + // the hook's fallback-to-bundled branch kicks in during the load window. + // Otherwise a switch between two uncached customs would render the new + // customMeta.kind alongside the old customUrl — e.g. a GLB URL handed to + // the branch — until the read IPC returns. + setCustomUrl(null) + pendingRef.current = customId + let cancelled = false + void loadCustomBlobUrl(customId, customMime).then((url) => { + if (cancelled || pendingRef.current !== customId) { + return + } + setCustomUrl(url) + }) + return () => { + cancelled = true + } + }, [customId, customMime]) + + if (bundled) { + return { url: bundled.url, ready: true, kind: 'glb' } + } + if (customMeta && customUrl) { + return { url: customUrl, ready: true, kind: customMeta.kind ?? 'glb' } + } + // Fallback: while a custom blob URL is loading (or if the custom model is + // missing entirely), render the default bundled GLB so the overlay + // doesn't flash empty. + return { url: PET_MODELS[0].url, ready: false, kind: 'glb' } +} diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx index 8df3cc7757b..b04b4ee2cbc 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.tsx @@ -91,8 +91,9 @@ export function ExperimentalPane({ const showAgentDashboard = matchesSettingsSearch(searchQuery, [ EXPERIMENTAL_PANE_SEARCH_ENTRIES[0] ]) + const showPet = matchesSettingsSearch(searchQuery, [EXPERIMENTAL_PANE_SEARCH_ENTRIES[1]]) const showOrchestration = matchesSettingsSearch(searchQuery, [ - EXPERIMENTAL_PANE_SEARCH_ENTRIES[1] + EXPERIMENTAL_PANE_SEARCH_ENTRIES[2] ]) const [orchestrationEnabled, setOrchestrationEnabled] = useState(() => { @@ -228,11 +229,49 @@ export function ExperimentalPane({ ) : null} + {showPet ? ( + +
+
+ +

+ Shows a small animated 3D gremlin pinned to the bottom-right corner. Hide it any + time from the status-bar pet menu without disabling this setting. Loads three.js and + a GLB model only while enabled, so users who leave it off pay zero cost. +

+
+ +
+
+ ) : null} + {showOrchestration ? (
diff --git a/src/renderer/src/components/settings/experimental-search.ts b/src/renderer/src/components/settings/experimental-search.ts index 86ba469ffe9..81ee2af0e4c 100644 --- a/src/renderer/src/components/settings/experimental-search.ts +++ b/src/renderer/src/components/settings/experimental-search.ts @@ -21,6 +21,22 @@ export const EXPERIMENTAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ 'sidebar' ] }, + { + title: 'Gremlin (pet)', + description: + 'Floating animated 3D pet in the bottom-right corner. Experimental — loads three.js and a GLB model only when enabled.', + keywords: [ + 'experimental', + 'pet', + 'gremlin', + 'mascot', + 'overlay', + '3d', + 'three', + 'animated', + 'corner' + ] + }, { title: 'Agent Orchestration', description: diff --git a/src/renderer/src/components/status-bar/PetStatusSegment.tsx b/src/renderer/src/components/status-bar/PetStatusSegment.tsx new file mode 100644 index 00000000000..bfc798cceb9 --- /dev/null +++ b/src/renderer/src/components/status-bar/PetStatusSegment.tsx @@ -0,0 +1,208 @@ +import React from 'react' +import { Cat, Check, Trash2, Upload } from 'lucide-react' +import { toast } from 'sonner' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useAppStore } from '../../store' +import { findBundledPetModel, PET_MODELS } from '../pet/pet-models' + +// Why: cluster pet-related controls (show/hide, model picker, custom GLB +// upload + removal, jump-to-settings) behind a single status-bar segment. +// Only rendered when experimentalPet is on (gated by the caller). Pet +// visibility is independently tracked so users can dismiss without having to +// find the experimental flag again. +function PetStatusSegmentInner({ + compact, + iconOnly +}: { + compact: boolean + iconOnly: boolean +}): React.JSX.Element { + const petVisible = useAppStore((s) => s.petVisible) + const setPetVisible = useAppStore((s) => s.setPetVisible) + const petModelId = useAppStore((s) => s.petModelId) + const setPetModelId = useAppStore((s) => s.setPetModelId) + const customPetModels = useAppStore((s) => s.customPetModels) + const addCustomPetModel = useAppStore((s) => s.addCustomPetModel) + const removeCustomPetModel = useAppStore((s) => s.removeCustomPetModel) + const openSettingsPage = useAppStore((s) => s.openSettingsPage) + const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + + const bundled = findBundledPetModel(petModelId) + const activeCustom = bundled ? null : customPetModels.find((m) => m.id === petModelId) + const activeLabel = bundled?.label ?? activeCustom?.label ?? 'Pet' + const label = petVisible ? activeLabel : `${activeLabel} hidden` + + const handleImport = async (): Promise => { + // Why: surface end-to-end diagnostics under a stable prefix so clicks on + // Upload GLB… that appear to do nothing (preload not reloaded, IPC not + // registered, dialog dismissed) leave a greppable trail in the console. + console.log('[pet-overlay] upload: click') + if (!window.api?.pet?.importModel) { + console.warn('[pet-overlay] upload: window.api.pet.importModel missing — restart Orca') + toast.error('Custom pet upload needs a full app restart (not just reload).') + return + } + try { + const model = await window.api.pet.importModel() + console.log('[pet-overlay] upload: result', model) + if (!model) { + return + } + addCustomPetModel(model) + if (!petVisible) { + setPetVisible(true) + } + setPetModelId(model.id) + toast.success(`Added "${model.label}"`) + } catch (error) { + console.error('[pet-overlay] upload: error', error) + toast.error(error instanceof Error ? error.message : 'Failed to import file') + } + } + + return ( + + + + + + + + + {petVisible ? `${activeLabel} (pet)` : `${activeLabel} hidden — click to restore`} + + + + Pet + { + event.preventDefault() + setPetVisible(!petVisible) + }} + > + {petVisible ? 'Hide pet' : 'Show pet'} + + + Model + {/* Why: portal so the submenu escapes the parent Content's overflow + clipping — without this, the submenu opens inside the scroll + container and gets clipped. Explicit portal matches the existing + convention used in BrowserToolbarMenu/BrowserProfileRow. */} + + + {PET_MODELS.map((model) => { + const selected = model.id === petModelId + return ( + { + event.preventDefault() + if (!petVisible) { + setPetVisible(true) + } + setPetModelId(model.id) + }} + > + + {selected ? : null} + + {model.label} + + ) + })} + {customPetModels.length > 0 ? : null} + {customPetModels.map((model) => { + const selected = model.id === petModelId + return ( + { + event.preventDefault() + if (!petVisible) { + setPetVisible(true) + } + setPetModelId(model.id) + }} + > + + {selected ? : null} + + {model.label} + {/* Why: inline remove — a native button sits inside the menu + item so the destructive action is discoverable without + another menu level. stopPropagation prevents Radix from + also triggering the item's onSelect. */} + + + ) + })} + + { + // Why: let the menu close naturally (no preventDefault) before + // invoking the native file picker. Keeping the menu open when + // the OS dialog opens caused the dialog to appear behind the + // dropdown overlay on macOS — clicks on "Upload GLB…" looked + // like nothing happened. + void handleImport() + }} + > + + Upload GLB or image… + + + + + + { + openSettingsTarget({ + pane: 'experimental', + repoId: null, + sectionId: 'experimental-pet' + }) + openSettingsPage() + }} + > + Pet settings… + + + + ) +} + +export const PetStatusSegment = React.memo(PetStatusSegmentInner) diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 061897cc376..00fbacdb2a8 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -35,6 +35,7 @@ import { SshStatusSegment } from './SshStatusSegment' import { SessionsStatusSegment } from './SessionsStatusSegment' import { UpdateStatusSegment } from './UpdateStatusSegment' import { MemoryStatusSegment } from './MemoryStatusSegment' +import { PetStatusSegment } from './PetStatusSegment' function getCodexAccountLabel( state: CodexRateLimitAccountsState, @@ -705,6 +706,11 @@ function StatusBarInner(): React.JSX.Element | null { const refreshRateLimits = useAppStore((s) => s.refreshRateLimits) const statusBarVisible = useAppStore((s) => s.statusBarVisible) const statusBarItems = useAppStore((s) => s.statusBarItems) + // Why: pet segment intentionally does NOT participate in statusBarItems + // (see design doc — gating with both the experimental flag and a + // statusBarItems checkbox would double-toggle the surface). It is driven + // purely by the experimentalPet settings flag. + const petEnabled = useAppStore((s) => s.settings?.experimentalPet === true) const toggleStatusBarItem = useAppStore((s) => s.toggleStatusBarItem) const containerRef = useRef(null) const [isRefreshing, setIsRefreshing] = useState(false) @@ -843,6 +849,7 @@ function StatusBarInner(): React.JSX.Element | null {
+ {petEnabled && } {showMemory && } {showSessions && } {showSsh && } diff --git a/src/renderer/src/components/ui/dropdown-menu.tsx b/src/renderer/src/components/ui/dropdown-menu.tsx index be59a85e3a2..8e7c65d3883 100644 --- a/src/renderer/src/components/ui/dropdown-menu.tsx +++ b/src/renderer/src/components/ui/dropdown-menu.tsx @@ -201,6 +201,7 @@ function DropdownMenuSubTrigger({ function DropdownMenuSubContent({ className, + style, ...props }: React.ComponentProps) { return ( @@ -210,6 +211,9 @@ function DropdownMenuSubContent({ 'z-50 min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-[11px] border border-black/14 bg-[rgba(255,255,255,0.82)] p-1 text-black dark:text-white shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl dark:border-white/14 dark:bg-[rgba(0,0,0,0.72)] dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', className )} + // Why: same no-drag fix as DropdownMenuContent — titlebar drag region + // would otherwise capture clicks when submenu overlaps it. + style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties} {...props} /> ) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 0888ebd0922..6979da94ca2 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -4,6 +4,7 @@ import type { AppState } from '../types' import { findPrevLiveWorktreeHistoryIndex } from './worktree-nav-history' import type { ChangelogData, + CustomPetModel, PersistedTrustedOrcaHooks, PersistedUIState, StatusBarItem, @@ -38,6 +39,8 @@ import { DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../../../shared/constants' import type { OrcaHookScriptKind } from '../../lib/orca-hook-trust' +import { DEFAULT_PET_MODEL_ID, PET_MODELS } from '../../components/pet/pet-models' +import { revokeCustomPetBlobUrl } from '../../components/pet/pet-blob-cache' const MIN_SIDEBAR_WIDTH = 220 const MAX_LEFT_SIDEBAR_WIDTH = 500 @@ -192,6 +195,21 @@ export type UISlice = { toggleStatusBarItem: (item: StatusBarItem) => void statusBarVisible: boolean setStatusBarVisible: (v: boolean) => void + /** Whether the experimental pet overlay is currently visible. Persisted so + * "Hide pet" from the status-bar menu survives reload. Independent of the + * experimentalPet settings flag — the feature flag gates whether the + * overlay can ever render; this controls whether it does right now. */ + petVisible: boolean + setPetVisible: (v: boolean) => void + /** Which pet model is active. May be a bundled PetModelId or a custom + * model UUID. Persisted alongside petVisible via the PersistedUIState + * pipeline. */ + petModelId: string + setPetModelId: (id: string) => void + /** User-uploaded GLB models. Metadata only — bytes live in main's userData. */ + customPetModels: CustomPetModel[] + addCustomPetModel: (model: CustomPetModel) => void + removeCustomPetModel: (id: string) => void pendingRevealWorktreeId: string | null revealWorktreeInSidebar: (worktreeId: string) => void clearPendingRevealWorktreeId: () => void @@ -479,6 +497,52 @@ export const createUISlice: StateCreator = (set, get) set({ statusBarVisible: v }) }, + // Why: default true so a user who enables experimentalPet sees the pet + // immediately. Hide pet from the status-bar menu flips this to false; the + // value is persisted via the standard PersistedUIState pipeline. + petVisible: true, + setPetVisible: (v) => { + window.api.ui.set({ petVisible: v }).catch(console.error) + set({ petVisible: v }) + }, + + petModelId: DEFAULT_PET_MODEL_ID, + setPetModelId: (id) => { + window.api.ui.set({ petModelId: id }).catch(console.error) + set({ petModelId: id }) + }, + + customPetModels: [], + addCustomPetModel: (model) => + set((s) => { + const next = [...s.customPetModels.filter((m) => m.id !== model.id), model] + window.api.ui.set({ customPetModels: next }).catch(console.error) + return { customPetModels: next } + }), + removeCustomPetModel: (id) => + set((s) => { + if (!s.customPetModels.some((m) => m.id === id)) { + return s + } + const next = s.customPetModels.filter((m) => m.id !== id) + window.api.ui.set({ customPetModels: next }).catch(console.error) + // Why: if the user removes the currently-active custom model, fall back + // to the default bundled model so the overlay doesn't render nothing. + const fallback = s.petModelId === id ? DEFAULT_PET_MODEL_ID : s.petModelId + if (fallback !== s.petModelId) { + window.api.ui.set({ petModelId: fallback }).catch(console.error) + } + // Why: revoke the cached blob: URL so the underlying Blob is released; + // otherwise it stays in memory for the rest of the session. + revokeCustomPetBlobUrl(id) + // Why: best-effort — the bytes are owned by main. If the disk delete + // fails, the orphaned GLB stays in userData (~MBs); each import uses a + // fresh UUID so the file won't be hit again, and the renderer's + // metadata index no longer references it. Leaving it is acceptable. + window.api.pet.deleteModel(id).catch(console.error) + return { customPetModels: next, petModelId: fallback } + }), + pendingRevealWorktreeId: null, revealWorktreeInSidebar: (worktreeId) => set({ pendingRevealWorktreeId: worktreeId }), clearPendingRevealWorktreeId: () => set({ pendingRevealWorktreeId: null }), @@ -529,6 +593,29 @@ export const createUISlice: StateCreator = (set, get) worktreeCardProperties: ui.worktreeCardProperties ?? [...DEFAULT_WORKTREE_CARD_PROPERTIES], statusBarItems: ui.statusBarItems ?? [...DEFAULT_STATUS_BAR_ITEMS], statusBarVisible: ui.statusBarVisible ?? true, + // Why: absent → true so existing users see the pet the first time + // they enable the experimental flag. Only an explicit Hide pet + // dismissal persists a `false` value. + petVisible: ui.petVisible ?? true, + customPetModels: Array.isArray(ui.customPetModels) ? ui.customPetModels : [], + // Why: accept the persisted id if it matches a bundled model or a + // known custom model; otherwise fall back to the default so the + // overlay never renders nothing (e.g. custom model was removed by + // another window/session). + petModelId: ((): string => { + const id = ui.petModelId + if (typeof id !== 'string') { + return DEFAULT_PET_MODEL_ID + } + if (PET_MODELS.some((m) => m.id === id)) { + return id + } + const custom = Array.isArray(ui.customPetModels) ? ui.customPetModels : [] + if (custom.some((m) => m.id === id)) { + return id + } + return DEFAULT_PET_MODEL_ID + })(), dismissedUpdateVersion: ui.dismissedUpdateVersion ?? null, updateReassuranceSeen: ui.updateReassuranceSeen ?? false, browserDefaultUrl: ui.browserDefaultUrl ?? null, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 77f46633d05..71edb8184dd 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -191,7 +191,11 @@ export function getDefaultSettings(homedir: string): GlobalSettings { // Why: opt-in preview — default off so managed-hook installation // (Claude/Codex/Gemini) stays dormant for existing users and upgraders // (persistence.ts merges defaults first, so upgraders inherit this). - experimentalAgentDashboard: false + experimentalAgentDashboard: false, + // Why: off by default — opting in dynamically imports three.js and a GLB + // model. Leaving the default false keeps the renderer bundle unaffected + // for users who never enable the feature. + experimentalPet: false } } diff --git a/src/shared/types.ts b/src/shared/types.ts index 945b33403a9..bec8bcf6c04 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1045,6 +1045,13 @@ export type GlobalSettings = { * takes effect on the next app launch. The in-pane status indicators and * the cursor-agent hook path are unaffected by this toggle. */ experimentalAgentDashboard: boolean + /** Experimental: floating animated pet overlay in the bottom-right corner. + * Opt-in because it dynamically loads three.js + @react-three/fiber and a + * GLB model; users who leave it off pay zero bytes — the PetOverlay is + * never mounted and the three.js chunks are never imported. Unlike + * experimentalAgentDashboard, toggling this takes effect immediately in + * the current session (no relaunch) because it is purely renderer-side. */ + experimentalPet: boolean } export type GhosttyImportPreview = { @@ -1162,6 +1169,44 @@ export type PersistedUIState = { * suppress the nag — no further thresholds, no notifications. */ starNagCompleted?: boolean trustedOrcaHooks?: PersistedTrustedOrcaHooks + /** Whether the experimental pet overlay is currently visible. Separate from + * the experimentalPet settings flag so "Hide pet" from the status-bar menu + * is a reversible dismiss (re-show without re-enabling the feature). + * Absent = treated as true so existing users see the pet the first time + * they enable the experimental flag. */ + petVisible?: boolean + /** Which pet model is currently displayed. Stores a bundled id + * ('gremlin' | 'dinosaur' | 'potted-plant' | 'sloth') or a custom model UUID. Unknown + * ids fall back to the default at read time so removing a custom model the + * user had selected doesn't leave the overlay rendering nothing. */ + petModelId?: string + /** User-uploaded pet models. GLB bytes live under userData/pets/custom/; + * this field is the metadata index. Keeping it here (vs a separate store) + * means custom models ride the existing PersistedUIState save pipeline. */ + customPetModels?: CustomPetModel[] +} + +/** Metadata for a single user-uploaded GLB. The `id` is both the stable pet + * model id and (with `.glb` appended) the on-disk filename under + * userData/pets/custom/. Renderer never learns the absolute path — it asks + * main for the bytes via pet:read. */ +/** Discriminator for custom pet kinds: + * - 'glb': 3D GLB rendered via three.js in PetScene (supports drag-to-rotate). + * - 'image': 2D image (static or animated: png/jpg/webp/apng/gif/svg) rendered + * via a plain tag in PetOverlay, skipping three.js entirely. + * Older entries without this field are implicitly 'glb' for back-compat. + */ +export type CustomPetKind = 'glb' | 'image' + +export type CustomPetModel = { + id: string + label: string + fileName: string + kind?: CustomPetKind + /** MIME type for 'image' kinds — needed so the renderer builds a Blob with + * the right Content-Type (especially image/svg+xml, which browsers won't + * render from a misdeclared blob URL). Unused for 'glb'. */ + mimeType?: string } export type PersistedTrustedOrcaHookEntry = {