diff --git a/docs/design/pet-overlay.mock.html b/docs/design/pet-overlay.mock.html deleted file mode 100644 index c7a6a5564cb..00000000000 --- a/docs/design/pet-overlay.mock.html +++ /dev/null @@ -1,387 +0,0 @@ - - - - -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/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index aaaccfc9990..ab28b7a415d 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -92,7 +92,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, experimentalAgentDashboard: false, - experimentalPet: false, + experimentalSidekick: 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 4033c8a95a5..eba0b38a4f6 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -86,7 +86,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, experimentalAgentDashboard: false, - experimentalPet: false, + experimentalSidekick: false, terminalWindowsShell: 'powershell.exe', enableGitHubAttribution: true, ...overrides diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 9c1fa03dd4b..9b10341db93 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -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) diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 55f46546b3b..a64361f275e 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -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) diff --git a/src/main/ipc/pet.ts b/src/main/ipc/sidekick.ts similarity index 73% rename from src/main/ipc/pet.ts rename to src/main/ipc/sidekick.ts index a3b03cde98b..ee8e97b6852 100644 --- a/src/main/ipc/pet.ts +++ b/src/main/ipc/sidekick.ts @@ -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 -// , 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 , 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', @@ -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 => { +export function registerSidekickHandlers(): void { + ipcMain.handle('sidekick: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', + 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 => { - 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 => { - const filePath = resolvePetFile(id, fileName) + ipcMain.handle('sidekick:delete', async (_event, id: string, fileName: string): Promise => { + 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) } }) } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index f7ff8f796bc..f722057a200 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 copyFile: (args: { srcPath: string; destPath: string }) => Promise } - pet: { - importModel: () => Promise - readModel: (id: string, fileName: string) => Promise - deleteModel: (id: string, fileName: string) => Promise + sidekick: { + import: () => Promise + read: (id: string, fileName: string) => Promise + delete: (id: string, fileName: string) => Promise } browser: BrowserApi hooks: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 8cf32475b70..6d45b03c61d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => 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) + sidekick: { + import: (): Promise => ipcRenderer.invoke('sidekick:import'), + read: (id: string, fileName: string): Promise => + ipcRenderer.invoke('sidekick:read', id, fileName), + delete: (id: string, fileName: string): Promise => + ipcRenderer.invoke('sidekick:delete', id, fileName) }, browser: { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 13b93a70681..7a70fc75a7b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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(null) @@ -1110,13 +1110,13 @@ 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 + {/* 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 ? ( - + ) : null} diff --git a/src/renderer/src/components/pet/pet-models.ts b/src/renderer/src/components/pet/pet-models.ts deleted file mode 100644 index 2ebbe96fd74..00000000000 --- a/src/renderer/src/components/pet/pet-models.ts +++ /dev/null @@ -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) -} diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx index f7e579ba875..57c12765ed5 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.tsx @@ -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({ ) : null} - {showPet ? ( + {showSidekick ? (
- +

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

diff --git a/src/renderer/src/components/settings/experimental-search.ts b/src/renderer/src/components/settings/experimental-search.ts index 9515b74c7ae..8f0b79c3b60 100644 --- a/src/renderer/src/components/settings/experimental-search.ts +++ b/src/renderer/src/components/settings/experimental-search.ts @@ -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', diff --git a/src/renderer/src/components/pet/PetOverlay.tsx b/src/renderer/src/components/sidekick/SidekickOverlay.tsx similarity index 77% rename from src/renderer/src/components/pet/PetOverlay.tsx rename to src/renderer/src/components/sidekick/SidekickOverlay.tsx index c3fa6e31a73..c887c8286ff 100644 --- a/src/renderer/src/components/pet/PetOverlay.tsx +++ b/src/renderer/src/components/sidekick/SidekickOverlay.tsx @@ -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.
@@ -66,4 +66,4 @@ export function PetOverlay(): React.JSX.Element { ) } -export default PetOverlay +export default SidekickOverlay diff --git a/src/renderer/src/components/pet/pet-blob-cache.ts b/src/renderer/src/components/sidekick/sidekick-blob-cache.ts similarity index 61% rename from src/renderer/src/components/pet/pet-blob-cache.ts rename to src/renderer/src/components/sidekick/sidekick-blob-cache.ts index 983c2b6b5e3..3482aa07d8d 100644 --- a/src/renderer/src/components/pet/pet-blob-cache.ts +++ b/src/renderer/src/components/sidekick/sidekick-blob-cache.ts @@ -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 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 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( @@ -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) diff --git a/src/renderer/src/components/sidekick/sidekick-models.ts b/src/renderer/src/components/sidekick/sidekick-models.ts new file mode 100644 index 00000000000..5234b88ba7f --- /dev/null +++ b/src/renderer/src/components/sidekick/sidekick-models.ts @@ -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) +} diff --git a/src/renderer/src/components/pet/usePetModelUrl.ts b/src/renderer/src/components/sidekick/useSidekickUrl.ts similarity index 65% rename from src/renderer/src/components/pet/usePetModelUrl.ts rename to src/renderer/src/components/sidekick/useSidekickUrl.ts index 2ee708cf227..b2439a93fb8 100644 --- a/src/renderer/src/components/pet/usePetModelUrl.ts +++ b/src/renderer/src/components/sidekick/useSidekickUrl.ts @@ -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(() => 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(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 } } diff --git a/src/renderer/src/components/status-bar/PetStatusSegment.tsx b/src/renderer/src/components/status-bar/PetStatusSegment.tsx deleted file mode 100644 index 8b9c6683f30..00000000000 --- a/src/renderer/src/components/status-bar/PetStatusSegment.tsx +++ /dev/null @@ -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 => { - 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/SidekickStatusSegment.tsx b/src/renderer/src/components/status-bar/SidekickStatusSegment.tsx new file mode 100644 index 00000000000..ac66fea4fd4 --- /dev/null +++ b/src/renderer/src/components/status-bar/SidekickStatusSegment.tsx @@ -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 => { + 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 ( + + + + + + Sidekick + { + event.preventDefault() + setSidekickVisible(!sidekickVisible) + }} + > + {sidekickVisible ? 'Hide sidekick' : 'Show sidekick'} + + + Choose sidekick + {/* 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_SIDEKICKS.map((sidekick) => { + const selected = sidekick.id === sidekickId + return ( + { + if (!sidekickVisible) { + setSidekickVisible(true) + } + setSidekickId(sidekick.id) + }} + > + + {selected ? : null} + + {sidekick.label} + + ) + })} + {customSidekicks.length > 0 ? : null} + {customSidekicks.map((model) => { + const selected = model.id === sidekickId + return ( + { + if (!sidekickVisible) { + setSidekickVisible(true) + } + setSidekickId(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() + }} + > + + Upload your own… + + + + + + { + openSettingsTarget({ + pane: 'experimental', + repoId: null, + sectionId: 'experimental-sidekick' + }) + openSettingsPage() + }} + > + Sidekick settings… + + + + ) +} + +export const SidekickStatusSegment = React.memo(SidekickStatusSegmentInner) diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 00fbacdb2a8..55d8c8d56c3 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -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(null) const [isRefreshing, setIsRefreshing] = useState(false) @@ -849,7 +849,7 @@ function StatusBarInner(): React.JSX.Element | null {
- {petEnabled && } + {sidekickEnabled && } {showMemory && } {showSessions && } {showSsh && } diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index bc38a195030..9d876b3f19e 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -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 = (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 = (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, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 89d2676ab25..2fdeab723cd 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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 } } diff --git a/src/shared/types.ts b/src/shared/types.ts index a69de25437c..b96d3300028 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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