mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 08:07:18 +00:00
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:
@@ -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.
|
||||
@@ -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> </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> docs/design/pet-overlay.md</div>
|
||||
<div> docs/design/pet-overlay.mock.html</div>
|
||||
<div> </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>
|
||||
@@ -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",
|
||||
|
||||
Generated
+457
@@ -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.
Executable
+84
@@ -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
|
||||
|
||||
@@ -86,6 +86,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
terminalMacOptionAsAlt: 'false',
|
||||
terminalMacOptionAsAltMigrated: true,
|
||||
experimentalAgentDashboard: false,
|
||||
experimentalPet: false,
|
||||
terminalWindowsShell: 'powershell.exe',
|
||||
enableGitHubAttribution: true,
|
||||
...overrides
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user