feat: add experimental pet overlay

Adds an opt-in 3D pet overlay pinned to the bottom-right. Gated behind
an experimental flag so three.js + GLB models stay out of the renderer
bundle for users who never enable the feature. Ships with four bundled
models plus user-uploaded custom GLBs via a pet:import IPC; a status-bar
segment provides model picker + hide toggle.

See docs/design/pet-overlay.md for the full design.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing
2026-05-01 15:38:24 -07:00
co-authored by Orca
parent d17ba3e036
commit 905f71b4ff
30 changed files with 2255 additions and 3 deletions
+98
View File
@@ -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/<uuid>.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.
+387
View File
@@ -0,0 +1,387 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Pet Overlay — UI mock</title>
<style>
:root {
--bg: #0d1117;
--bg-2: #11161d;
--bg-3: #161c24;
--border: #222a34;
--fg: #c9d1d9;
--fg-dim: #8b949e;
--accent: #4c8bf5;
--green: #3fb950;
--amber: #d29922;
--modal-z: 40;
--pet-z: 30;
}
* { box-sizing: border-box; }
html, body {
margin: 0; height: 100%;
background: var(--bg);
color: var(--fg);
font: 13px/1.45 -apple-system, "SF Pro Text", Inter, system-ui, sans-serif;
overflow: hidden;
}
/* ---------- App chrome ---------- */
.app {
display: grid;
grid-template-rows: 36px 1fr 24px;
grid-template-columns: 240px 1fr;
height: 100%;
}
.tabbar {
grid-column: 1 / -1;
display: flex; align-items: center; gap: 2px;
padding: 0 10px;
background: var(--bg-2);
border-bottom: 1px solid var(--border);
-webkit-app-region: drag;
}
.traffic { display: flex; gap: 6px; margin-right: 10px; }
.traffic i { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
.traffic i:nth-child(1) { background: #ff5f57; }
.traffic i:nth-child(2) { background: #febc2e; }
.traffic i:nth-child(3) { background: #28c840; }
.tab {
padding: 6px 12px;
font-size: 12px; color: var(--fg-dim);
border-radius: 6px 6px 0 0;
}
.tab.active { background: var(--bg); color: var(--fg); }
.sidebar {
background: var(--bg-2);
border-right: 1px solid var(--border);
padding: 10px 8px;
overflow-y: auto;
}
.sidebar h4 {
margin: 10px 6px 4px;
font-size: 10px; font-weight: 600;
letter-spacing: 0.08em; text-transform: uppercase;
color: var(--fg-dim);
}
.wt {
display: flex; align-items: center; gap: 8px;
padding: 6px 8px; border-radius: 6px;
color: var(--fg-dim); font-size: 12px;
}
.wt.active { background: #1b2430; color: var(--fg); }
.wt .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--green); }
.main {
position: relative;
background: var(--bg);
display: grid;
grid-template-rows: 1fr 1fr;
gap: 1px;
background-color: var(--border);
}
.pane {
background: var(--bg);
padding: 10px 14px;
font-family: "SF Mono", Menlo, monospace;
font-size: 12px; color: #b6c2cf;
overflow: hidden;
}
.pane .prompt { color: var(--green); }
.pane .path { color: var(--accent); }
.pane .muted { color: var(--fg-dim); }
/* ---------- Status bar ---------- */
.statusbar {
grid-column: 1 / -1;
display: flex; align-items: center;
padding: 0 8px; gap: 2px;
background: var(--bg-2);
border-top: 1px solid var(--border);
font-size: 11px; color: var(--fg-dim);
position: relative;
}
.statusbar .spacer { flex: 1; }
.seg {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 8px; border-radius: 4px;
cursor: default;
}
.seg:hover { background: #1b2430; color: var(--fg); }
.seg .chip {
display: inline-block; width: 6px; height: 6px; border-radius: 50%;
background: var(--green);
}
.seg.pet {
position: relative;
color: var(--fg);
}
.seg.pet .pet-ico {
width: 14px; height: 14px;
background: radial-gradient(circle at 35% 30%, #6dd3a8 0%, #2f8f6a 70%, #1a5a44 100%);
border-radius: 50%;
box-shadow: inset -1px -2px 0 rgba(0,0,0,.35), 0 0 0 1px #1a5a44;
}
/* ---------- Pet menu (open state) ---------- */
.menu {
position: absolute;
bottom: 28px;
min-width: 180px;
background: #1b2430;
border: 1px solid #2b3643;
border-radius: 8px;
padding: 4px;
box-shadow: 0 8px 24px rgba(0,0,0,0.45);
color: var(--fg);
font-size: 12px;
z-index: 50;
}
.menu .row {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px; border-radius: 5px; cursor: default;
}
.menu .row:hover { background: #27354a; }
.menu .row .kbd {
margin-left: auto; color: var(--fg-dim); font-size: 10px;
}
.menu .sep {
height: 1px; background: #2b3643; margin: 4px 2px;
}
.menu .label {
padding: 6px 10px 2px; color: var(--fg-dim); font-size: 10px;
letter-spacing: 0.06em; text-transform: uppercase;
}
/* ---------- Pet overlay (bottom-right, pointer-events: none) ---------- */
.pet-overlay {
position: absolute;
right: 18px; bottom: 34px; /* sits above status bar */
width: 120px; height: 120px;
pointer-events: none;
z-index: var(--pet-z);
/* transparent "canvas" — the GLB would render here */
filter: drop-shadow(0 8px 12px rgba(0,0,0,0.5));
}
.pet {
position: absolute; inset: 10px;
animation: bob 3.2s ease-in-out infinite;
}
@keyframes bob {
0%,100% { transform: translateY(0) rotate(-2deg); }
50% { transform: translateY(-6px) rotate(2deg); }
}
@media (prefers-reduced-motion: reduce) {
.pet { animation: none; }
}
/* a stand-in "gremlin": stacked gradient blobs to fake 3D */
.pet .body {
position: absolute; inset: 10% 15% 5% 15%;
border-radius: 50% 50% 45% 45% / 55% 55% 45% 45%;
background:
radial-gradient(ellipse at 35% 25%, #a4e8c7 0%, #4fbf8d 45%, #1f6a4b 100%);
box-shadow:
inset -6px -10px 12px rgba(0,0,0,0.35),
inset 4px 6px 10px rgba(255,255,255,0.15);
}
.pet .eye {
position: absolute; width: 18px; height: 22px;
background: #fff; border-radius: 50%;
top: 32%;
box-shadow: inset 0 -2px 3px rgba(0,0,0,0.1);
}
.pet .eye.l { left: 30%; }
.pet .eye.r { right: 30%; }
.pet .eye::after {
content: ""; position: absolute;
width: 8px; height: 10px; border-radius: 50%;
background: #1a1a1a;
top: 40%; left: 50%;
transform: translate(-50%, -20%);
animation: look 4s ease-in-out infinite;
}
@keyframes look {
0%, 40% { transform: translate(-50%, -20%); }
50%, 70% { transform: translate(-110%, -20%); }
80%, 100% { transform: translate(-50%, -20%); }
}
.pet .mouth {
position: absolute;
left: 50%; top: 62%;
width: 22px; height: 10px;
transform: translateX(-50%);
border-bottom: 2px solid #0f3c2a;
border-radius: 0 0 20px 20px;
}
.pet .ear {
position: absolute; width: 22px; height: 30px;
background: linear-gradient(160deg, #4fbf8d, #1f6a4b);
border-radius: 60% 60% 40% 40% / 70% 70% 30% 30%;
top: 2%;
}
.pet .ear.l { left: 14%; transform: rotate(-20deg); }
.pet .ear.r { right: 14%; transform: rotate(20deg); }
/* ---------- Annotations ---------- */
.note {
position: absolute;
background: rgba(76, 139, 245, 0.12);
border: 1px dashed var(--accent);
color: #cfe0ff;
padding: 4px 8px;
font-size: 10px;
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
}
.note::before {
content: ""; position: absolute;
width: 30px; height: 1px;
background: var(--accent);
}
.note.a {
right: 150px; bottom: 90px;
}
.note.a::before { right: -30px; top: 50%; }
.note.b {
right: 200px; bottom: 40px;
}
.note.b::before { right: -40px; top: 50%; width: 40px; }
/* disabled-state comparison — small inset preview */
.compare {
position: absolute;
right: 18px; top: 50px;
width: 240px;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
font-size: 11px;
color: var(--fg-dim);
z-index: 60;
}
.compare h5 { margin: 0 0 6px; color: var(--fg); font-size: 11px; }
.compare .row2 {
display: flex; gap: 8px; margin-top: 6px;
}
.compare .mini {
flex: 1;
height: 54px;
border-radius: 5px;
background: var(--bg);
position: relative;
overflow: hidden;
border: 1px solid var(--border);
}
.compare .mini.off::after {
content: "off — 0 bytes"; position: absolute;
right: 6px; bottom: 4px; font-size: 9px; color: var(--fg-dim);
}
.compare .mini.on::after {
content: "on"; position: absolute;
right: 6px; bottom: 4px; font-size: 9px; color: var(--green);
}
.compare .mini.on::before {
content: ""; position: absolute;
right: 6px; bottom: 14px; width: 22px; height: 22px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #a4e8c7 0%, #4fbf8d 50%, #1f6a4b 100%);
box-shadow: 0 2px 3px rgba(0,0,0,0.5);
}
</style>
</head>
<body>
<div class="app">
<!-- tab bar -->
<div class="tabbar">
<div class="traffic"><i></i><i></i><i></i></div>
<div class="tab active">orca</div>
<div class="tab">add-a-pet-for-orca</div>
<div class="tab">share-text-search</div>
</div>
<!-- sidebar -->
<aside class="sidebar">
<h4>Repos</h4>
<div class="wt active"><span class="dot"></span>orca</div>
<div class="wt"><span class="dot" style="background:#d29922"></span>internal-tools</div>
<h4>Worktrees</h4>
<div class="wt active"><span class="dot"></span>add-a-pet-for-orca</div>
<div class="wt"><span class="dot"></span>main</div>
<div class="wt"><span class="dot" style="background:#8b949e"></span>persist-ssh-sessions</div>
<div class="wt"><span class="dot" style="background:#8b949e"></span>share-text-search</div>
</aside>
<!-- main content: fake terminals -->
<section class="main">
<div class="pane">
<div><span class="prompt">~/orca/add-a-pet-for-orca</span> <span class="muted">$</span> pnpm dev</div>
<div class="muted">› Local: http://localhost:5173</div>
<div class="muted">› ready in 412ms</div>
<div>&nbsp;</div>
<div><span class="prompt">$</span> <span class="muted">_</span></div>
</div>
<div class="pane">
<div><span class="prompt">~/orca</span> <span class="muted">$</span> git status</div>
<div class="muted">On branch add-a-pet-for-orca</div>
<div class="muted">Untracked files:</div>
<div>&nbsp;&nbsp;docs/design/pet-overlay.md</div>
<div>&nbsp;&nbsp;docs/design/pet-overlay.mock.html</div>
<div>&nbsp;</div>
<div><span class="prompt">$</span> <span class="muted">_</span></div>
</div>
<!-- Pet overlay (bottom-right of main pane area) -->
<div class="pet-overlay" aria-hidden="true">
<div class="pet">
<div class="ear l"></div>
<div class="ear r"></div>
<div class="body"></div>
<div class="eye l"></div>
<div class="eye r"></div>
<div class="mouth"></div>
</div>
</div>
<div class="note a">GLB canvas — pointer-events: none, z-index: var(--pet-z)</div>
<div class="note b">PetStatusSegment ↓ (only when experimentalPet=true)</div>
<!-- comparison card: enabled vs disabled -->
<div class="compare">
<h5>Zero-cost-when-disabled</h5>
<div>three.js never imported when <code>experimentalPet=false</code>. Component unmounts = no listeners, no RAF loop.</div>
<div class="row2">
<div class="mini off"></div>
<div class="mini on"></div>
</div>
</div>
</section>
<!-- status bar -->
<footer class="statusbar">
<span class="seg"><span class="chip"></span>claude · 78%</span>
<span class="seg"><span class="chip" style="background:var(--amber)"></span>codex · 42%</span>
<span class="seg">● 3 sessions</span>
<span class="seg">ssh</span>
<span class="seg">mem 412MB</span>
<span class="spacer"></span>
<span class="seg pet" id="pet-seg">
<span class="pet-ico"></span>
Pet
<!-- Menu rendered open for the mock -->
<div class="menu" role="menu">
<div class="label">Gremlin</div>
<div class="row">Hide pet <span class="kbd">⌘⇧P</span></div>
<div class="sep"></div>
<div class="row">Pet settings…</div>
</div>
</span>
<span class="seg">v1.3.26-rc.0</span>
</footer>
</div>
</body>
</html>
+4
View File
@@ -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",
+457
View File
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+84
View File
@@ -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/<slug>.glb unless --out is given.
#
# Usage:
# scripts/compress-pet-glb.sh <input.glb> [--out <path>] [--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 <input.glb> [--out <path>] [--size <px>]" >&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)"
@@ -92,6 +92,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalMacOptionAsAlt: 'false',
terminalMacOptionAsAltMigrated: true,
experimentalAgentDashboard: false,
experimentalPet: false,
terminalWindowsShell: 'powershell.exe',
enableGitHubAttribution: true,
...overrides
+1
View File
@@ -86,6 +86,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalMacOptionAsAlt: 'false',
terminalMacOptionAsAltMigrated: true,
experimentalAgentDashboard: false,
experimentalPet: false,
terminalWindowsShell: 'powershell.exe',
enableGitHubAttribution: true,
...overrides
+183
View File
@@ -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 <img>; 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<string, string> = {
'.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 `<id>.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<CustomPetModel | null> => {
// 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<ReturnType<typeof stat>>
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 `<id>.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<ArrayBuffer | null> => {
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<void> => {
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)
}
})
}
@@ -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)
+2
View File
@@ -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)
+6
View File
@@ -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<string | null>
copyFile: (args: { srcPath: string; destPath: string }) => Promise<void>
}
pet: {
importModel: () => Promise<CustomPetModel | null>
readModel: (id: string) => Promise<ArrayBuffer | null>
deleteModel: (id: string) => Promise<void>
}
browser: BrowserApi
hooks: {
check: (args: {
+7
View File
@@ -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<CustomPetModel | null> => ipcRenderer.invoke('pet:import'),
readModel: (id: string): Promise<ArrayBuffer | null> => ipcRenderer.invoke('pet:read', id),
deleteModel: (id: string): Promise<void> => ipcRenderer.invoke('pet:delete', id)
},
browser: {
registerGuest: (args: {
browserPageId: string
+15
View File
@@ -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<HTMLDivElement | null>(null)
@@ -1084,6 +1090,15 @@ function App(): React.JSX.Element {
{mountedLazyModalIds.has('quick-open') ? <QuickOpen /> : null}
{mountedLazyModalIds.has('worktree-palette') ? <WorktreeJumpPalette /> : null}
</Suspense>
{/* 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 ? (
<Suspense fallback={null}>
<PetOverlay />
</Suspense>
) : null}
<UpdateCard />
<StarNagCard />
<ZoomOverlay />
@@ -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<boolean | null>(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
aria-hidden
className={
kind === 'glb'
? 'pointer-events-none fixed bottom-4 right-0 z-40 h-[140px] w-[140px] [&>div]:pointer-events-auto'
: 'pointer-events-none fixed bottom-4 right-0 z-40 h-[140px] w-[140px]'
}
>
{kind === 'image' ? (
<ImagePet src={modelUrl} animate={documentVisible && !reducedMotion} />
) : (
<Suspense fallback={null}>
<PetScene animate={documentVisible && !reducedMotion} modelUrl={modelUrl} />
</Suspense>
)}
</div>
)
}
// Why: tiny inline component for 2D pets — static and animated formats
// (apng/gif/webp/svg) all render natively via <img>, 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 (
<div
className="flex size-full items-center justify-center"
style={{
animation: 'pet-bob 1.2s ease-in-out infinite',
animationPlayState: animate ? 'running' : 'paused'
}}
>
<style>
{
'@keyframes pet-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }'
}
</style>
<img src={src} alt="" className="max-h-full max-w-full object-contain" draggable={false} />
</div>
)
}
export default PetOverlay
@@ -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<THREE.Group>(null)
const spinRef = useRef<THREE.Group>(null)
const fitRef = useRef<THREE.Group>(null)
const { camera, invalidate } = useThree()
// Why: useGLTF suspends until the model is fetched and parsed; wrapped by
// the parent <Suspense> 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 <Bounds>. <Bounds> 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 <Center> 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<PointerEvent>): 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<PointerEvent>): 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<PointerEvent>): 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 (
<group
ref={bobRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<group ref={spinRef}>
<group ref={fitRef}>
<primitive object={scene} />
</group>
</group>
</group>
)
}
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 (
<Canvas
// Why: transparent background so the pet sits directly on top of the
// app chrome. `alpha: true` + clearColor 0 keeps every pixel outside
// the mesh fully transparent.
gl={{ alpha: true, antialias: true, preserveDrawingBuffer: false }}
camera={{ position: [0, 0, 3.5], fov: 35 }}
// Why: frameloop 'demand' when animation is disabled so the idle
// state doesn't re-render every frame — saves battery when the user
// prefers reduced motion or the window is backgrounded. The fit
// effect calls invalidate() so the initial frame still renders.
frameloop={animate ? 'always' : 'demand'}
onCreated={({ gl }) => {
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)
}}
>
<ambientLight intensity={0.6} />
<directionalLight position={[2, 3, 2]} intensity={0.9} />
<directionalLight position={[-2, 1, -1]} intensity={0.35} color={0x88aaff} />
<Suspense fallback={null}>
{/* 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. */}
<FittedPetModel key={modelUrl} animate={animate} url={modelUrl} />
</Suspense>
</Canvas>
)
}
export default PetScene
@@ -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<string, string>()
export async function loadCustomBlobUrl(
id: string,
mimeType: string = 'model/gltf-binary'
): Promise<string | null> {
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)
}
}
@@ -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]
}
@@ -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
* (<img>).
*/
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<string | null>(() =>
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<string | null>(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 <img> 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' }
}
@@ -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<boolean>(() => {
@@ -228,11 +229,49 @@ export function ExperimentalPane({
</SearchableSetting>
) : null}
{showPet ? (
<SearchableSetting
title="Gremlin (pet)"
description="Floating animated 3D pet in the bottom-right corner."
keywords={EXPERIMENTAL_PANE_SEARCH_ENTRIES[1].keywords}
className="space-y-3 px-1 py-2"
id="experimental-pet"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 shrink space-y-1.5">
<Label>Gremlin (pet)</Label>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={settings.experimentalPet}
onClick={() => {
updateSettings({ experimentalPet: !settings.experimentalPet })
}}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.experimentalPet ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow-sm transition-transform ${
settings.experimentalPet ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
</SearchableSetting>
) : null}
{showOrchestration ? (
<SearchableSetting
title="Agent Orchestration"
description="Coordinate multiple coding agents via messaging, task DAGs, dispatch, and decision gates."
keywords={EXPERIMENTAL_PANE_SEARCH_ENTRIES[1].keywords}
keywords={EXPERIMENTAL_PANE_SEARCH_ENTRIES[2].keywords}
className="space-y-3 px-1 py-2"
>
<div className="flex items-start justify-between gap-4">
@@ -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:
@@ -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<void> => {
// 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 (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex items-center cursor-pointer gap-1 rounded px-1 py-0.5 text-muted-foreground hover:bg-accent/70 hover:text-foreground"
aria-label="Pet menu"
>
<Cat className={`size-3.5 ${petVisible ? '' : 'opacity-50'}`} aria-hidden />
{!iconOnly && !compact ? (
<span className="text-[11px] font-medium">{label}</span>
) : null}
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{petVisible ? `${activeLabel} (pet)` : `${activeLabel} hidden — click to restore`}
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="top" align="end" sideOffset={8} className="min-w-[220px]">
<DropdownMenuLabel>Pet</DropdownMenuLabel>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault()
setPetVisible(!petVisible)
}}
>
{petVisible ? 'Hide pet' : 'Show pet'}
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Model</DropdownMenuSubTrigger>
{/* 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. */}
<DropdownMenuPortal>
<DropdownMenuSubContent className="min-w-[220px]">
{PET_MODELS.map((model) => {
const selected = model.id === petModelId
return (
<DropdownMenuItem
key={model.id}
onSelect={(event) => {
event.preventDefault()
if (!petVisible) {
setPetVisible(true)
}
setPetModelId(model.id)
}}
>
<span className="flex w-4 items-center justify-center">
{selected ? <Check className="size-3.5" aria-hidden /> : null}
</span>
{model.label}
</DropdownMenuItem>
)
})}
{customPetModels.length > 0 ? <DropdownMenuSeparator /> : null}
{customPetModels.map((model) => {
const selected = model.id === petModelId
return (
<DropdownMenuItem
key={model.id}
className="group"
onSelect={(event) => {
event.preventDefault()
if (!petVisible) {
setPetVisible(true)
}
setPetModelId(model.id)
}}
>
<span className="flex w-4 items-center justify-center">
{selected ? <Check className="size-3.5" aria-hidden /> : null}
</span>
<span className="flex-1 truncate">{model.label}</span>
{/* 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. */}
<button
type="button"
className="ml-2 flex size-5 items-center justify-center rounded text-muted-foreground hover:bg-destructive/15 hover:text-destructive"
aria-label={`Remove ${model.label}`}
onClick={(event) => {
event.stopPropagation()
event.preventDefault()
removeCustomPetModel(model.id)
toast.success(`Removed "${model.label}"`)
}}
>
<Trash2 className="size-3" aria-hidden />
</button>
</DropdownMenuItem>
)
})}
<DropdownMenuSeparator />
<DropdownMenuItem
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 className="size-3.5" aria-hidden />
Upload GLB or image…
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
openSettingsTarget({
pane: 'experimental',
repoId: null,
sectionId: 'experimental-pet'
})
openSettingsPage()
}}
>
Pet settings…
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export const PetStatusSegment = React.memo(PetStatusSegmentInner)
@@ -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<HTMLDivElement>(null)
const [isRefreshing, setIsRefreshing] = useState(false)
@@ -843,6 +849,7 @@ function StatusBarInner(): React.JSX.Element | null {
<div className="flex items-center gap-3">
<UpdateStatusSegment compact={compact} iconOnly={iconOnly} />
{petEnabled && <PetStatusSegment compact={compact} iconOnly={iconOnly} />}
{showMemory && <MemoryStatusSegment compact={compact} iconOnly={iconOnly} />}
{showSessions && <SessionsStatusSegment compact={compact} iconOnly={iconOnly} />}
{showSsh && <SshStatusSegment compact={compact} iconOnly={iconOnly} />}
@@ -201,6 +201,7 @@ function DropdownMenuSubTrigger({
function DropdownMenuSubContent({
className,
style,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
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}
/>
)
+87
View File
@@ -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<AppState, [], [], UISlice> = (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<AppState, [], [], UISlice> = (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,
+5 -1
View File
@@ -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
}
}
+45
View File
@@ -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 <img> 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 = {