feat: add experimental pet overlay (#1331)
* 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> * refactor(pet): replace 3D GLB models with 2D webp images Co-authored-by: Orca <help@stably.ai> * chore(pet): compress pet webp images and remove unused assets Reduce resources/claude.webp, gremlin.webp, opencode.webp sizes; drop the obsolete pet-overlay design doc and compress-pet-glb script now that the GLB pipeline has been replaced by 2D webp images. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
d26cb4b898
commit
d287342f99
|
|
@ -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>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 503 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 921 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 740 KiB |
|
|
@ -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,164 @@
|
|||
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 { CustomPetModel } from '../../shared/types'
|
||||
|
||||
// Why: image-only pet uploads. Static + animated variants render natively via
|
||||
// <img>, so no 3D engine is needed. Main owns the accepted-format table as the
|
||||
// single source of truth for what the renderer will try to display.
|
||||
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): { mimeType: string; ext: string } | null {
|
||||
const ext = extname(src).toLowerCase()
|
||||
const mime = IMAGE_FORMATS[ext]
|
||||
if (!mime) {
|
||||
return null
|
||||
}
|
||||
return { mimeType: mime, ext }
|
||||
}
|
||||
|
||||
// Why: custom user-uploaded images live in a dedicated folder under userData
|
||||
// so they persist across 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_BYTES = 64 * 1024 * 1024 // 64 MB — generous but 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, fileName: string): string | null {
|
||||
if (!isSafeId(id)) {
|
||||
return null
|
||||
}
|
||||
// Why: the renderer hands back the persisted fileName (which includes the
|
||||
// original extension so Blob MIME detection works). We still normalize and
|
||||
// prefix-check against the pets dir to defend against any edge case that
|
||||
// slipped the id regex.
|
||||
const safeName = basename(fileName)
|
||||
if (!safeName.startsWith(`${id}.`)) {
|
||||
return null
|
||||
}
|
||||
const filePath = normalize(join(getPetsDir(), safeName))
|
||||
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 so the dialog opens as
|
||||
// a sheet attached to the main window. Without a parent, on macOS the
|
||||
// dialog can land behind the main window.
|
||||
const senderWindow =
|
||||
BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow()
|
||||
const options: Electron.OpenDialogOptions = {
|
||||
title: 'Pick 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 drop sibling extensions (notably `webp`) from the allowed set.
|
||||
// APNG files carry the `.png` extension and are detected from magic
|
||||
// bytes by the browser.
|
||||
filters: [
|
||||
{
|
||||
name: 'Pet image',
|
||||
extensions: ['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]
|
||||
const classified = classifyFile(src)
|
||||
if (!classified) {
|
||||
throw new Error('Unsupported file. Pick a PNG, APNG, JPG, GIF, WebP, or SVG.')
|
||||
}
|
||||
let srcStat: Awaited<ReturnType<typeof stat>>
|
||||
try {
|
||||
srcStat = await stat(src)
|
||||
} catch {
|
||||
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_BYTES) {
|
||||
throw new Error(
|
||||
`File is too large (${(srcStat.size / (1024 * 1024)).toFixed(1)} MB). Max is ${MAX_BYTES / (1024 * 1024)} MB.`
|
||||
)
|
||||
}
|
||||
|
||||
const dir = getPetsDir()
|
||||
await mkdir(dir, { recursive: true })
|
||||
const id = randomUUID()
|
||||
// Why: preserve original extension in the on-disk name so pet:read can
|
||||
// rebuild the right Blob MIME via resolvePetFile without a separate
|
||||
// lookup. The extension is only ever written by main (never the renderer).
|
||||
const fileName = `${id}${classified.ext}`
|
||||
const dest = join(dir, fileName)
|
||||
try {
|
||||
await copyFile(src, dest)
|
||||
} catch {
|
||||
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,
|
||||
mimeType: classified.mimeType
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'pet:read',
|
||||
async (_event, id: string, fileName: string): Promise<ArrayBuffer | null> => {
|
||||
const filePath = resolvePetFile(id, fileName)
|
||||
if (!filePath) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const buf = await readFile(filePath)
|
||||
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)
|
||||
} catch (error) {
|
||||
console.warn('[pet-overlay] pet:read failed', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('pet:delete', async (_event, id: string, fileName: string): Promise<void> => {
|
||||
const filePath = resolvePetFile(id, fileName)
|
||||
if (!filePath) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await rm(filePath, { force: true })
|
||||
} catch (error) {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
CodexRateLimitAccountsState,
|
||||
CreateWorktreeArgs,
|
||||
CreateWorktreeResult,
|
||||
CustomPetModel,
|
||||
DirEntry,
|
||||
FsChangedPayload,
|
||||
GhosttyImportPreview,
|
||||
|
|
@ -583,6 +584,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, fileName: string) => Promise<ArrayBuffer | null>
|
||||
deleteModel: (id: string, fileName: string) => Promise<void>
|
||||
}
|
||||
browser: BrowserApi
|
||||
hooks: {
|
||||
check: (args: {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type { AgentHookInstallStatus } from '../shared/agent-hook-types'
|
|||
import type {
|
||||
BaseRefDefaultResult,
|
||||
CreateWorktreeArgs,
|
||||
CustomPetModel,
|
||||
FsChangedPayload,
|
||||
GitHubAssignableUser,
|
||||
GitHubCommentResult,
|
||||
|
|
@ -716,6 +717,14 @@ const api = {
|
|||
ipcRenderer.invoke('shell:copyFile', args)
|
||||
},
|
||||
|
||||
pet: {
|
||||
importModel: (): Promise<CustomPetModel | null> => ipcRenderer.invoke('pet:import'),
|
||||
readModel: (id: string, fileName: string): Promise<ArrayBuffer | null> =>
|
||||
ipcRenderer.invoke('pet:read', id, fileName),
|
||||
deleteModel: (id: string, fileName: string): Promise<void> =>
|
||||
ipcRenderer.invoke('pet:delete', id, fileName)
|
||||
},
|
||||
|
||||
browser: {
|
||||
registerGuest: (args: {
|
||||
browserPageId: string
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ 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: lazy-loaded so the WebP asset + overlay module aren't fetched unless
|
||||
// the user opts into the experimental flag.
|
||||
const PetOverlay = lazy(() => import('./components/pet/PetOverlay'))
|
||||
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
|
|
@ -146,6 +149,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)
|
||||
|
|
@ -1105,6 +1110,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,69 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { usePetModelUrl } from './usePetModelUrl'
|
||||
|
||||
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 {
|
||||
const documentVisible = useDocumentVisible()
|
||||
const reducedMotion = usePrefersReducedMotion()
|
||||
const { url } = usePetModelUrl()
|
||||
const animate = documentVisible && !reducedMotion
|
||||
|
||||
return (
|
||||
// Why: pointer-events-none so the app chrome underneath the pet stays
|
||||
// interactive — the pet is purely decorative. z-index sits just under
|
||||
// typical modal layers.
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed bottom-4 right-3 z-40 h-[140px] w-[140px]"
|
||||
>
|
||||
<div
|
||||
className="flex size-full items-center justify-end"
|
||||
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={url} alt="" className="max-h-full max-w-full object-contain" draggable={false} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PetOverlay
|
||||
|
|
@ -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 images we fetch the bytes over IPC and turn
|
||||
// them into a `blob:` URL that an <img> tag can load. A small in-memory cache
|
||||
// means switching back and forth between images in the same session doesn't
|
||||
// re-fetch from main.
|
||||
export const blobUrlCache = new Map<string, string>()
|
||||
|
||||
export async function loadCustomBlobUrl(
|
||||
id: string,
|
||||
fileName: string,
|
||||
mimeType: string
|
||||
): Promise<string | null> {
|
||||
const cached = blobUrlCache.get(id)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const buffer = await window.api.pet.readModel(id, fileName)
|
||||
if (!buffer) {
|
||||
return null
|
||||
}
|
||||
// Why: MIME comes from CustomPetModel.mimeType — required especially for
|
||||
// SVG, which browsers refuse to render from a blob URL with the wrong
|
||||
// Content-Type.
|
||||
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 memory per imported image.
|
||||
export function revokeCustomPetBlobUrl(id: string): void {
|
||||
const url = blobUrlCache.get(id)
|
||||
if (url) {
|
||||
URL.revokeObjectURL(url)
|
||||
blobUrlCache.delete(id)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import theClaudeUrl from '../../../../../resources/claude.webp?url'
|
||||
import theOpencodeUrl from '../../../../../resources/opencode.webp?url'
|
||||
import theGremlinUrl from '../../../../../resources/gremlin.webp?url'
|
||||
|
||||
// Why: bundled defaults so the overlay always has something to render when the
|
||||
// user hasn't uploaded a custom image. Vite's `?url` import hashes each asset
|
||||
// at build time so they participate in the normal caching pipeline.
|
||||
export const DEFAULT_PET_MODEL_ID = 'default'
|
||||
export const OPENCODE_PET_MODEL_ID = 'the-opencode'
|
||||
export const GREMLIN_PET_MODEL_ID = 'the-gremlin'
|
||||
|
||||
export type BundledPetModelId =
|
||||
| typeof DEFAULT_PET_MODEL_ID
|
||||
| typeof OPENCODE_PET_MODEL_ID
|
||||
| typeof GREMLIN_PET_MODEL_ID
|
||||
|
||||
export type BundledPetModel = {
|
||||
id: BundledPetModelId
|
||||
label: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export const BUNDLED_PETS: readonly BundledPetModel[] = [
|
||||
{
|
||||
id: DEFAULT_PET_MODEL_ID,
|
||||
label: 'The Claude',
|
||||
url: theClaudeUrl
|
||||
},
|
||||
{
|
||||
id: OPENCODE_PET_MODEL_ID,
|
||||
label: 'The OpenCode',
|
||||
url: theOpencodeUrl
|
||||
},
|
||||
{
|
||||
id: GREMLIN_PET_MODEL_ID,
|
||||
label: 'The Gremlin',
|
||||
url: theGremlinUrl
|
||||
}
|
||||
] as const
|
||||
|
||||
// Why: keep the single-pet export around so existing call sites that refer to
|
||||
// "the" bundled pet (fallback URL while loading, default selection) continue
|
||||
// to resolve to the original Claude image.
|
||||
export const BUNDLED_PET: BundledPetModel = BUNDLED_PETS[0]
|
||||
|
||||
export function isBundledPetId(id: string | undefined): boolean {
|
||||
return BUNDLED_PETS.some((p) => p.id === id)
|
||||
}
|
||||
|
||||
export function findBundledPet(id: string | undefined): BundledPetModel | undefined {
|
||||
return BUNDLED_PETS.find((p) => p.id === id)
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useAppStore } from '../../store'
|
||||
import { BUNDLED_PET, findBundledPet, isBundledPetId } 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 to a URL the overlay can render.
|
||||
*
|
||||
* For the bundled default 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 bundled default so the overlay is never empty.
|
||||
*/
|
||||
export function usePetModelUrl(): { url: string; ready: boolean } {
|
||||
const petModelId = useAppStore((s) => s.petModelId)
|
||||
const customModels = useAppStore((s) => s.customPetModels)
|
||||
const bundled = isBundledPetId(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
|
||||
const customFileName = customMeta?.fileName ?? null
|
||||
const customMime = customMeta?.mimeType ?? 'image/png'
|
||||
useEffect(() => {
|
||||
if (!customId || !customFileName) {
|
||||
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.
|
||||
setCustomUrl(null)
|
||||
pendingRef.current = customId
|
||||
let cancelled = false
|
||||
void loadCustomBlobUrl(customId, customFileName, customMime).then((url) => {
|
||||
if (cancelled || pendingRef.current !== customId) {
|
||||
return
|
||||
}
|
||||
setCustomUrl(url)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [customId, customFileName, customMime])
|
||||
|
||||
if (bundled) {
|
||||
const pet = findBundledPet(petModelId) ?? BUNDLED_PET
|
||||
return { url: pet.url, ready: true }
|
||||
}
|
||||
if (customMeta && customUrl) {
|
||||
return { url: customUrl, ready: true }
|
||||
}
|
||||
// Fallback: while a custom blob URL is loading (or if the custom model is
|
||||
// missing entirely), render the bundled default so the overlay doesn't
|
||||
// flash empty.
|
||||
return { url: BUNDLED_PET.url, ready: false }
|
||||
}
|
||||
|
|
@ -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="Pet"
|
||||
description="Floating animated 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>Pet</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Shows a small animated pet pinned to the bottom-right corner. Upload your own PNG,
|
||||
APNG, GIF, WebP, JPG, or SVG from the status-bar pet menu. Hide it any time from the
|
||||
same menu without disabling this setting.
|
||||
</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,11 @@ export const EXPERIMENTAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
|||
'sidebar'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Pet',
|
||||
description: 'Floating animated pet in the bottom-right corner.',
|
||||
keywords: ['experimental', 'pet', 'mascot', 'overlay', 'animated', 'corner']
|
||||
},
|
||||
{
|
||||
title: 'Agent Orchestration',
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,201 @@
|
|||
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 { BUNDLED_PET, BUNDLED_PETS, findBundledPet, isBundledPetId } from '../pet/pet-models'
|
||||
|
||||
// Why: cluster pet-related controls (show/hide, image picker, custom 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 = isBundledPetId(petModelId)
|
||||
const activeBundled = bundled ? (findBundledPet(petModelId) ?? BUNDLED_PET) : null
|
||||
const activeCustom = bundled ? null : customPetModels.find((m) => m.id === petModelId)
|
||||
const activeLabel = activeBundled ? activeBundled.label : (activeCustom?.label ?? 'Pet')
|
||||
const label = petVisible ? activeLabel : `${activeLabel} hidden`
|
||||
|
||||
const handleImport = async (): Promise<void> => {
|
||||
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 pl-1 pr-3 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>Customize pet</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. Matches the convention used in
|
||||
BrowserToolbarMenu/BrowserProfileRow. */}
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent className="min-w-[220px]">
|
||||
{BUNDLED_PETS.map((pet) => {
|
||||
const selected = pet.id === petModelId
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={pet.id}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault()
|
||||
if (!petVisible) {
|
||||
setPetVisible(true)
|
||||
}
|
||||
setPetModelId(pet.id)
|
||||
}}
|
||||
>
|
||||
<span className="flex w-4 items-center justify-center">
|
||||
{selected ? <Check className="size-3.5" aria-hidden /> : null}
|
||||
</span>
|
||||
{pet.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>
|
||||
<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.
|
||||
void handleImport()
|
||||
}}
|
||||
>
|
||||
<Upload className="size-3.5" aria-hidden />
|
||||
Pick pet…
|
||||
</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, isBundledPetId } from '../../components/pet/pet-models'
|
||||
import { revokeCustomPetBlobUrl } from '../../components/pet/pet-blob-cache'
|
||||
|
||||
const MIN_SIDEBAR_WIDTH = 220
|
||||
const MAX_LEFT_SIDEBAR_WIDTH = 500
|
||||
|
|
@ -190,6 +193,20 @@ 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 is active. 'default' for the bundled image or a custom model
|
||||
* UUID. Persisted alongside petVisible via the PersistedUIState pipeline. */
|
||||
petModelId: string
|
||||
setPetModelId: (id: string) => void
|
||||
/** User-uploaded pet images. 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
|
||||
|
|
@ -474,6 +491,53 @@ 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) => {
|
||||
const target = s.customPetModels.find((m) => m.id === id)
|
||||
if (!target) {
|
||||
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 pet, fall back
|
||||
// to the bundled default 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 image stays in userData; each import uses a fresh
|
||||
// UUID so the file won't be hit again, and the renderer's metadata
|
||||
// index no longer references it.
|
||||
window.api.pet.deleteModel(id, target.fileName).catch(console.error)
|
||||
return { customPetModels: next, petModelId: fallback }
|
||||
}),
|
||||
|
||||
pendingRevealWorktreeId: null,
|
||||
revealWorktreeInSidebar: (worktreeId) => set({ pendingRevealWorktreeId: worktreeId }),
|
||||
clearPendingRevealWorktreeId: () => set({ pendingRevealWorktreeId: null }),
|
||||
|
|
@ -524,6 +588,28 @@ 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 the bundled default or a
|
||||
// known custom model; otherwise fall back so the overlay never
|
||||
// renders nothing (e.g. custom model was removed by another session).
|
||||
petModelId: ((): string => {
|
||||
const id = ui.petModelId
|
||||
if (typeof id !== 'string') {
|
||||
return DEFAULT_PET_MODEL_ID
|
||||
}
|
||||
if (isBundledPetId(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,10 @@ 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 — opt-in cosmetic joke feature. Leaving the default
|
||||
// false keeps the overlay unmounted for users who never enable it.
|
||||
experimentalPet: false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1075,6 +1075,11 @@ 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 (claude.webp) in the bottom-right
|
||||
* corner. Opt-in because it's a cosmetic joke feature; users who leave it
|
||||
* off never mount the overlay. Toggling takes effect immediately in the
|
||||
* current session (no relaunch) because it is purely renderer-side. */
|
||||
experimentalPet: boolean
|
||||
}
|
||||
|
||||
export type GhosttyImportPreview = {
|
||||
|
|
@ -1192,6 +1197,35 @@ 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
|
||||
/** Active pet id: either 'default' (bundled claude.webp) or a custom
|
||||
* model UUID from customPetModels. Unknown ids fall back to '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 images. Bytes live under userData/pets/custom/; this
|
||||
* field is the metadata index so custom pets ride the existing
|
||||
* PersistedUIState save pipeline. */
|
||||
customPetModels?: CustomPetModel[]
|
||||
}
|
||||
|
||||
/** Metadata for a user-uploaded pet image. `id` is the stable identifier; the
|
||||
* on-disk filename (preserving the original extension) lives in `fileName`.
|
||||
* The renderer never learns the absolute path — it asks main for the bytes
|
||||
* via pet:read using (id, fileName). */
|
||||
export type CustomPetModel = {
|
||||
id: string
|
||||
label: string
|
||||
fileName: string
|
||||
/** MIME type needed so the renderer builds a Blob with the correct
|
||||
* Content-Type — especially image/svg+xml, which browsers won't render
|
||||
* from a misdeclared blob URL. */
|
||||
mimeType: string
|
||||
}
|
||||
|
||||
export type PersistedTrustedOrcaHookEntry = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue