PetStatusSegment ↓ (only when experimentalPet=true)
+
+
+
+
Zero-cost-when-disabled
+
three.js never imported when experimentalPet=false. Component unmounts = no listeners, no RAF loop.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/claude.webp b/resources/claude.webp
new file mode 100644
index 000000000..328fc8547
Binary files /dev/null and b/resources/claude.webp differ
diff --git a/resources/gremlin.webp b/resources/gremlin.webp
new file mode 100644
index 000000000..9e831557a
Binary files /dev/null and b/resources/gremlin.webp differ
diff --git a/resources/opencode.webp b/resources/opencode.webp
new file mode 100644
index 000000000..fa2ac4244
Binary files /dev/null and b/resources/opencode.webp differ
diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts
index 2dfb2b32e..aaaccfc99 100644
--- a/src/main/codex-accounts/runtime-home-service.test.ts
+++ b/src/main/codex-accounts/runtime-home-service.test.ts
@@ -92,6 +92,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings
terminalMacOptionAsAlt: 'false',
terminalMacOptionAsAltMigrated: true,
experimentalAgentDashboard: false,
+ experimentalPet: false,
terminalWindowsShell: 'powershell.exe',
enableGitHubAttribution: true,
...overrides
diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts
index 492bb0007..4033c8a95 100644
--- a/src/main/codex-accounts/service.test.ts
+++ b/src/main/codex-accounts/service.test.ts
@@ -86,6 +86,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings
terminalMacOptionAsAlt: 'false',
terminalMacOptionAsAltMigrated: true,
experimentalAgentDashboard: false,
+ experimentalPet: false,
terminalWindowsShell: 'powershell.exe',
enableGitHubAttribution: true,
...overrides
diff --git a/src/main/ipc/pet.ts b/src/main/ipc/pet.ts
new file mode 100644
index 000000000..a3b03cde9
--- /dev/null
+++ b/src/main/ipc/pet.ts
@@ -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
+// , 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 = {
+ '.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 => {
+ // 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>
+ 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 => {
+ 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 => {
+ const filePath = resolvePetFile(id, fileName)
+ if (!filePath) {
+ return
+ }
+ try {
+ await rm(filePath, { force: true })
+ } catch (error) {
+ console.warn('[pet-overlay] pet:delete failed', error)
+ }
+ })
+}
diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts
index e268255f3..9c1fa03dd 100644
--- a/src/main/ipc/register-core-handlers.test.ts
+++ b/src/main/ipc/register-core-handlers.test.ts
@@ -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)
diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts
index 7df662b70..55f46546b 100644
--- a/src/main/ipc/register-core-handlers.ts
+++ b/src/main/ipc/register-core-handlers.ts
@@ -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)
diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts
index d3cb04832..f7ff8f796 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -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
copyFile: (args: { srcPath: string; destPath: string }) => Promise
}
+ pet: {
+ importModel: () => Promise
+ readModel: (id: string, fileName: string) => Promise
+ deleteModel: (id: string, fileName: string) => Promise
+ }
browser: BrowserApi
hooks: {
check: (args: {
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 9225b9fa5..8cf32475b 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -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 => ipcRenderer.invoke('pet:import'),
+ readModel: (id: string, fileName: string): Promise =>
+ ipcRenderer.invoke('pet:read', id, fileName),
+ deleteModel: (id: string, fileName: string): Promise =>
+ ipcRenderer.invoke('pet:delete', id, fileName)
+ },
+
browser: {
registerGuest: (args: {
browserPageId: string
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index c352e8067..13b93a706 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -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(null)
@@ -1105,6 +1110,15 @@ function App(): React.JSX.Element {
{mountedLazyModalIds.has('quick-open') ? : null}
{mountedLazyModalIds.has('worktree-palette') ? : null}
+ {/* 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 ? (
+
+
+
+ ) : null}
diff --git a/src/renderer/src/components/pet/PetOverlay.tsx b/src/renderer/src/components/pet/PetOverlay.tsx
new file mode 100644
index 000000000..c3fa6e31a
--- /dev/null
+++ b/src/renderer/src/components/pet/PetOverlay.tsx
@@ -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.
+
+
+
+
+
+
+ )
+}
+
+export default PetOverlay
diff --git a/src/renderer/src/components/pet/pet-blob-cache.ts b/src/renderer/src/components/pet/pet-blob-cache.ts
new file mode 100644
index 000000000..983c2b6b5
--- /dev/null
+++ b/src/renderer/src/components/pet/pet-blob-cache.ts
@@ -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 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()
+
+export async function loadCustomBlobUrl(
+ id: string,
+ fileName: string,
+ mimeType: string
+): Promise {
+ 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)
+ }
+}
diff --git a/src/renderer/src/components/pet/pet-models.ts b/src/renderer/src/components/pet/pet-models.ts
new file mode 100644
index 000000000..2ebbe96fd
--- /dev/null
+++ b/src/renderer/src/components/pet/pet-models.ts
@@ -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)
+}
diff --git a/src/renderer/src/components/pet/usePetModelUrl.ts b/src/renderer/src/components/pet/usePetModelUrl.ts
new file mode 100644
index 000000000..2ee708cf2
--- /dev/null
+++ b/src/renderer/src/components/pet/usePetModelUrl.ts
@@ -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(() =>
+ 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(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 }
+}
diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx
index 8df3cc775..f7e579ba8 100644
--- a/src/renderer/src/components/settings/ExperimentalPane.tsx
+++ b/src/renderer/src/components/settings/ExperimentalPane.tsx
@@ -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(() => {
@@ -228,11 +229,49 @@ export function ExperimentalPane({
) : null}
+ {showPet ? (
+
+
+
+
+
+ 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.
+
+
+
+
+
+ ) : null}
+
{showOrchestration ? (
diff --git a/src/renderer/src/components/settings/experimental-search.ts b/src/renderer/src/components/settings/experimental-search.ts
index 86ba469ff..9515b74c7 100644
--- a/src/renderer/src/components/settings/experimental-search.ts
+++ b/src/renderer/src/components/settings/experimental-search.ts
@@ -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:
diff --git a/src/renderer/src/components/status-bar/PetStatusSegment.tsx b/src/renderer/src/components/status-bar/PetStatusSegment.tsx
new file mode 100644
index 000000000..8b9c6683f
--- /dev/null
+++ b/src/renderer/src/components/status-bar/PetStatusSegment.tsx
@@ -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 => {
+ 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 (
+
+
+
+
+
+
+
+
+ {petVisible ? `${activeLabel} (pet)` : `${activeLabel} hidden — click to restore`}
+
+
+
+ Pet
+ {
+ event.preventDefault()
+ setPetVisible(!petVisible)
+ }}
+ >
+ {petVisible ? 'Hide pet' : 'Show pet'}
+
+
+ Customize pet
+ {/* 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. */}
+
+
+ {BUNDLED_PETS.map((pet) => {
+ const selected = pet.id === petModelId
+ return (
+ {
+ event.preventDefault()
+ if (!petVisible) {
+ setPetVisible(true)
+ }
+ setPetModelId(pet.id)
+ }}
+ >
+
+ {selected ? : null}
+
+ {pet.label}
+
+ )
+ })}
+ {customPetModels.length > 0 ? : null}
+ {customPetModels.map((model) => {
+ const selected = model.id === petModelId
+ return (
+ {
+ event.preventDefault()
+ if (!petVisible) {
+ setPetVisible(true)
+ }
+ setPetModelId(model.id)
+ }}
+ >
+
+ {selected ? : null}
+
+ {model.label}
+
+
+ )
+ })}
+
+ {
+ // 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()
+ }}
+ >
+
+ Pick pet…
+
+
+
+
+
+ {
+ openSettingsTarget({
+ pane: 'experimental',
+ repoId: null,
+ sectionId: 'experimental-pet'
+ })
+ openSettingsPage()
+ }}
+ >
+ Pet settings…
+
+
+
+ )
+}
+
+export const PetStatusSegment = React.memo(PetStatusSegmentInner)
diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx
index 061897cc3..00fbacdb2 100644
--- a/src/renderer/src/components/status-bar/StatusBar.tsx
+++ b/src/renderer/src/components/status-bar/StatusBar.tsx
@@ -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(null)
const [isRefreshing, setIsRefreshing] = useState(false)
@@ -843,6 +849,7 @@ function StatusBarInner(): React.JSX.Element | null {
+ {petEnabled && }
{showMemory && }
{showSessions && }
{showSsh && }
diff --git a/src/renderer/src/components/ui/dropdown-menu.tsx b/src/renderer/src/components/ui/dropdown-menu.tsx
index be59a85e3..8e7c65d38 100644
--- a/src/renderer/src/components/ui/dropdown-menu.tsx
+++ b/src/renderer/src/components/ui/dropdown-menu.tsx
@@ -201,6 +201,7 @@ function DropdownMenuSubTrigger({
function DropdownMenuSubContent({
className,
+ style,
...props
}: React.ComponentProps) {
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}
/>
)
diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts
index 946d01bf5..bc38a1950 100644
--- a/src/renderer/src/store/slices/ui.ts
+++ b/src/renderer/src/store/slices/ui.ts
@@ -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 = (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 = (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,
diff --git a/src/shared/constants.ts b/src/shared/constants.ts
index 04657a3bd..89d2676ab 100644
--- a/src/shared/constants.ts
+++ b/src/shared/constants.ts
@@ -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
}
}
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 856e51424..a69de2543 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -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 = {