refactor: rename pet → sidekick across codebase (#1334)

Renames the experimental pet overlay to "sidekick" with themed character
names (Claude the Mage, OpenCode the Rogue, Gremlin the Trickster).
Covers IPC channels, preload API, persisted UI state, settings flag,
on-disk userData path, components, and types.

Deletes the standalone pet-overlay design mock.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing 2026-05-01 18:40:52 -07:00 committed by GitHub
parent fe17888104
commit 547448e15c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 438 additions and 830 deletions

View File

@ -1,387 +0,0 @@
<!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>

View File

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

View File

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

View File

@ -13,7 +13,7 @@ const {
registerDeveloperPermissionHandlersMock,
registerSettingsHandlersMock,
registerShellHandlersMock,
registerPetHandlersMock,
registerSidekickHandlersMock,
registerSessionHandlersMock,
registerUIHandlersMock,
registerFilesystemHandlersMock,
@ -44,7 +44,7 @@ const {
registerDeveloperPermissionHandlersMock: vi.fn(),
registerSettingsHandlersMock: vi.fn(),
registerShellHandlersMock: vi.fn(),
registerPetHandlersMock: vi.fn(),
registerSidekickHandlersMock: vi.fn(),
registerSessionHandlersMock: vi.fn(),
registerUIHandlersMock: vi.fn(),
registerFilesystemHandlersMock: vi.fn(),
@ -116,8 +116,8 @@ vi.mock('./shell', () => ({
registerShellHandlers: registerShellHandlersMock
}))
vi.mock('./pet', () => ({
registerPetHandlers: registerPetHandlersMock
vi.mock('./sidekick', () => ({
registerSidekickHandlers: registerSidekickHandlersMock
}))
vi.mock('./session', () => ({
@ -191,7 +191,7 @@ describe('registerCoreHandlers', () => {
registerDeveloperPermissionHandlersMock.mockReset()
registerSettingsHandlersMock.mockReset()
registerShellHandlersMock.mockReset()
registerPetHandlersMock.mockReset()
registerSidekickHandlersMock.mockReset()
registerSessionHandlersMock.mockReset()
registerUIHandlersMock.mockReset()
registerFilesystemHandlersMock.mockReset()
@ -236,7 +236,7 @@ describe('registerCoreHandlers', () => {
expect(registerCodexUsageHandlersMock).toHaveBeenCalledWith(codexUsage)
expect(registerCodexAccountHandlersMock).toHaveBeenCalledWith(codexAccounts)
expect(registerAgentHookHandlersMock).toHaveBeenCalled()
expect(registerPetHandlersMock).toHaveBeenCalled()
expect(registerSidekickHandlersMock).toHaveBeenCalled()
expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts)
expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits)
expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats)

View File

@ -24,7 +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 { registerSidekickHandlers } from './sidekick'
import { registerUIHandlers } from './ui'
import { registerCodexAccountHandlers } from './codex-accounts'
import { registerAgentHookHandlers } from './agent-hooks'
@ -90,7 +90,7 @@ export function registerCoreHandlers(
browserSessionRegistry.applyPendingCookieImport()
browserSessionRegistry.restorePersistedUserAgent()
registerShellHandlers()
registerPetHandlers()
registerSidekickHandlers()
registerSessionHandlers(store)
registerUIHandlers(store)
registerFilesystemHandlers(store)

View File

@ -2,11 +2,11 @@ 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'
import type { CustomSidekick } 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.
// Why: image-only sidekick 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',
@ -29,9 +29,9 @@ function classifyFile(src: string): { mimeType: string; ext: string } | null {
// 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')
// CustomSidekick.id; main resolves it to an absolute path inside this folder.
function getSidekicksDir(): string {
return join(app.getPath('userData'), 'sidekicks', '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.
@ -41,34 +41,34 @@ function isSafeId(id: string): boolean {
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 {
function resolveSidekickFile(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
// prefix-check against the sidekicks 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)) {
const filePath = normalize(join(getSidekicksDir(), safeName))
if (!filePath.startsWith(normalize(getSidekicksDir()) + sep)) {
return null
}
return filePath
}
export function registerPetHandlers(): void {
ipcMain.handle('pet:import', async (event): Promise<CustomPetModel | null> => {
export function registerSidekickHandlers(): void {
ipcMain.handle('sidekick:import', async (event): Promise<CustomSidekick | 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',
title: 'Pick sidekick',
properties: ['openFile'],
// Why: single filter and no `apng` extension. macOS file dialogs map
// filter extensions to UTIs; `apng` has no registered UTI, so including
@ -77,7 +77,7 @@ export function registerPetHandlers(): void {
// bytes by the browser.
filters: [
{
name: 'Pet image',
name: 'Sidekick image',
extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg']
}
]
@ -108,11 +108,11 @@ export function registerPetHandlers(): void {
)
}
const dir = getPetsDir()
const dir = getSidekicksDir()
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
// Why: preserve original extension in the on-disk name so sidekick:read can
// rebuild the right Blob MIME via resolveSidekickFile 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)
@ -120,11 +120,11 @@ export function registerPetHandlers(): void {
await copyFile(src, dest)
} catch {
await rm(dest, { force: true }).catch(() => {})
throw new Error('Could not save the pet.')
throw new Error('Could not save the sidekick.')
}
const rawLabel = basename(src, extname(src)).trim()
const label = rawLabel.length > 0 ? rawLabel.slice(0, 40) : 'Custom pet'
const label = rawLabel.length > 0 ? rawLabel.slice(0, 40) : 'Custom sidekick'
return {
id,
label,
@ -134,9 +134,9 @@ export function registerPetHandlers(): void {
})
ipcMain.handle(
'pet:read',
'sidekick:read',
async (_event, id: string, fileName: string): Promise<ArrayBuffer | null> => {
const filePath = resolvePetFile(id, fileName)
const filePath = resolveSidekickFile(id, fileName)
if (!filePath) {
return null
}
@ -144,21 +144,21 @@ export function registerPetHandlers(): void {
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)
console.warn('[sidekick-overlay] sidekick:read failed', error)
return null
}
}
)
ipcMain.handle('pet:delete', async (_event, id: string, fileName: string): Promise<void> => {
const filePath = resolvePetFile(id, fileName)
ipcMain.handle('sidekick:delete', async (_event, id: string, fileName: string): Promise<void> => {
const filePath = resolveSidekickFile(id, fileName)
if (!filePath) {
return
}
try {
await rm(filePath, { force: true })
} catch (error) {
console.warn('[pet-overlay] pet:delete failed', error)
console.warn('[sidekick-overlay] sidekick:delete failed', error)
}
})
}

View File

@ -10,7 +10,7 @@ import type {
CodexRateLimitAccountsState,
CreateWorktreeArgs,
CreateWorktreeResult,
CustomPetModel,
CustomSidekick,
DirEntry,
FsChangedPayload,
GhosttyImportPreview,
@ -584,10 +584,10 @@ 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>
sidekick: {
import: () => Promise<CustomSidekick | null>
read: (id: string, fileName: string) => Promise<ArrayBuffer | null>
delete: (id: string, fileName: string) => Promise<void>
}
browser: BrowserApi
hooks: {

View File

@ -10,7 +10,7 @@ import type { AgentHookInstallStatus } from '../shared/agent-hook-types'
import type {
BaseRefDefaultResult,
CreateWorktreeArgs,
CustomPetModel,
CustomSidekick,
FsChangedPayload,
GitHubAssignableUser,
GitHubCommentResult,
@ -717,12 +717,12 @@ 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)
sidekick: {
import: (): Promise<CustomSidekick | null> => ipcRenderer.invoke('sidekick:import'),
read: (id: string, fileName: string): Promise<ArrayBuffer | null> =>
ipcRenderer.invoke('sidekick:read', id, fileName),
delete: (id: string, fileName: string): Promise<void> =>
ipcRenderer.invoke('sidekick:delete', id, fileName)
},
browser: {

View File

@ -54,7 +54,7 @@ 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'))
const SidekickOverlay = lazy(() => import('./components/sidekick/SidekickOverlay'))
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
@ -149,8 +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 sidekickEnabled = useAppStore((s) => s.settings?.experimentalSidekick === true)
const sidekickVisible = useAppStore((s) => s.sidekickVisible)
const canGoBackWorktree = useAppStore(canGoBackWorktreeHistory)
const canGoForwardWorktree = useAppStore(canGoForwardWorktreeHistory)
const titlebarLeftControlsRef = useRef<HTMLDivElement | null>(null)
@ -1110,13 +1110,13 @@ 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
{/* Why: mount SidekickOverlay only when the experimental flag is on AND
the user hasn't hit "Hide sidekick" in the status-bar menu. Both
conditions must be true see design doc (sidekick-overlay.md) on why
the two toggles are kept independent. */}
{petEnabled && petVisible ? (
{sidekickEnabled && sidekickVisible ? (
<Suspense fallback={null}>
<PetOverlay />
<SidekickOverlay />
</Suspense>
) : null}
<UpdateCard />

View File

@ -1,52 +0,0 @@
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

@ -91,7 +91,7 @@ export function ExperimentalPane({
const showAgentDashboard = matchesSettingsSearch(searchQuery, [
EXPERIMENTAL_PANE_SEARCH_ENTRIES[0]
])
const showPet = matchesSettingsSearch(searchQuery, [EXPERIMENTAL_PANE_SEARCH_ENTRIES[1]])
const showSidekick = matchesSettingsSearch(searchQuery, [EXPERIMENTAL_PANE_SEARCH_ENTRIES[1]])
const showOrchestration = matchesSettingsSearch(searchQuery, [
EXPERIMENTAL_PANE_SEARCH_ENTRIES[2]
])
@ -229,37 +229,38 @@ export function ExperimentalPane({
</SearchableSetting>
) : null}
{showPet ? (
{showSidekick ? (
<SearchableSetting
title="Pet"
description="Floating animated pet in the bottom-right corner."
title="Sidekick"
description="Floating animated sidekick in the bottom-right corner."
keywords={EXPERIMENTAL_PANE_SEARCH_ENTRIES[1].keywords}
className="space-y-3 px-1 py-2"
id="experimental-pet"
id="experimental-sidekick"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 shrink space-y-1.5">
<Label>Pet</Label>
<Label>Sidekick</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.
Shows a small animated sidekick pinned to the bottom-right corner. Pick a character
(Claudino, OpenCode, Gremlin) or upload your own PNG, APNG, GIF, WebP, JPG, or SVG
from the status-bar sidekick menu. Hide it any time from the same menu without
disabling this setting.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={settings.experimentalPet}
aria-checked={settings.experimentalSidekick}
onClick={() => {
updateSettings({ experimentalPet: !settings.experimentalPet })
updateSettings({ experimentalSidekick: !settings.experimentalSidekick })
}}
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'
settings.experimentalSidekick ? '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'
settings.experimentalSidekick ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>

View File

@ -22,9 +22,18 @@ export const EXPERIMENTAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
]
},
{
title: 'Pet',
description: 'Floating animated pet in the bottom-right corner.',
keywords: ['experimental', 'pet', 'mascot', 'overlay', 'animated', 'corner']
title: 'Sidekick',
description: 'Floating animated sidekick in the bottom-right corner.',
keywords: [
'experimental',
'sidekick',
'pet',
'mascot',
'overlay',
'animated',
'corner',
'character'
]
},
{
title: 'Agent Orchestration',

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { usePetModelUrl } from './usePetModelUrl'
import { useSidekickUrl } from './useSidekickUrl'
function useDocumentVisible(): boolean {
const [visible, setVisible] = useState(() =>
@ -34,30 +34,30 @@ function usePrefersReducedMotion(): boolean {
return reduced
}
export function PetOverlay(): React.JSX.Element {
export function SidekickOverlay(): React.JSX.Element {
const documentVisible = useDocumentVisible()
const reducedMotion = usePrefersReducedMotion()
const { url } = usePetModelUrl()
const { url } = useSidekickUrl()
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
// Why: pointer-events-none so the app chrome underneath the sidekick stays
// interactive — the sidekick 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]"
className="pointer-events-none fixed bottom-4 right-16 z-40 h-[180px] w-[180px]"
>
<div
className="flex size-full items-center justify-end"
style={{
animation: 'pet-bob 1.2s ease-in-out infinite',
animation: 'sidekick-bob 1.2s ease-in-out infinite',
animationPlayState: animate ? 'running' : 'paused'
}}
>
<style>
{
'@keyframes pet-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }'
'@keyframes sidekick-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} />
@ -66,4 +66,4 @@ export function PetOverlay(): React.JSX.Element {
)
}
export default PetOverlay
export default SidekickOverlay

View File

@ -1,12 +1,12 @@
// Why: isolated module so the store slice can call revokeCustomPetBlobUrl
// without importing usePetModelUrl (which itself imports the store). Keeps
// Why: isolated module so the store slice can call revokeCustomSidekickBlobUrl
// without importing useSidekickUrl (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.
// files directly. For custom sidekick 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(
@ -18,11 +18,11 @@ export async function loadCustomBlobUrl(
if (cached) {
return cached
}
const buffer = await window.api.pet.readModel(id, fileName)
const buffer = await window.api.sidekick.read(id, fileName)
if (!buffer) {
return null
}
// Why: MIME comes from CustomPetModel.mimeType — required especially for
// Why: MIME comes from CustomSidekick.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 })
@ -31,10 +31,10 @@ export async function loadCustomBlobUrl(
return url
}
// Why: the store invokes this on removeCustomPetModel so the underlying Blob
// Why: the store invokes this on removeCustomSidekick 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 {
export function revokeCustomSidekickBlobUrl(id: string): void {
const url = blobUrlCache.get(id)
if (url) {
URL.revokeObjectURL(url)

View File

@ -0,0 +1,49 @@
import claudeUrl from '../../../../../resources/claude.webp?url'
import opencodeUrl from '../../../../../resources/opencode.webp?url'
import gremlinUrl 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_SIDEKICK_ID = 'claude-the-mage'
export const OPENCODE_SIDEKICK_ID = 'opencode-the-rogue'
export const GREMLIN_SIDEKICK_ID = 'gremlin-the-trickster'
export type BundledSidekickId =
| typeof DEFAULT_SIDEKICK_ID
| typeof OPENCODE_SIDEKICK_ID
| typeof GREMLIN_SIDEKICK_ID
export type BundledSidekick = {
id: BundledSidekickId
label: string
url: string
}
export const BUNDLED_SIDEKICKS: readonly BundledSidekick[] = [
{
id: DEFAULT_SIDEKICK_ID,
label: 'Claudino',
url: claudeUrl
},
{
id: OPENCODE_SIDEKICK_ID,
label: 'OpenCode',
url: opencodeUrl
},
{
id: GREMLIN_SIDEKICK_ID,
label: 'Gremlin',
url: gremlinUrl
}
] as const
export const BUNDLED_SIDEKICK: BundledSidekick = BUNDLED_SIDEKICKS[0]
export function isBundledSidekickId(id: string | undefined): boolean {
return BUNDLED_SIDEKICKS.some((s) => s.id === id)
}
export function findBundledSidekick(id: string | undefined): BundledSidekick | undefined {
return BUNDLED_SIDEKICKS.find((s) => s.id === id)
}

View File

@ -1,29 +1,29 @@
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'
import { BUNDLED_SIDEKICK, findBundledSidekick, isBundledSidekickId } from './sidekick-models'
import { blobUrlCache, loadCustomBlobUrl } from './sidekick-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'
export { revokeCustomSidekickBlobUrl } from './sidekick-blob-cache'
/** Resolve the active pet to a URL the overlay can render.
/** Resolve the active sidekick to a URL the overlay can render.
*
* For the bundled default this is synchronous. For custom models we issue an
* For bundled sidekicks this is synchronous. For custom ones 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)
export function useSidekickUrl(): { url: string; ready: boolean } {
const sidekickId = useAppStore((s) => s.sidekickId)
const customSidekicks = useAppStore((s) => s.customSidekicks)
const bundled = isBundledSidekickId(sidekickId)
const customMeta = bundled ? null : customSidekicks.find((m) => m.id === sidekickId)
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
// custom sidekicks doesn't let a slower earlier response clobber the newer
// state.
const pendingRef = useRef<string | null>(null)
@ -57,14 +57,14 @@ export function usePetModelUrl(): { url: string; ready: boolean } {
}, [customId, customFileName, customMime])
if (bundled) {
const pet = findBundledPet(petModelId) ?? BUNDLED_PET
return { url: pet.url, ready: true }
const sidekick = findBundledSidekick(sidekickId) ?? BUNDLED_SIDEKICK
return { url: sidekick.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
// Fallback: while a custom blob URL is loading (or if the custom sidekick is
// missing entirely), render the bundled default so the overlay doesn't
// flash empty.
return { url: BUNDLED_PET.url, ready: false }
return { url: BUNDLED_SIDEKICK.url, ready: false }
}

View File

@ -1,201 +0,0 @@
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

@ -0,0 +1,189 @@
import React from 'react'
import { 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 { useAppStore } from '../../store'
import {
BUNDLED_SIDEKICK,
BUNDLED_SIDEKICKS,
findBundledSidekick,
isBundledSidekickId
} from '../sidekick/sidekick-models'
// Why: cluster sidekick-related controls (show/hide, character picker, custom
// upload + removal, jump-to-settings) behind a single status-bar segment. Only
// rendered when experimentalSidekick is on (gated by the caller). Sidekick
// visibility is independently tracked so users can dismiss without having to
// find the experimental flag again.
function SidekickStatusSegmentInner(): React.JSX.Element {
const sidekickVisible = useAppStore((s) => s.sidekickVisible)
const setSidekickVisible = useAppStore((s) => s.setSidekickVisible)
const sidekickId = useAppStore((s) => s.sidekickId)
const setSidekickId = useAppStore((s) => s.setSidekickId)
const customSidekicks = useAppStore((s) => s.customSidekicks)
const addCustomSidekick = useAppStore((s) => s.addCustomSidekick)
const removeCustomSidekick = useAppStore((s) => s.removeCustomSidekick)
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const bundled = isBundledSidekickId(sidekickId)
const activeBundled = bundled ? (findBundledSidekick(sidekickId) ?? BUNDLED_SIDEKICK) : null
const activeCustom = bundled ? null : customSidekicks.find((m) => m.id === sidekickId)
const activeLabel = activeBundled ? activeBundled.label : (activeCustom?.label ?? 'Sidekick')
const label = sidekickVisible ? activeLabel : `${activeLabel} hidden`
const handleImport = async (): Promise<void> => {
console.log('[sidekick-overlay] upload: click')
if (!window.api?.sidekick?.import) {
console.warn('[sidekick-overlay] upload: window.api.sidekick.import missing — restart Orca')
toast.error('Custom sidekick upload needs a full app restart (not just reload).')
return
}
try {
const model = await window.api.sidekick.import()
console.log('[sidekick-overlay] upload: result', model)
if (!model) {
return
}
addCustomSidekick(model)
if (!sidekickVisible) {
setSidekickVisible(true)
}
setSidekickId(model.id)
} catch (error) {
console.error('[sidekick-overlay] upload: error', error)
toast.error(error instanceof Error ? error.message : 'Failed to import file')
}
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="group inline-flex items-center cursor-pointer pl-1 pr-[6.5rem] py-0.5"
aria-label="Sidekick menu"
>
<span
className={`rounded px-1 py-0.5 text-[11px] font-medium text-muted-foreground group-hover:bg-accent/70 group-hover:text-foreground ${sidekickVisible ? '' : 'opacity-50'}`}
>
{label}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="end" sideOffset={8} className="min-w-[220px]">
<DropdownMenuLabel>Sidekick</DropdownMenuLabel>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault()
setSidekickVisible(!sidekickVisible)
}}
>
{sidekickVisible ? 'Hide sidekick' : 'Show sidekick'}
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Choose sidekick</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_SIDEKICKS.map((sidekick) => {
const selected = sidekick.id === sidekickId
return (
<DropdownMenuItem
key={sidekick.id}
onSelect={() => {
if (!sidekickVisible) {
setSidekickVisible(true)
}
setSidekickId(sidekick.id)
}}
>
<span className="flex w-4 items-center justify-center">
{selected ? <Check className="size-3.5" aria-hidden /> : null}
</span>
{sidekick.label}
</DropdownMenuItem>
)
})}
{customSidekicks.length > 0 ? <DropdownMenuSeparator /> : null}
{customSidekicks.map((model) => {
const selected = model.id === sidekickId
return (
<DropdownMenuItem
key={model.id}
className="group"
onSelect={() => {
if (!sidekickVisible) {
setSidekickVisible(true)
}
setSidekickId(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()
removeCustomSidekick(model.id)
}}
>
<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 />
Upload your own
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
openSettingsTarget({
pane: 'experimental',
repoId: null,
sectionId: 'experimental-sidekick'
})
openSettingsPage()
}}
>
Sidekick settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export const SidekickStatusSegment = React.memo(SidekickStatusSegmentInner)

View File

@ -35,7 +35,7 @@ import { SshStatusSegment } from './SshStatusSegment'
import { SessionsStatusSegment } from './SessionsStatusSegment'
import { UpdateStatusSegment } from './UpdateStatusSegment'
import { MemoryStatusSegment } from './MemoryStatusSegment'
import { PetStatusSegment } from './PetStatusSegment'
import { SidekickStatusSegment } from './SidekickStatusSegment'
function getCodexAccountLabel(
state: CodexRateLimitAccountsState,
@ -706,11 +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
// Why: sidekick 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)
// purely by the experimentalSidekick settings flag.
const sidekickEnabled = useAppStore((s) => s.settings?.experimentalSidekick === true)
const toggleStatusBarItem = useAppStore((s) => s.toggleStatusBarItem)
const containerRef = useRef<HTMLDivElement>(null)
const [isRefreshing, setIsRefreshing] = useState(false)
@ -849,7 +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} />}
{sidekickEnabled && <SidekickStatusSegment />}
{showMemory && <MemoryStatusSegment compact={compact} iconOnly={iconOnly} />}
{showSessions && <SessionsStatusSegment compact={compact} iconOnly={iconOnly} />}
{showSsh && <SshStatusSegment compact={compact} iconOnly={iconOnly} />}

View File

@ -4,7 +4,7 @@ import type { AppState } from '../types'
import { findPrevLiveWorktreeHistoryIndex } from './worktree-nav-history'
import type {
ChangelogData,
CustomPetModel,
CustomSidekick,
PersistedTrustedOrcaHooks,
PersistedUIState,
StatusBarItem,
@ -39,8 +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'
import { DEFAULT_SIDEKICK_ID, isBundledSidekickId } from '../../components/sidekick/sidekick-models'
import { revokeCustomSidekickBlobUrl } from '../../components/sidekick/sidekick-blob-cache'
const MIN_SIDEBAR_WIDTH = 220
const MAX_LEFT_SIDEBAR_WIDTH = 500
@ -193,20 +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
/** Whether the experimental sidekick overlay is currently visible. Persisted
* so "Hide sidekick" from the status-bar menu survives reload. Independent
* of the experimentalSidekick settings flag the feature flag gates
* whether the overlay can ever render; this controls whether it does now. */
sidekickVisible: boolean
setSidekickVisible: (v: boolean) => void
/** Which sidekick is active either a bundled id or a custom UUID.
* Persisted alongside sidekickVisible via the PersistedUIState pipeline. */
sidekickId: string
setSidekickId: (id: string) => void
/** User-uploaded sidekick images. Metadata only — bytes live in main's userData. */
customSidekicks: CustomSidekick[]
addCustomSidekick: (model: CustomSidekick) => void
removeCustomSidekick: (id: string) => void
pendingRevealWorktreeId: string | null
revealWorktreeInSidebar: (worktreeId: string) => void
clearPendingRevealWorktreeId: () => void
@ -491,51 +491,51 @@ 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 })
// Why: default true so a user who enables experimentalSidekick sees the
// sidekick immediately. Hide sidekick from the status-bar menu flips this
// to false; the value is persisted via the standard PersistedUIState pipeline.
sidekickVisible: true,
setSidekickVisible: (v) => {
window.api.ui.set({ sidekickVisible: v }).catch(console.error)
set({ sidekickVisible: v })
},
petModelId: DEFAULT_PET_MODEL_ID,
setPetModelId: (id) => {
window.api.ui.set({ petModelId: id }).catch(console.error)
set({ petModelId: id })
sidekickId: DEFAULT_SIDEKICK_ID,
setSidekickId: (id) => {
window.api.ui.set({ sidekickId: id }).catch(console.error)
set({ sidekickId: id })
},
customPetModels: [],
addCustomPetModel: (model) =>
customSidekicks: [],
addCustomSidekick: (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 }
const next = [...s.customSidekicks.filter((m) => m.id !== model.id), model]
window.api.ui.set({ customSidekicks: next }).catch(console.error)
return { customSidekicks: next }
}),
removeCustomPetModel: (id) =>
removeCustomSidekick: (id) =>
set((s) => {
const target = s.customPetModels.find((m) => m.id === id)
const target = s.customSidekicks.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)
const next = s.customSidekicks.filter((m) => m.id !== id)
window.api.ui.set({ customSidekicks: next }).catch(console.error)
// Why: if the user removes the currently-active custom sidekick, fall
// back to the bundled default so the overlay doesn't render nothing.
const fallback = s.sidekickId === id ? DEFAULT_SIDEKICK_ID : s.sidekickId
if (fallback !== s.sidekickId) {
window.api.ui.set({ sidekickId: 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)
revokeCustomSidekickBlobUrl(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 }
window.api.sidekick.delete(id, target.fileName).catch(console.error)
return { customSidekicks: next, sidekickId: fallback }
}),
pendingRevealWorktreeId: null,
@ -588,27 +588,27 @@ 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
// Why: absent → true so existing users see the sidekick the first time
// they enable the experimental flag. Only an explicit Hide sidekick
// 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
sidekickVisible: ui.sidekickVisible ?? true,
customSidekicks: Array.isArray(ui.customSidekicks) ? ui.customSidekicks : [],
// Why: accept the persisted id if it matches a bundled sidekick or a
// known custom one; otherwise fall back so the overlay never renders
// nothing (e.g. custom sidekick was removed by another session).
sidekickId: ((): string => {
const id = ui.sidekickId
if (typeof id !== 'string') {
return DEFAULT_PET_MODEL_ID
return DEFAULT_SIDEKICK_ID
}
if (isBundledPetId(id)) {
if (isBundledSidekickId(id)) {
return id
}
const custom = Array.isArray(ui.customPetModels) ? ui.customPetModels : []
const custom = Array.isArray(ui.customSidekicks) ? ui.customSidekicks : []
if (custom.some((m) => m.id === id)) {
return id
}
return DEFAULT_PET_MODEL_ID
return DEFAULT_SIDEKICK_ID
})(),
dismissedUpdateVersion: ui.dismissedUpdateVersion ?? null,
updateReassuranceSeen: ui.updateReassuranceSeen ?? false,

View File

@ -194,7 +194,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
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
experimentalSidekick: false
}
}

View File

@ -1075,11 +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
/** Experimental: floating animated sidekick (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
experimentalSidekick: boolean
}
export type GhosttyImportPreview = {
@ -1197,28 +1197,28 @@ 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
/** Whether the experimental sidekick overlay is currently visible. Separate
* from the experimentalSidekick settings flag so "Hide sidekick" 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 sidekick
* the first time they enable the experimental flag. */
sidekickVisible?: boolean
/** Active sidekick id: one of the bundled ids or a custom UUID from
* customSidekicks. Unknown ids fall back to the default at read time so
* removing a custom sidekick the user had selected doesn't leave the
* overlay rendering nothing. */
sidekickId?: string
/** User-uploaded sidekick images. Bytes live under userData/sidekicks/custom/;
* this field is the metadata index so custom sidekicks ride the existing
* PersistedUIState save pipeline. */
customPetModels?: CustomPetModel[]
customSidekicks?: CustomSidekick[]
}
/** Metadata for a user-uploaded pet image. `id` is the stable identifier; the
* on-disk filename (preserving the original extension) lives in `fileName`.
/** Metadata for a user-uploaded sidekick 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 = {
* via sidekick:read using (id, fileName). */
export type CustomSidekick = {
id: string
label: string
fileName: string