diff --git a/docs/design/pet-overlay.mock.html b/docs/design/pet-overlay.mock.html new file mode 100644 index 000000000..c7a6a5564 --- /dev/null +++ b/docs/design/pet-overlay.mock.html @@ -0,0 +1,387 @@ + + + + +Pet Overlay — UI mock + + + +
+ + +
+
+
orca
+
add-a-pet-for-orca
+
share-text-search
+
+ + + + + +
+
+
~/orca/add-a-pet-for-orca $ pnpm dev
+
› Local: http://localhost:5173
+
› ready in 412ms
+
 
+
$ _
+
+
+
~/orca $ git status
+
On branch add-a-pet-for-orca
+
Untracked files:
+
  docs/design/pet-overlay.md
+
  docs/design/pet-overlay.mock.html
+
 
+
$ _
+
+ + + + +
GLB canvas — pointer-events: none, z-index: var(--pet-z)
+
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 = {