From 547448e15c0ab7e8449e153a328429ca4cc7cfeb Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 1 May 2026 18:40:52 -0700 Subject: [PATCH] =?UTF-8?q?refactor:=20rename=20pet=20=E2=86=92=20sidekick?= =?UTF-8?q?=20across=20codebase=20(#1334)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames the experimental pet overlay to "sidekick" with themed character names (Claude the Mage, OpenCode the Rogue, Gremlin the Trickster). Covers IPC channels, preload API, persisted UI state, settings flag, on-disk userData path, components, and types. Deletes the standalone pet-overlay design mock. Co-authored-by: Orca --- docs/design/pet-overlay.mock.html | 387 ------------------ .../runtime-home-service.test.ts | 2 +- src/main/codex-accounts/service.test.ts | 2 +- src/main/ipc/register-core-handlers.test.ts | 12 +- src/main/ipc/register-core-handlers.ts | 4 +- src/main/ipc/{pet.ts => sidekick.ts} | 52 +-- src/preload/api-types.ts | 10 +- src/preload/index.ts | 14 +- src/renderer/src/App.tsx | 16 +- src/renderer/src/components/pet/pet-models.ts | 52 --- .../components/settings/ExperimentalPane.tsx | 27 +- .../settings/experimental-search.ts | 15 +- .../SidekickOverlay.tsx} | 18 +- .../sidekick-blob-cache.ts} | 20 +- .../components/sidekick/sidekick-models.ts | 49 +++ .../useSidekickUrl.ts} | 30 +- .../status-bar/PetStatusSegment.tsx | 201 --------- .../status-bar/SidekickStatusSegment.tsx | 189 +++++++++ .../src/components/status-bar/StatusBar.tsx | 10 +- src/renderer/src/store/slices/ui.ts | 116 +++--- src/shared/constants.ts | 2 +- src/shared/types.ts | 40 +- 22 files changed, 438 insertions(+), 830 deletions(-) delete mode 100644 docs/design/pet-overlay.mock.html rename src/main/ipc/{pet.ts => sidekick.ts} (73%) delete mode 100644 src/renderer/src/components/pet/pet-models.ts rename src/renderer/src/components/{pet/PetOverlay.tsx => sidekick/SidekickOverlay.tsx} (77%) rename src/renderer/src/components/{pet/pet-blob-cache.ts => sidekick/sidekick-blob-cache.ts} (61%) create mode 100644 src/renderer/src/components/sidekick/sidekick-models.ts rename src/renderer/src/components/{pet/usePetModelUrl.ts => sidekick/useSidekickUrl.ts} (65%) delete mode 100644 src/renderer/src/components/status-bar/PetStatusSegment.tsx create mode 100644 src/renderer/src/components/status-bar/SidekickStatusSegment.tsx diff --git a/docs/design/pet-overlay.mock.html b/docs/design/pet-overlay.mock.html deleted file mode 100644 index c7a6a5564..000000000 --- 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.
-
-
-
-
-
-
- - -
- claude · 78% - codex · 42% - ● 3 sessions - ssh - mem 412MB - - - - Pet - - - - v1.3.26-rc.0 -
-
- - diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index aaaccfc99..ab28b7a41 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 4033c8a95..eba0b38a4 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 9c1fa03dd..9b10341db 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 55f46546b..a64361f27 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 a3b03cde9..ee8e97b68 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 f7ff8f796..f722057a2 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 8cf32475b..6d45b03c6 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 13b93a706..7a70fc75a 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 2ebbe96fd..000000000 --- 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 f7e579ba8..57c12765e 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 9515b74c7..8f0b79c3b 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 c3fa6e31a..c887c8286 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 983c2b6b5..3482aa07d 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 000000000..5234b88ba --- /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 2ee708cf2..b2439a93f 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 8b9c6683f..000000000 --- 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 000000000..ac66fea4f --- /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 00fbacdb2..55d8c8d56 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 bc38a1950..9d876b3f1 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 89d2676ab..2fdeab723 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 a69de2543..b96d33000 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