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