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:
Jinjing 2026-05-01 16:18:21 -07:00 committed by GitHub
parent d26cb4b898
commit d287342f99
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1208 additions and 3 deletions

View File

@ -0,0 +1,387 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Pet Overlay — UI mock</title>
<style>
:root {
--bg: #0d1117;
--bg-2: #11161d;
--bg-3: #161c24;
--border: #222a34;
--fg: #c9d1d9;
--fg-dim: #8b949e;
--accent: #4c8bf5;
--green: #3fb950;
--amber: #d29922;
--modal-z: 40;
--pet-z: 30;
}
* { box-sizing: border-box; }
html, body {
margin: 0; height: 100%;
background: var(--bg);
color: var(--fg);
font: 13px/1.45 -apple-system, "SF Pro Text", Inter, system-ui, sans-serif;
overflow: hidden;
}
/* ---------- App chrome ---------- */
.app {
display: grid;
grid-template-rows: 36px 1fr 24px;
grid-template-columns: 240px 1fr;
height: 100%;
}
.tabbar {
grid-column: 1 / -1;
display: flex; align-items: center; gap: 2px;
padding: 0 10px;
background: var(--bg-2);
border-bottom: 1px solid var(--border);
-webkit-app-region: drag;
}
.traffic { display: flex; gap: 6px; margin-right: 10px; }
.traffic i { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
.traffic i:nth-child(1) { background: #ff5f57; }
.traffic i:nth-child(2) { background: #febc2e; }
.traffic i:nth-child(3) { background: #28c840; }
.tab {
padding: 6px 12px;
font-size: 12px; color: var(--fg-dim);
border-radius: 6px 6px 0 0;
}
.tab.active { background: var(--bg); color: var(--fg); }
.sidebar {
background: var(--bg-2);
border-right: 1px solid var(--border);
padding: 10px 8px;
overflow-y: auto;
}
.sidebar h4 {
margin: 10px 6px 4px;
font-size: 10px; font-weight: 600;
letter-spacing: 0.08em; text-transform: uppercase;
color: var(--fg-dim);
}
.wt {
display: flex; align-items: center; gap: 8px;
padding: 6px 8px; border-radius: 6px;
color: var(--fg-dim); font-size: 12px;
}
.wt.active { background: #1b2430; color: var(--fg); }
.wt .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--green); }
.main {
position: relative;
background: var(--bg);
display: grid;
grid-template-rows: 1fr 1fr;
gap: 1px;
background-color: var(--border);
}
.pane {
background: var(--bg);
padding: 10px 14px;
font-family: "SF Mono", Menlo, monospace;
font-size: 12px; color: #b6c2cf;
overflow: hidden;
}
.pane .prompt { color: var(--green); }
.pane .path { color: var(--accent); }
.pane .muted { color: var(--fg-dim); }
/* ---------- Status bar ---------- */
.statusbar {
grid-column: 1 / -1;
display: flex; align-items: center;
padding: 0 8px; gap: 2px;
background: var(--bg-2);
border-top: 1px solid var(--border);
font-size: 11px; color: var(--fg-dim);
position: relative;
}
.statusbar .spacer { flex: 1; }
.seg {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 8px; border-radius: 4px;
cursor: default;
}
.seg:hover { background: #1b2430; color: var(--fg); }
.seg .chip {
display: inline-block; width: 6px; height: 6px; border-radius: 50%;
background: var(--green);
}
.seg.pet {
position: relative;
color: var(--fg);
}
.seg.pet .pet-ico {
width: 14px; height: 14px;
background: radial-gradient(circle at 35% 30%, #6dd3a8 0%, #2f8f6a 70%, #1a5a44 100%);
border-radius: 50%;
box-shadow: inset -1px -2px 0 rgba(0,0,0,.35), 0 0 0 1px #1a5a44;
}
/* ---------- Pet menu (open state) ---------- */
.menu {
position: absolute;
bottom: 28px;
min-width: 180px;
background: #1b2430;
border: 1px solid #2b3643;
border-radius: 8px;
padding: 4px;
box-shadow: 0 8px 24px rgba(0,0,0,0.45);
color: var(--fg);
font-size: 12px;
z-index: 50;
}
.menu .row {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px; border-radius: 5px; cursor: default;
}
.menu .row:hover { background: #27354a; }
.menu .row .kbd {
margin-left: auto; color: var(--fg-dim); font-size: 10px;
}
.menu .sep {
height: 1px; background: #2b3643; margin: 4px 2px;
}
.menu .label {
padding: 6px 10px 2px; color: var(--fg-dim); font-size: 10px;
letter-spacing: 0.06em; text-transform: uppercase;
}
/* ---------- Pet overlay (bottom-right, pointer-events: none) ---------- */
.pet-overlay {
position: absolute;
right: 18px; bottom: 34px; /* sits above status bar */
width: 120px; height: 120px;
pointer-events: none;
z-index: var(--pet-z);
/* transparent "canvas" — the GLB would render here */
filter: drop-shadow(0 8px 12px rgba(0,0,0,0.5));
}
.pet {
position: absolute; inset: 10px;
animation: bob 3.2s ease-in-out infinite;
}
@keyframes bob {
0%,100% { transform: translateY(0) rotate(-2deg); }
50% { transform: translateY(-6px) rotate(2deg); }
}
@media (prefers-reduced-motion: reduce) {
.pet { animation: none; }
}
/* a stand-in "gremlin": stacked gradient blobs to fake 3D */
.pet .body {
position: absolute; inset: 10% 15% 5% 15%;
border-radius: 50% 50% 45% 45% / 55% 55% 45% 45%;
background:
radial-gradient(ellipse at 35% 25%, #a4e8c7 0%, #4fbf8d 45%, #1f6a4b 100%);
box-shadow:
inset -6px -10px 12px rgba(0,0,0,0.35),
inset 4px 6px 10px rgba(255,255,255,0.15);
}
.pet .eye {
position: absolute; width: 18px; height: 22px;
background: #fff; border-radius: 50%;
top: 32%;
box-shadow: inset 0 -2px 3px rgba(0,0,0,0.1);
}
.pet .eye.l { left: 30%; }
.pet .eye.r { right: 30%; }
.pet .eye::after {
content: ""; position: absolute;
width: 8px; height: 10px; border-radius: 50%;
background: #1a1a1a;
top: 40%; left: 50%;
transform: translate(-50%, -20%);
animation: look 4s ease-in-out infinite;
}
@keyframes look {
0%, 40% { transform: translate(-50%, -20%); }
50%, 70% { transform: translate(-110%, -20%); }
80%, 100% { transform: translate(-50%, -20%); }
}
.pet .mouth {
position: absolute;
left: 50%; top: 62%;
width: 22px; height: 10px;
transform: translateX(-50%);
border-bottom: 2px solid #0f3c2a;
border-radius: 0 0 20px 20px;
}
.pet .ear {
position: absolute; width: 22px; height: 30px;
background: linear-gradient(160deg, #4fbf8d, #1f6a4b);
border-radius: 60% 60% 40% 40% / 70% 70% 30% 30%;
top: 2%;
}
.pet .ear.l { left: 14%; transform: rotate(-20deg); }
.pet .ear.r { right: 14%; transform: rotate(20deg); }
/* ---------- Annotations ---------- */
.note {
position: absolute;
background: rgba(76, 139, 245, 0.12);
border: 1px dashed var(--accent);
color: #cfe0ff;
padding: 4px 8px;
font-size: 10px;
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
}
.note::before {
content: ""; position: absolute;
width: 30px; height: 1px;
background: var(--accent);
}
.note.a {
right: 150px; bottom: 90px;
}
.note.a::before { right: -30px; top: 50%; }
.note.b {
right: 200px; bottom: 40px;
}
.note.b::before { right: -40px; top: 50%; width: 40px; }
/* disabled-state comparison — small inset preview */
.compare {
position: absolute;
right: 18px; top: 50px;
width: 240px;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
font-size: 11px;
color: var(--fg-dim);
z-index: 60;
}
.compare h5 { margin: 0 0 6px; color: var(--fg); font-size: 11px; }
.compare .row2 {
display: flex; gap: 8px; margin-top: 6px;
}
.compare .mini {
flex: 1;
height: 54px;
border-radius: 5px;
background: var(--bg);
position: relative;
overflow: hidden;
border: 1px solid var(--border);
}
.compare .mini.off::after {
content: "off — 0 bytes"; position: absolute;
right: 6px; bottom: 4px; font-size: 9px; color: var(--fg-dim);
}
.compare .mini.on::after {
content: "on"; position: absolute;
right: 6px; bottom: 4px; font-size: 9px; color: var(--green);
}
.compare .mini.on::before {
content: ""; position: absolute;
right: 6px; bottom: 14px; width: 22px; height: 22px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #a4e8c7 0%, #4fbf8d 50%, #1f6a4b 100%);
box-shadow: 0 2px 3px rgba(0,0,0,0.5);
}
</style>
</head>
<body>
<div class="app">
<!-- tab bar -->
<div class="tabbar">
<div class="traffic"><i></i><i></i><i></i></div>
<div class="tab active">orca</div>
<div class="tab">add-a-pet-for-orca</div>
<div class="tab">share-text-search</div>
</div>
<!-- sidebar -->
<aside class="sidebar">
<h4>Repos</h4>
<div class="wt active"><span class="dot"></span>orca</div>
<div class="wt"><span class="dot" style="background:#d29922"></span>internal-tools</div>
<h4>Worktrees</h4>
<div class="wt active"><span class="dot"></span>add-a-pet-for-orca</div>
<div class="wt"><span class="dot"></span>main</div>
<div class="wt"><span class="dot" style="background:#8b949e"></span>persist-ssh-sessions</div>
<div class="wt"><span class="dot" style="background:#8b949e"></span>share-text-search</div>
</aside>
<!-- main content: fake terminals -->
<section class="main">
<div class="pane">
<div><span class="prompt">~/orca/add-a-pet-for-orca</span> <span class="muted">$</span> pnpm dev</div>
<div class="muted"> Local: http://localhost:5173</div>
<div class="muted"> ready in 412ms</div>
<div>&nbsp;</div>
<div><span class="prompt">$</span> <span class="muted">_</span></div>
</div>
<div class="pane">
<div><span class="prompt">~/orca</span> <span class="muted">$</span> git status</div>
<div class="muted">On branch add-a-pet-for-orca</div>
<div class="muted">Untracked files:</div>
<div>&nbsp;&nbsp;docs/design/pet-overlay.md</div>
<div>&nbsp;&nbsp;docs/design/pet-overlay.mock.html</div>
<div>&nbsp;</div>
<div><span class="prompt">$</span> <span class="muted">_</span></div>
</div>
<!-- Pet overlay (bottom-right of main pane area) -->
<div class="pet-overlay" aria-hidden="true">
<div class="pet">
<div class="ear l"></div>
<div class="ear r"></div>
<div class="body"></div>
<div class="eye l"></div>
<div class="eye r"></div>
<div class="mouth"></div>
</div>
</div>
<div class="note a">GLB canvas — pointer-events: none, z-index: var(--pet-z)</div>
<div class="note b">PetStatusSegment ↓ (only when experimentalPet=true)</div>
<!-- comparison card: enabled vs disabled -->
<div class="compare">
<h5>Zero-cost-when-disabled</h5>
<div>three.js never imported when <code>experimentalPet=false</code>. Component unmounts = no listeners, no RAF loop.</div>
<div class="row2">
<div class="mini off"></div>
<div class="mini on"></div>
</div>
</div>
</section>
<!-- status bar -->
<footer class="statusbar">
<span class="seg"><span class="chip"></span>claude · 78%</span>
<span class="seg"><span class="chip" style="background:var(--amber)"></span>codex · 42%</span>
<span class="seg">● 3 sessions</span>
<span class="seg">ssh</span>
<span class="seg">mem 412MB</span>
<span class="spacer"></span>
<span class="seg pet" id="pet-seg">
<span class="pet-ico"></span>
Pet
<!-- Menu rendered open for the mock -->
<div class="menu" role="menu">
<div class="label">Gremlin</div>
<div class="row">Hide pet <span class="kbd">⌘⇧P</span></div>
<div class="sep"></div>
<div class="row">Pet settings…</div>
</div>
</span>
<span class="seg">v1.3.26-rc.0</span>
</footer>
</div>
</body>
</html>

BIN
resources/claude.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

BIN
resources/gremlin.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 921 KiB

BIN
resources/opencode.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 740 KiB

View File

@ -92,6 +92,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalMacOptionAsAlt: 'false',
terminalMacOptionAsAltMigrated: true,
experimentalAgentDashboard: false,
experimentalPet: false,
terminalWindowsShell: 'powershell.exe',
enableGitHubAttribution: true,
...overrides

View File

@ -86,6 +86,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
terminalMacOptionAsAlt: 'false',
terminalMacOptionAsAltMigrated: true,
experimentalAgentDashboard: false,
experimentalPet: false,
terminalWindowsShell: 'powershell.exe',
enableGitHubAttribution: true,
...overrides

164
src/main/ipc/pet.ts Normal file
View File

@ -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)
}
})
}

View File

@ -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)

View File

@ -24,6 +24,7 @@ import { registerSettingsHandlers } from './settings'
import { registerBrowserHandlers } from './browser'
import { browserSessionRegistry } from '../browser/browser-session-registry'
import { registerShellHandlers } from './shell'
import { registerPetHandlers } from './pet'
import { registerUIHandlers } from './ui'
import { registerCodexAccountHandlers } from './codex-accounts'
import { registerAgentHookHandlers } from './agent-hooks'
@ -89,6 +90,7 @@ export function registerCoreHandlers(
browserSessionRegistry.applyPendingCookieImport()
browserSessionRegistry.restorePersistedUserAgent()
registerShellHandlers()
registerPetHandlers()
registerSessionHandlers(store)
registerUIHandlers(store)
registerFilesystemHandlers(store)

View File

@ -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: {

View File

@ -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

View File

@ -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 />

View File

@ -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

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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 }
}

View File

@ -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">

View File

@ -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:

View File

@ -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)

View File

@ -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} />}

View File

@ -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}
/>
)

View File

@ -4,6 +4,7 @@ import type { AppState } from '../types'
import { findPrevLiveWorktreeHistoryIndex } from './worktree-nav-history'
import type {
ChangelogData,
CustomPetModel,
PersistedTrustedOrcaHooks,
PersistedUIState,
StatusBarItem,
@ -38,6 +39,8 @@ import {
DEFAULT_WORKTREE_CARD_PROPERTIES
} from '../../../../shared/constants'
import type { OrcaHookScriptKind } from '../../lib/orca-hook-trust'
import { DEFAULT_PET_MODEL_ID, 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,

View File

@ -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
}
}

View File

@ -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 = {