From 743985bf1ec4c911cd5af7bec705a419d8cdd61b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 24 Jun 2026 13:48:45 -0500 Subject: [PATCH] =?UTF-8?q?feat(pets):=20Pok=C3=A9dex=20generate=20UI=20?= =?UTF-8?q?=E2=80=94=20overlay,=20animated=20egg,=20hatch=20FX,=20manage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dedicated generate modal (Cmd-K → Pets → Generate): prompt → 2×2 draft grid → egg hatch → preview → adopt, width fits each phase. - Reuses shared primitives (Button/Input/Dialog/Alert/GenerateButton); cards use selectableCardClass; only canvas + range stay raw. - Animated creme pixel egg + PetStarShower hatch celebration (canvas). - Live streamed drafts with a real Stop (AbortSignal); clean default name. - Manage generated pets: badge + top ranking, rename (optimistic), safe delete (confirm + drop), export — in both the Cmd-K and Settings lists. - pet-gallery routes every RPC through profile-scoped petRpc; i18n ×5. --- .../desktop/src/app/command-palette/index.tsx | 13 +- .../app/command-palette/pet-palette-page.tsx | 33 +- apps/desktop/src/app/desktop-controller.tsx | 2 + .../app/pet-generate/pet-generate-overlay.tsx | 469 ++++++++++++++++ .../desktop/src/app/settings/pet-settings.tsx | 156 +++++- .../src/components/pet/pet-egg-hatch.tsx | 66 +++ .../src/components/pet/pet-egg-sheet.png | Bin 0 -> 1797 bytes .../desktop/src/components/pet/pet-sprite.tsx | 47 +- .../src/components/pet/pet-star-shower.tsx | 204 +++++++ .../src/components/pet/pixel-egg-sprite.tsx | 234 ++++++++ .../src/components/ui/generate-button.tsx | 62 +++ apps/desktop/src/i18n/en.ts | 34 ++ apps/desktop/src/i18n/ja.ts | 34 ++ apps/desktop/src/i18n/types.ts | 34 ++ apps/desktop/src/i18n/zh-hant.ts | 34 ++ apps/desktop/src/i18n/zh.ts | 34 ++ apps/desktop/src/lib/icons.ts | 2 + apps/desktop/src/store/pet-gallery.ts | 163 +++++- apps/desktop/src/store/pet-generate.ts | 527 ++++++++++++++++++ apps/desktop/src/styles.css | 232 ++++++++ 20 files changed, 2353 insertions(+), 27 deletions(-) create mode 100644 apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx create mode 100644 apps/desktop/src/components/pet/pet-egg-hatch.tsx create mode 100644 apps/desktop/src/components/pet/pet-egg-sheet.png create mode 100644 apps/desktop/src/components/pet/pet-star-shower.tsx create mode 100644 apps/desktop/src/components/pet/pixel-egg-sprite.tsx create mode 100644 apps/desktop/src/components/ui/generate-button.tsx create mode 100644 apps/desktop/src/store/pet-generate.ts diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 84c75b1c150..0f94ed13945 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -20,6 +20,7 @@ import { Clock, Cpu, Download, + Egg, Globe, type IconComponent, Info, @@ -43,6 +44,7 @@ import { import { cn } from '@/lib/utils' import { $commandPaletteOpen, $commandPalettePage, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette' import { $bindings } from '@/store/keybinds' +import { openPetGenerate } from '@/store/pet-generate' import { runGatewayRestart } from '@/store/system-actions' import { luminance } from '@/themes/color' import { type ThemeMode, useTheme } from '@/themes/context' @@ -409,6 +411,13 @@ export function CommandPalette() { keywords: ['pet', 'petdex', 'mascot', 'pets', '/pet', 'paw'], label: cc.pets.title, to: 'pets' + }, + { + icon: Egg, + id: 'appearance-generate-pet', + keywords: ['pet', 'generate', 'create', 'make', 'new pet', 'mascot', 'hatch', 'ai'], + label: cc.generatePet.title, + run: () => openPetGenerate() } ] }, @@ -653,6 +662,8 @@ export function CommandPalette() { event.preventDefault() event.stopPropagation() goBack() + + return } }} onValueChange={setSearch} @@ -663,7 +674,7 @@ export function CommandPalette() { {/* Server-driven pages render their own list; the rest show groups. */} {page === 'pets' ? ( - + { closeCommandPalette(); openPetGenerate() }} search={search} /> ) : page === 'install-theme' ? ( ) : ( diff --git a/apps/desktop/src/app/command-palette/pet-palette-page.tsx b/apps/desktop/src/app/command-palette/pet-palette-page.tsx index 891637c67cb..9e75b666ef6 100644 --- a/apps/desktop/src/app/command-palette/pet-palette-page.tsx +++ b/apps/desktop/src/app/command-palette/pet-palette-page.tsx @@ -15,7 +15,7 @@ import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' import { PetThumb } from '@/components/pet/pet-thumb' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' -import { Check, Loader2, PawPrint } from '@/lib/icons' +import { Check, Egg, Loader2, PawPrint } from '@/lib/icons' import { cn } from '@/lib/utils' import { $petBusy, @@ -31,9 +31,11 @@ import { interface PetPalettePageProps { search: string + /** Navigate to the "generate a pet" page (rendered as a header action). */ + onGenerate?: () => void } -export function PetPalettePage({ search }: PetPalettePageProps) { +export function PetPalettePage({ search, onGenerate }: PetPalettePageProps) { const { t } = useI18n() const copy = t.commandCenter.pets const { requestGateway } = useGatewayRequest() @@ -72,6 +74,24 @@ export function PetPalettePage({ search }: PetPalettePageProps) { return (
+ {onGenerate && ( + + )} + {error &&

{error}

} {shown.length === 0 ? ( @@ -104,7 +124,14 @@ export function PetPalettePage({ search }: PetPalettePageProps) { url={pet.spritesheetUrl} /> - {pet.displayName} + + {pet.displayName} + {pet.generated && ( + + {copy.generatedTag} + + )} + {pet.slug} {pet.installed ? ` · ${copy.installed}` : ''} diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index ac965299bdd..8a039f41710 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -108,6 +108,7 @@ import { useKeybinds } from './hooks/use-keybinds' import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants' import { ModelPickerOverlay } from './model-picker-overlay' import { ModelVisibilityOverlay } from './model-visibility-overlay' +import { PetGenerateOverlay } from './pet-generate/pet-generate-overlay' import { RightSidebarPane } from './right-sidebar' import { $terminalTakeover } from './right-sidebar/store' import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent' @@ -1028,6 +1029,7 @@ export function DesktopController() { + {settingsOpen && ( diff --git a/apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx b/apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx new file mode 100644 index 00000000000..2ba12a22bc0 --- /dev/null +++ b/apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx @@ -0,0 +1,469 @@ +/** + * "Hatch a Pet" — a dedicated, Pokédex-style overlay for pet generation. + * + * Previously generation lived as a cramped nested page inside the Cmd-K command + * palette (~34rem popover). This is its own full Radix dialog with room to + * breathe: a device-framed header, its own concept prompt, a roomy draft grid + * that streams in live, and the egg-hatch + reveal flow. It's a thin view over + * the `pet-generate` store; the store owns the generate → hatch → adopt steps. + */ + +import { useStore } from '@nanostores/react' +import { useEffect, useState } from 'react' + +import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' +import { PetEggHatch } from '@/components/pet/pet-egg-hatch' +import { PetStarShower } from '@/components/pet/pet-star-shower' +import { PetSprite } from '@/components/pet/pet-sprite' +import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { GenerateButton } from '@/components/ui/generate-button' +import { Input } from '@/components/ui/input' +import { useI18n } from '@/i18n' +import { triggerHaptic } from '@/lib/haptics' +import { Egg, Loader2, PawPrint, RefreshCw } from '@/lib/icons' +import { selectableCardClass } from '@/lib/selectable-card' +import { cn } from '@/lib/utils' +import { type PetInfo } from '@/store/pet' +import { + $petGenDrafts, + $petGenerateOpen, + $petGenError, + $petGenPreview, + $petGenSelected, + $petGenStage, + $petGenStatus, + adoptHatched, + cancelGenerate, + cancelHatch, + cleanPetName, + cleanupPetGen, + closePetGenerate, + discardHatched, + generateDrafts, + hatchSelected +} from '@/store/pet-generate' + +const VARIANT_COUNT = 4 +const PREVIEW_SCALE = 0.7 +const PREVIEW_ROWS = [ + 'idle', + 'waving', + 'running-right', + 'running-left', + 'running', + 'review', + 'jumping', + 'failed', + 'waiting' +] +const PREVIEW_STATE_MS = 1400 + +const ROW_TO_FRAME_KEY: Record = { + idle: 'idle', + wave: 'wave', + waving: 'wave', + jump: 'jump', + jumping: 'jump', + run: 'run', + running: 'run', + 'running-right': 'run', + 'running-left': 'run', + failed: 'failed', + review: 'review', + waiting: 'waiting' +} + +function frameCountForRow(pet: PetInfo, row: string): number { + const byState = pet.framesByState + const mapped = ROW_TO_FRAME_KEY[row] + return byState?.[row] ?? (mapped ? byState?.[mapped] : undefined) ?? pet.framesPerState ?? 0 +} + +export function PetGenerateOverlay() { + const open = useStore($petGenerateOpen) + const status = useStore($petGenStatus) + const { requestGateway } = useGatewayRequest() + + const handleOpenChange = (next: boolean) => { + if (!next) { + // Deletes a hatched-but-unadopted preview pet so it doesn't linger, then + // resets all generation state. + cleanupPetGen(requestGateway) + closePetGenerate() + } + } + + // The draft screen needs room for the 2×2 grid; the single-pet screens + // (hatch egg, reveal) shrink to the pet's frame so it isn't lost in a wide box. + const single = status === 'hatching' || status === 'preview' || status === 'adopting' + + return ( + + + {open && } + + + ) +} + +function PetGenerateContent() { + const { t } = useI18n() + const copy = t.commandCenter.generatePet + const { requestGateway } = useGatewayRequest() + + const status = useStore($petGenStatus) + const error = useStore($petGenError) + const drafts = useStore($petGenDrafts) + const selected = useStore($petGenSelected) + const preview = useStore($petGenPreview) + const stage = useStore($petGenStage) + + const [prompt, setPrompt] = useState('') + + const busy = status === 'generating' || status === 'hatching' + const hasDrafts = drafts.length > 0 + const generating = status === 'generating' + // The idle "describe a pet" state — egg + suggestions get generous, equidistant + // breathing room (gap-7.5) from the prompt; the working states stay compact. + const isEmptyState = + !hasDrafts && + !generating && + status !== 'hatching' && + status !== 'preview' && + status !== 'adopting' && + status !== 'stale' + + const close = () => { + cleanupPetGen(requestGateway) + closePetGenerate() + } + + const generate = () => { + if (prompt.trim() && !busy) { + void generateDrafts(requestGateway, { prompt: prompt.trim() }) + } + } + + // One-click an example prompt straight into a draft round. + const runExample = (example: string) => { + setPrompt(example) + void generateDrafts(requestGateway, { prompt: example }) + } + + // Hatch with a clean default name derived from the prompt (the prompt itself + // is grounding text, not a label); the user names it on the reveal screen. + const hatch = () => { + if (prompt.trim()) { + void hatchSelected(requestGateway, { name: cleanPetName(prompt), prompt: prompt.trim() }) + } + } + + const adopt = (finalName: string) => { + void adoptHatched(requestGateway, finalName).then(out => { + if (out.ok) { + triggerHaptic('crisp') + close() + } + }) + } + + // The header title tracks the phase instead of sticking on "Generate a pet". + const headerTitle = + status === 'hatching' ? copy.spawning : status === 'preview' || status === 'adopting' ? copy.hatched : copy.title + // Prompt input only belongs on the describe/draft screens. + const showPrompt = status !== 'hatching' && status !== 'preview' && status !== 'adopting' + + return ( + <> + + {headerTitle} + + +
+ {/* Concept prompt with the inline sparkle generate/stop affordance (the + same primitive as the commit-message + project-idea fields). */} + {showPrompt && ( +
+ setPrompt(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') { + event.preventDefault() + generate() + } + }} + placeholder={copy.placeholder} + value={prompt} + /> + +
+ )} + + {error && status !== 'preview' && status !== 'adopting' && ( + + {error} + + )} + + {status === 'stale' ? ( + + {copy.staleBackend} + + ) : status === 'hatching' ? ( + + ) : (status === 'preview' || status === 'adopting') && preview ? ( + void discardHatched(requestGateway)} + pet={preview} + /> + ) : !hasDrafts && !generating ? ( + + ) : ( + $petGenSelected.set(index)} + selected={selected} + /> + )} +
+ + ) +} + +// Creative seed prompts — specifics make better pets (petdex's own advice). +// Doubling as guidance and a one-click way to see the flow. +const EXAMPLE_PROMPTS = ['a bubble-tea otter', 'a tiny sock elf', 'a pixel dragon', 'a grumpy office cat', 'a neon axolotl'] + +function EmptyHint({ onExample }: { onExample: (prompt: string) => void }) { + return ( +
+

Need a spark?

+
+ {EXAMPLE_PROMPTS.map(example => ( + + ))} +
+
+ ) +} + +function HatchingView({ stage }: { stage: { phase: string; state?: string; done?: number; total?: number } | null }) { + const { t } = useI18n() + const copy = t.commandCenter.generatePet + + const subtitle = stage + ? stage.phase === 'row' + ? copy.hatchRow(stage.state ?? '', stage.done ?? 0, stage.total ?? 0) + : stage.phase === 'compose' + ? copy.hatchComposing + : copy.hatchSaving + : copy.hatchingSub + + return +} + +interface DraftGridProps { + busy: boolean + drafts: { index: number; dataUri: string }[] + generating: boolean + hasDrafts: boolean + onHatch: () => void + onSelect: (index: number) => void + selected: number | null +} + +function DraftGrid({ busy, drafts, generating, hasDrafts, onHatch, onSelect, selected }: DraftGridProps) { + const { t } = useI18n() + const copy = t.commandCenter.generatePet + + const slots = generating + ? Array.from({ length: VARIANT_COUNT }, (_, i) => drafts.find(draft => draft.index === i) ?? null) + : drafts + + return ( +
+ {generating && ( +
+ {copy.generating} + + {drafts.length}/{VARIANT_COUNT} + +
+ )} + +
+ {slots.map((draft, i) => { + const isSelected = !generating && draft != null && selected === draft.index + + return ( + + ) + })} +
+ + {hasDrafts && ( + + )} +
+ ) +} + +interface HatchPreviewProps { + pet: PetInfo + adopting: boolean + error: string | null + onAdopt: (name: string) => void + onDiscard: () => void +} + +function HatchPreview({ pet, adopting, error, onAdopt, onDiscard }: HatchPreviewProps) { + const { t } = useI18n() + const copy = t.commandCenter.generatePet + // Empty so the "Name your pet" placeholder shows; blank adopt keeps the + // provisional name from the prompt. + const [name, setName] = useState('') + // Play the egg's crack/hatch frames once before swapping in the live pet. + const [revealed, setRevealed] = useState(false) + // Right after the egg cracks the pet plays its "yay" jump a couple times, then + // hands off to the normal state-cycling preview. + const [celebrating, setCelebrating] = useState(false) + const [stateIndex, setStateIndex] = useState(0) + const previewRows = (pet.stateRows?.length ? pet.stateRows : PREVIEW_ROWS).filter(row => frameCountForRow(pet, row) > 0) + const rows = previewRows.length > 0 ? previewRows : ['idle'] + const activeRow = rows[stateIndex % rows.length] ?? 'idle' + const canJump = frameCountForRow(pet, 'jumping') > 0 + const rowOverride = celebrating && canJump ? 'jumping' : activeRow + + useEffect(() => { + const id = setInterval(() => setStateIndex(i => (i + 1) % rows.length), PREVIEW_STATE_MS) + return () => clearInterval(id) + }, [rows.length]) + + // On reveal: celebrate (jump) ~2 loops, then drop into the cycling preview. + useEffect(() => { + if (!revealed) { + return + } + setCelebrating(true) + const id = setTimeout(() => { + setCelebrating(false) + setStateIndex(0) + }, 2 * (pet.loopMs ?? 1100)) + return () => clearTimeout(id) + }, [revealed, pet.loopMs]) + + useEffect(() => { + setStateIndex(0) + setName('') + setRevealed(false) + setCelebrating(false) + }, [pet.slug]) + + const previewInfo: PetInfo = { ...pet, scale: PREVIEW_SCALE } + + return ( +
+ {/* Fills the (now narrow) dialog so the pet frame is the screen width. */} +
+ {revealed ? ( + <> +
+ +
+ + + ) : ( + // The egg cracks open, then we swap in the live pet. + { + setRevealed(true) + triggerHaptic('crisp') + }} + size={150} + /> + )} +
+ + setName(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') { + event.preventDefault() + onAdopt(name) + } + }} + placeholder={copy.namePlaceholder} + value={name} + /> + + {error && ( + + {error} + + )} + +
+ + +
+
+ ) +} + diff --git a/apps/desktop/src/app/settings/pet-settings.tsx b/apps/desktop/src/app/settings/pet-settings.tsx index e9b0e925ce1..2990a7cdc37 100644 --- a/apps/desktop/src/app/settings/pet-settings.tsx +++ b/apps/desktop/src/app/settings/pet-settings.tsx @@ -1,12 +1,16 @@ import { useStore } from '@nanostores/react' -import { useEffect, useState } from 'react' +import { type ReactNode, useEffect, useState } from 'react' import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' import { PetThumb } from '@/components/pet/pet-thumb' +import { Button } from '@/components/ui/button' +import { ConfirmDialog } from '@/components/ui/confirm-dialog' +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' import { SegmentedControl } from '@/components/ui/segmented-control' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' -import { Loader2, PawPrint, Trash2 } from '@/lib/icons' +import { Download, Loader2, PawPrint, Pencil, Trash2 } from '@/lib/icons' import { selectableCardClass } from '@/lib/selectable-card' import { cn } from '@/lib/utils' import { $petInfo } from '@/store/pet' @@ -16,13 +20,16 @@ import { $petGalleryError, $petGalleryStatus, adoptPet, + exportPet as exportPetAction, loadPetGallery, loadPetThumb, PET_SCALE_DEFAULT, PET_SCALE_MAX, PET_SCALE_MIN, + type GalleryPet, rankedGalleryPets, removePet as removePetAction, + renamePet as renamePetAction, setPetEnabled, setPetScale } from '@/store/pet-gallery' @@ -48,6 +55,9 @@ export function PetSettings() { const busySlug = useStore($petBusy) const petInfo = useStore($petInfo) const [query, setQuery] = useState('') + const [confirmDelete, setConfirmDelete] = useState(null) + const [renameTarget, setRenameTarget] = useState(null) + const [renameValue, setRenameValue] = useState('') const scale = petInfo.scale ?? PET_SCALE_DEFAULT useEffect(() => { @@ -71,6 +81,23 @@ export function PetSettings() { void removePetAction(requestGateway, slug, copy.uninstallFailed(slug)).then(ok => ok && triggerHaptic('crisp')) } + const exportPet = (slug: string) => { + void exportPetAction(requestGateway, slug, copy.exportFailed(slug)).then(ok => ok && triggerHaptic('crisp')) + } + + const saveRename = () => { + if (!renameTarget || !renameValue.trim()) { + return + } + + // Optimistic: the rename paints instantly, so close now and let the RPC + // settle in the background (it rolls back + surfaces an error on failure). + const { slug } = renameTarget + setRenameTarget(null) + triggerHaptic('crisp') + void renamePetAction(requestGateway, slug, renameValue, copy.renameFailed(slug)) + } + const toggle = (on: boolean) => { void setPetEnabled(requestGateway, on, { noneAvailable: copy.noneAvailable, @@ -142,8 +169,15 @@ export function PetSettings() { url={pet.spritesheetUrl} /> - - {pet.displayName} + + + {pet.displayName} + + {pet.generated && ( + + {copy.generatedTag} + + )} {pet.slug} @@ -152,16 +186,36 @@ export function PetSettings() { {isBusy && } - {pet.installed && !isBusy && ( - + {!isBusy && (pet.installed || pet.generated) && ( +
+ {pet.generated && ( + } + label={copy.rename(pet.displayName)} + onClick={() => { + setRenameValue(pet.displayName) + setRenameTarget(pet) + }} + /> + )} + {pet.generated && ( + } + label={copy.exportPet(pet.displayName)} + onClick={() => exportPet(pet.slug)} + /> + )} + {pet.installed && ( + // Generated pets have no remote source — deletion is + // permanent, so confirm; petdex pets just uninstall. + } + label={pet.generated ? copy.delete(pet.displayName) : copy.uninstall(pet.displayName)} + onClick={() => (pet.generated ? setConfirmDelete(pet) : removePet(pet.slug))} + /> + )} +
)}
) @@ -226,6 +280,80 @@ export function PetSettings() { /> )} + + setConfirmDelete(null)} + onConfirm={async () => { + if (confirmDelete) { + const ok = await removePetAction(requestGateway, confirmDelete.slug, copy.uninstallFailed(confirmDelete.slug)) + if (!ok) { + throw new Error(copy.uninstallFailed(confirmDelete.slug)) + } + triggerHaptic('crisp') + } + }} + open={confirmDelete !== null} + title={confirmDelete ? copy.deleteTitle(confirmDelete.displayName) : ''} + /> + + !open && setRenameTarget(null)} open={renameTarget !== null}> + + + {copy.renameTitle} + + setRenameValue(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') { + event.preventDefault() + saveRename() + } + }} + placeholder={copy.renamePlaceholder} + value={renameValue} + /> + + + + + + ) } + +/** A single hover-revealed icon action on a pet card (rename / export / delete). */ +function PetAction({ + danger, + icon, + label, + onClick +}: { + danger?: boolean + icon: ReactNode + label: string + onClick: () => void +}) { + return ( + + ) +} diff --git a/apps/desktop/src/components/pet/pet-egg-hatch.tsx b/apps/desktop/src/components/pet/pet-egg-hatch.tsx new file mode 100644 index 00000000000..a677d84b13c --- /dev/null +++ b/apps/desktop/src/components/pet/pet-egg-hatch.tsx @@ -0,0 +1,66 @@ +/** + * Egg-hatch visuals for the pet generation flow (Cmd-K → Pets → Generate). + * + * `PetEggHatch` is the incubation beat shown while `pet.hatch` runs: a wobbling + * egg that reads as "something is about to hatch" instead of a bare spinner. The + * reveal celebration is the canvas `PetStarShower`. Motion is disabled under + * `prefers-reduced-motion`. + */ + +import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite' +import { Button } from '@/components/ui/button' + +interface PetEggHatchProps { + subtitle?: string + onCancel?: () => void + cancelLabel?: string +} + +/** + * Thin progress bar. Determinate when given done/total (hatch rows stream one by + * one, so a real percentage is meaningful); indeterminate otherwise (drafts + * return together, so a count would just snap 0→100). + */ +export function PetProgress({ done, total }: { done?: number; total?: number }) { + const determinate = typeof done === 'number' && typeof total === 'number' && total > 0 + const pct = determinate ? Math.min(100, Math.round((done / total) * 100)) : 0 + + return ( +
+ {determinate ? ( +
+ ) : ( +
+ )} +
+ ) +} + +export function PetEggHatch({ subtitle, onCancel, cancelLabel }: PetEggHatchProps) { + return ( +
+
+ + +
+ + {subtitle && ( +

+ {subtitle} +

+ )} + + {onCancel && ( + + )} +
+ ) +} diff --git a/apps/desktop/src/components/pet/pet-egg-sheet.png b/apps/desktop/src/components/pet/pet-egg-sheet.png new file mode 100644 index 0000000000000000000000000000000000000000..128958eeec38667555a2af9e533cb823f1c45e35 GIT binary patch literal 1797 zcmV+g2m1JlP)Px*zez+vRCr$PT~U(UDiE7J-R|*TRnC&C-P7e&CgUlarIFNt8_#w=V{C+InwB7w zw_EU^x8VM73Bbd^A24mwH>Jjpj>7=lJO2FqY!dMC@v%Lp21hm8k30eZHS&@cmI;P| zFaXt#-{0TwgZ=vYqH-8wqQ$^k0MwCh^bBL*OaQK+LjhqiQHy~D@LCLf{{DRrkNszju43+Hqmgl8vibnOn6`q#@Ky6Mc6AA-i09+!K zWrAT~O#mqAPG{f@094TR0+AgO)S+rtC4gig8JNZZlI4st>Pkdm091`QdM2|!{(?U-R;833#=;F9TzE>DC4e3V^8UafzEJ&d1*2yQ!Y(jSo0H0f!ax`Rmr7=tU>H~v07|jb88`y~ z6?DBoXoCWEWIabykpPl`WMCQt$QfajQCA`g1E6Ze;ZraYz%t^LiUoD#Oc0j)OWq$~ zeFCP;d$KDve!PWz0_mGlV{cK?dM^Pa1D9kVd>MuTXc`mS&jKJJ;&LrY{$)dU?u>{m?&K`>x#$iR{vpMxh5>FnbQEUp3pP~ z&ICXeq3WJ|{rdwb*fvJ2cHIn|4#1jYR<2iE^lI{h_Xkvm#F~oTcMg3{JG^FP`s)H< z#p*N$dH_hiz_%cM+!@*a-uj;#n0mmKlG%|n03;^VjxPm(^#v$z*wu*i1zg|uC?mBL zXtqxPmu4XE58R_W?+>WwgsyD0&xFhT`+`lXy}kWg$*Lyo-VMV6c$JVR1}KN*S%yE3 zc1Kt=u9B=AAp#;hB2m_#sQ&tr-d7$bEBY8d?0O}A0<**<` z9a+!A0Ob9FRL95{VDi8+^1KPe>kB9hECYb`2_(5EH70!m>6=nxZ#r(hmjIH1OEM64 zd>@{O^8P?Iq0KzLr|N;30P_C8fq^rR@2hivCIFW+g0BB}pPr*)WIBL`>~3O1P6>T- zdO84WSgc>b8WnkefQ|vQ3d|{s`T+!1Gfrcm2LQDVBg)eud0mG@SdNtFp&F6CK#7g| zHq}TNwcPX)04yzNftOCz83w`tG_|00PxyAb9o`#m*P{=P0KgtpHe-N2OO|&-m4R(t zGG)0-3sz#`5VVMiJp*e2V5Jzl8my@>88{O_mljkRD4F=nz?uLADaP&z_raH1(PZFs z0F8~D*|3;8pt`9&8HfbHpx?JaUa?e~dFY)EpxHtC18TsadjxVla2WtS^4zsIl+5%1 zNPnPYlbRBiaw$#jRWV9HGXWf8LRf}Wnx`72KOoR9H)W z(Dq(GP{q*JhR94(d#kix1AxaLV3HL>P&Gv_A~(jQ2F0Nt`re}LHwsAW0R nph$lJZBAX!nHcEm0KNP_3jvkS7Ad = { waiting: ['waiting'] } +const ROW_TO_STATE: Record = { + idle: 'idle', + wave: 'wave', + waving: 'wave', + jump: 'jump', + jumping: 'jump', + run: 'run', + running: 'run', + 'running-right': 'run', + 'running-left': 'run', + failed: 'failed', + review: 'review', + waiting: 'waiting' +} + interface PetSpriteProps { info: PetInfo /** On-screen scale multiplier applied on top of the pet's native scale. */ zoom?: number + /** + * Force a specific animation state instead of reading the live `$petState`. + * Used by the generate-flow preview to showcase every row without driving (or + * being driven by) the real agent activity that moves the floating mascot. + */ + stateOverride?: PetState + /** Force a concrete row name from `info.stateRows` (e.g. `running-right`). */ + rowOverride?: string } /** @@ -49,9 +72,20 @@ interface PetSpriteProps { * with `memo`, this component effectively never re-renders after mount until * the pet itself changes. */ -function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) { +function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSpriteProps) { const canvasRef = useRef(null) const stateRef = useRef($petState.get()) + const overrideRef = useRef(stateOverride) + const rowOverrideRef = useRef(rowOverride) + + // Keep the override current without re-running the RAF setup effect. + useEffect(() => { + overrideRef.current = stateOverride + }, [stateOverride]) + + useEffect(() => { + rowOverrideRef.current = rowOverride + }, [rowOverride]) const frameW = info.frameW ?? DEFAULT_FRAME_W const frameH = info.frameH ?? DEFAULT_FRAME_H @@ -116,6 +150,7 @@ function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) { // than flashing blank padding. const resolve = (s: PetState): { row: number; count: number } => { const real = framesByState?.[s] ?? frames + if (real > 0) { return { row: rowIndexForState(s), count: real } } @@ -123,8 +158,16 @@ function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) { return { row: rowIndexForState('idle'), count: Math.max(1, framesByState?.idle ?? frames) } } + const resolveRow = (rowName: string): { row: number; count: number } => { + const row = rows.indexOf(rowName) + const state = ROW_TO_STATE[rowName] + const count = Math.max(1, framesByState?.[rowName] ?? (state ? framesByState?.[state] : 0) ?? frames) + return { row: row >= 0 ? row : rowIndexForState(state ?? 'idle'), count } + } + const render = (now: number) => { - const { row, count } = resolve(stateRef.current) + const forcedRow = rowOverrideRef.current + const { row, count } = forcedRow ? resolveRow(forcedRow) : resolve(overrideRef.current ?? stateRef.current) // Per-state step keeps every state's loop ~loopMs even when frame counts // differ; counts vary per row so derive the cadence here, not once. const stepMs = loopMs / count diff --git a/apps/desktop/src/components/pet/pet-star-shower.tsx b/apps/desktop/src/components/pet/pet-star-shower.tsx new file mode 100644 index 00000000000..ad5552cd1ff --- /dev/null +++ b/apps/desktop/src/components/pet/pet-star-shower.tsx @@ -0,0 +1,204 @@ +import { useEffect, useRef } from 'react' + +/** + * Canvas hatch celebration layered over a freshly revealed pet: a one-shot + * sunburst of rotating god-rays, a fast radial star burst (confetti physics — + * velocity + decay + gravity + spin), and a light trickle of rising twinkle + * motes. Additive (`lighter`) so the sparkles bloom. No glow-halo flash. + * + * Sized to its container (absolute inset-0, pointer-events: none) and disabled + * under `prefers-reduced-motion`. + */ + +const GOLD = '#ffd76a' +const BURST = 15 +const VELOCITY = 500 +const DECAY = 0.9 +const GRAVITY = 90 +const RAY_COUNT = 24 +const GOLD_MIX = 0.6 +const MOTE_MS = 333 // ~3 / sec + +interface Star { + x: number + y: number + vx: number + vy: number + size: number + rot: number + vrot: number + phase: number + twinkle: number + life: number + ttl: number + color: string + rise: boolean +} + +function readAccent(el: HTMLElement): string { + return getComputedStyle(el).getPropertyValue('--ui-accent').trim() || '#9aa0ff' +} + +function sparkle(ctx: CanvasRenderingContext2D, size: number, rot: number, color: string): void { + ctx.rotate(rot) + ctx.fillStyle = color + for (const [rx, ry] of [ + [size, size * 0.26], + [size * 0.26, size] + ]) { + ctx.beginPath() + ctx.moveTo(0, -ry) + ctx.lineTo(rx, 0) + ctx.lineTo(0, ry) + ctx.lineTo(-rx, 0) + ctx.closePath() + ctx.fill() + } + const core = Math.max(1, Math.round(size * 0.4)) + ctx.fillStyle = '#fff' + ctx.fillRect(-core / 2, -core / 2, core, core) +} + +export function PetStarShower() { + const canvasRef = useRef(null) + + useEffect(() => { + const canvas = canvasRef.current + const ctx = canvas?.getContext('2d') + const parent = canvas?.parentElement + if (!canvas || !ctx || !parent) { + return + } + if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) { + return + } + + const accent = readAccent(canvas) + const dpr = Math.min(window.devicePixelRatio || 1, 3) + let w = 0 + let h = 0 + let cx = 0 + let cy = 0 + const resize = () => { + const r = parent.getBoundingClientRect() + w = r.width + h = r.height + cx = w / 2 + cy = h * 0.54 + canvas.width = Math.round(w * dpr) + canvas.height = Math.round(h * dpr) + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + } + resize() + const ro = new ResizeObserver(resize) + ro.observe(parent) + + const pick = () => (Math.random() < GOLD_MIX ? GOLD : Math.random() < 0.5 ? accent : '#ffffff') + const stars: Star[] = [] + for (let i = 0; i < BURST; i++) { + const a = Math.random() * Math.PI * 2 + const sp = VELOCITY * (0.4 + Math.random() * 0.7) + stars.push({ + x: cx, y: cy, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, + size: 3.5 + Math.random() * 5.5, rot: Math.random() * 6.28, vrot: (Math.random() - 0.5) * 8, + phase: 0, twinkle: 0, life: 0, ttl: 0.8 + Math.random() * 0.7, color: pick(), rise: false + }) + } + const rays = { life: 0, ttl: 0.9, rot: Math.random() * 6.28 } + + let raf = 0 + let last = performance.now() + let acc = 0 + let raysAlive = true + + const tick = (now: number) => { + raf = requestAnimationFrame(tick) + const ms = now - last + last = now + const dt = Math.min(0.05, ms / 1000) + const decay = Math.pow(DECAY, dt * 60) + acc += ms + if (acc >= MOTE_MS && stars.length < 40) { + acc = 0 + stars.push({ + x: cx + (Math.random() - 0.5) * w * 0.85, y: cy + Math.random() * h * 0.25, + vx: (Math.random() - 0.5) * 14, vy: -(14 + Math.random() * 26), + size: 2.5 + Math.random() * 3.5, rot: Math.random() * 6.28, vrot: (Math.random() - 0.5) * 2, + phase: Math.random() * 6.28, twinkle: 5 + Math.random() * 4, life: 0, ttl: 1.2 + Math.random(), + color: pick(), rise: true + }) + } + + ctx.clearRect(0, 0, w, h) + ctx.globalCompositeOperation = 'lighter' + + // Sunburst god-rays — one-shot bloom + slow spin. + if (raysAlive) { + rays.life += dt + rays.rot += dt * 0.6 + const t = rays.life / rays.ttl + if (t >= 1) { + raysAlive = false + } else { + const len = Math.max(w, h) * 0.62 * (1 - (1 - t) ** 2) + ctx.save() + ctx.translate(cx, cy) + ctx.rotate(rays.rot) + for (let i = 0; i < RAY_COUNT; i++) { + ctx.rotate((Math.PI * 2) / RAY_COUNT) + const a = (1 - t) * 0.3 * (i % 2 ? 0.65 : 1) + const wd = len * 0.05 + const g = ctx.createLinearGradient(0, 0, 0, -len) + g.addColorStop(0, `rgba(255,255,255,${a})`) + g.addColorStop(1, 'rgba(255,255,255,0)') + ctx.fillStyle = g + ctx.beginPath() + ctx.moveTo(-wd, 0) + ctx.lineTo(wd, 0) + ctx.lineTo(0, -len) + ctx.closePath() + ctx.fill() + } + ctx.restore() + } + } + + for (let i = stars.length - 1; i >= 0; i--) { + const s = stars[i] + s.life += dt + if (s.rise) { + s.vy += 7 * dt + s.phase += s.twinkle * dt + } else { + s.vx *= decay + s.vy = s.vy * decay + GRAVITY * dt + } + s.x += s.vx * dt + s.y += s.vy * dt + s.rot += s.vrot * dt + if (s.life >= s.ttl || s.y < -12) { + stars.splice(i, 1) + continue + } + const fade = s.rise + ? Math.min(1, s.life * 5, (s.ttl - s.life) * 3) * (0.45 + 0.55 * Math.abs(Math.sin(s.phase))) + : Math.min(1, (s.ttl - s.life) * 3) + ctx.save() + ctx.globalAlpha = fade + ctx.translate(Math.round(s.x), Math.round(s.y)) + sparkle(ctx, s.size, s.rot, s.color) + ctx.restore() + } + + ctx.globalCompositeOperation = 'source-over' + } + raf = requestAnimationFrame(tick) + + return () => { + cancelAnimationFrame(raf) + ro.disconnect() + } + }, []) + + return +} diff --git a/apps/desktop/src/components/pet/pixel-egg-sprite.tsx b/apps/desktop/src/components/pet/pixel-egg-sprite.tsx new file mode 100644 index 00000000000..7d3b6fa55a7 --- /dev/null +++ b/apps/desktop/src/components/pet/pixel-egg-sprite.tsx @@ -0,0 +1,234 @@ +import { type CSSProperties, useEffect, useRef } from 'react' + +import eggSheetUrl from './pet-egg-sheet.png' + +/** + * Animated pixel egg — the iamcrog "bouncing hatching egg" 12-frame sheet + * (32×32 cells, stacked vertically), drawn to a canvas and recolored to a warm + * white/creme shell. + * + * The sheet's shell is mid-gray, so a plain multiply only darkens it (still + * gray). Instead we remap each pixel's luminance through a creme ramp via a 256- + * entry LUT: near-black stays a warm dark outline, midtones become creme shadow, + * highlights go near-white. Done on a 32×32 offscreen then nearest-neighbor + * scaled up so it stays crisp. + * + * Frames 0–5 are the intact squash/stretch bounce; 6–11 are the crack/hatch. + * `mode="bounce"` loops 0–5 (never shows a crack); `mode="hatch"` plays 6–11 + * once then calls onDone. + */ + +const FRAME = 32 +const TOTAL_FRAMES = 12 +const BOUNCE_FRAMES = 6 // 0..5 — intact egg only; cracks start at frame 6 +const HATCH_START = 6 // first crack frame +// Per-frame speed *while* a bounce is playing. +const BOUNCE_MS = 250 +const HATCH_MS = 190 +// Harvest-Moon idle: the egg rests on frame 0 for a long, randomized gap between +// bounces so it reads as "occasionally stirs", not "constantly animating". +const REST_MIN_MS = 2600 +const REST_MAX_MS = 6200 + +// Creme ramp endpoints: warm dark outline → creme shadow → near-white highlight. +const OUTLINE: [number, number, number] = [78, 66, 58] +const SHADOW: [number, number, number] = [214, 198, 168] +const HIGHLIGHT: [number, number, number] = [253, 249, 238] +const OUTLINE_CUTOFF = 46 + +const lerp = (a: number, b: number, t: number) => a + (b - a) * t + +// Precompute the luminance→creme mapping once (shared across every egg). Below +// the cutoff it's the flat outline; above, a SHADOW→HIGHLIGHT ramp. +const CREME_LUT = (() => { + const lut = new Uint8ClampedArray(256 * 3) + for (let g = 0; g < 256; g++) { + const dark = g < OUTLINE_CUTOFF + const t = dark ? 0 : (g - OUTLINE_CUTOFF) / (255 - OUTLINE_CUTOFF) + const from = dark ? OUTLINE : SHADOW + const to = dark ? OUTLINE : HIGHLIGHT + lut.set([lerp(from[0], to[0], t), lerp(from[1], to[1], t), lerp(from[2], to[2], t)], g * 3) + } + return lut +})() + +let _sheet: HTMLImageElement | null = null +let _sheetLoading: Promise | null = null + +function loadSheet(): Promise { + if (_sheet?.complete) { + return Promise.resolve(_sheet) + } + if (!_sheetLoading) { + _sheetLoading = new Promise((resolve, reject) => { + const img = new Image() + img.onload = () => { + _sheet = img + resolve(img) + } + img.onerror = reject + img.src = eggSheetUrl + }) + } + return _sheetLoading +} + +interface PixelEggSpriteProps { + mode: 'bounce' | 'hatch' + /** On-screen size (px, square). */ + size: number + /** + * Slot position in a grid of eggs. Used to deterministically spread each egg's + * first bounce across the rest window so neighbours never stir together (random + * jitter alone can collide with only a handful of eggs). + */ + index?: number + className?: string + style?: CSSProperties + /** Fired once when a `hatch` run reaches the final frame. */ + onDone?: () => void +} + +export function PixelEggSprite({ mode, size, index = 0, className, style, onDone }: PixelEggSpriteProps) { + const canvasRef = useRef(null) + const onDoneRef = useRef(onDone) + onDoneRef.current = onDone + + useEffect(() => { + const canvas = canvasRef.current + const ctx = canvas?.getContext('2d') + if (!canvas || !ctx) { + return + } + + const dpr = Math.min(window.devicePixelRatio || 1, 3) + const dim = Math.round(size * dpr) + canvas.width = dim + canvas.height = dim + + const lastFrame = TOTAL_FRAMES - 1 + // Mild per-egg speed jitter so bounces don't feel mechanical. + const frameMs = (mode === 'bounce' ? BOUNCE_MS : HATCH_MS) * (0.85 + Math.random() * 0.3) + const restMs = () => REST_MIN_MS + Math.random() * (REST_MAX_MS - REST_MIN_MS) + // First bounce: a deterministic per-slot slice of the rest window (so two + // eggs never start together) plus a little random jitter on top. + const firstDelay = ((index % 4) + 1) * (REST_MIN_MS / 4) + Math.random() * REST_MIN_MS + + // 32×32 offscreen we recolor per frame, then scale up nearest-neighbor. + const off = document.createElement('canvas') + off.width = FRAME + off.height = FRAME + const offCtx = off.getContext('2d', { willReadFrequently: true }) + + let sheet: HTMLImageElement | null = null + void loadSheet().then(img => { + sheet = img + }) + + const render = (frame: number) => { + if (!sheet || !offCtx) { + return + } + offCtx.clearRect(0, 0, FRAME, FRAME) + offCtx.imageSmoothingEnabled = false + offCtx.drawImage(sheet, 0, frame * FRAME, FRAME, FRAME, 0, 0, FRAME, FRAME) + const img = offCtx.getImageData(0, 0, FRAME, FRAME) + const d = img.data + for (let i = 0; i < d.length; i += 4) { + if (d[i + 3] === 0) { + continue + } + const g = d[i] * 3 + d[i] = CREME_LUT[g] + d[i + 1] = CREME_LUT[g + 1] + d[i + 2] = CREME_LUT[g + 2] + } + offCtx.putImageData(img, 0, 0) + + ctx.clearRect(0, 0, dim, dim) + ctx.imageSmoothingEnabled = false + ctx.drawImage(off, 0, 0, FRAME, FRAME, 0, 0, dim, dim) + } + + let raf = 0 + let step = 0 + let finished = false + // bounce: `nextAt` is when the next thing happens — the next bounce frame, or + // the start of a new bounce after a rest. hatch: `lastHatch` time-gates frames. + let resting = mode === 'bounce' + let nextAt = 0 + let lastHatch = 0 + + const tick = (now: number) => { + raf = requestAnimationFrame(tick) + if (!sheet) { + return + } + + if (mode === 'hatch') { + if (!lastHatch) { + lastHatch = now + render(HATCH_START) + return + } + if (now - lastHatch < frameMs) { + return + } + lastHatch = now + const frame = Math.min(HATCH_START + step, lastFrame) + render(frame) + if (frame >= lastFrame) { + if (!finished) { + finished = true + onDoneRef.current?.() + } + return // hold the cracked-open last frame + } + step += 1 + return + } + + // bounce: rest on frame 0, play 0..5, then rest again. + if (!nextAt) { + render(0) + nextAt = now + firstDelay // staggered first bounce, per slot + return + } + if (now < nextAt) { + return + } + + if (resting) { + resting = false + step = 0 + render(0) + nextAt = now + frameMs + return + } + + step += 1 + if (step >= BOUNCE_FRAMES) { + resting = true + render(0) + nextAt = now + restMs() + return + } + render(step) + nextAt = now + frameMs + } + + raf = requestAnimationFrame(tick) + + return () => { + cancelAnimationFrame(raf) + } + }, [mode, size, index]) + + return ( + + ) +} diff --git a/apps/desktop/src/components/ui/generate-button.tsx b/apps/desktop/src/components/ui/generate-button.tsx new file mode 100644 index 00000000000..80cb19172a3 --- /dev/null +++ b/apps/desktop/src/components/ui/generate-button.tsx @@ -0,0 +1,62 @@ +import type * as React from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Tip } from '@/components/ui/tooltip' +import { Square } from '@/lib/icons' +import { cn } from '@/lib/utils' + +interface GenerateButtonProps extends Omit, 'children' | 'onClick'> { + /** True while a generation is in flight. */ + generating: boolean + /** Start a generation. */ + onGenerate: () => void + /** Cancel an in-flight generation. When omitted, the button just spins while + * generating (for one-shots that can't be cancelled). */ + onCancel?: () => void + /** Tooltip + aria label at rest (and while generating if no `generatingLabel`). */ + label: string + /** Tooltip while generating (e.g. "Stop" with cancel, "Generating…" without). */ + generatingLabel?: string + iconSize?: number | string +} + +/** The sparkle "generate with AI" affordance — icon + tooltip, shared by the + * commit-message box and the new-project idea field so they stay one pattern. + * Sparkle → click generates; with `onCancel`, a Stop square appears mid-run; + * without it, the sparkle spins until the one-shot resolves. */ +export function GenerateButton({ + generating, + onGenerate, + onCancel, + label, + generatingLabel, + disabled, + iconSize = 12, + className, + ...rest +}: GenerateButtonProps) { + const tip = generating ? (generatingLabel ?? label) : label + const cancellable = generating && !!onCancel + + return ( + + + + ) +} diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 8a1a295ce92..fab233cd7ff 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -389,11 +389,23 @@ export const en: Translations = { unreachable: "Couldn't reach the petdex gallery. Check your connection and reopen this page.", noMatch: query => `No pets match "${query}".`, installedTag: 'installed', + generatedTag: 'Generated', countCapped: (cap, total) => `Showing ${cap} of ${total} — type to narrow it down.`, count: n => `${n} pet${n === 1 ? '' : 's'}.`, uninstall: name => `Uninstall ${name}`, + delete: name => `Delete ${name}`, + deleteTitle: name => `Delete ${name}?`, + deleteBody: "This permanently deletes the pet — it can't be reinstalled.", + deleteConfirm: 'Delete', + rename: name => `Rename ${name}`, + renameTitle: 'Rename pet', + renamePlaceholder: 'Name your pet', + renameSave: 'Save', + exportPet: name => `Export ${name}`, adoptFailed: slug => `Could not adopt ${slug}`, uninstallFailed: slug => `Could not uninstall ${slug}`, + renameFailed: slug => `Could not rename ${slug}`, + exportFailed: slug => `Could not export ${slug}`, noneAvailable: 'No pets available to turn on right now.', turnOnFailed: 'Could not turn the pet on.', turnOffFailed: 'Could not turn the pet off.' @@ -760,10 +772,32 @@ export const en: Translations = { turnOff: 'Turn off', turnOn: 'Turn on', installed: 'Installed', + generatedTag: 'Generated', adoptFailed: 'Could not adopt that pet.', toggleFailed: 'Could not toggle the pet.', noneAvailable: 'No pets available — pick one below to install.' }, + generatePet: { + title: 'Generate a pet', + placeholder: 'Describe a pet to generate…', + promptHint: 'Type a description, then press Enter to draft four looks.', + readyHint: 'Press Enter to draft four looks from your description.', + generate: 'Generate', + generating: 'Generating…', + retry: 'Retry', + hatch: 'Hatch', + spawning: 'Spawning…', + hatching: 'Hatching your pet…', + hatchingSub: 'Bringing every frame to life — this takes a moment.', + hatched: 'It hatched!', + hatchRow: (state, done, total) => `Drawing ${state}… ${done}/${total}`, + hatchComposing: 'Composing the spritesheet…', + hatchSaving: 'Saving your pet…', + namePlaceholder: 'Name your pet', + staleBackend: 'Update Hermes to generate pets.', + adopt: 'Adopt', + startOver: 'Start over' + }, installTheme: { title: 'Install theme...', placeholder: 'Search the VS Code Marketplace...', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index ad1bf090657..e1c748c5ee6 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -304,11 +304,23 @@ export const ja = defineLocale({ unreachable: 'petdex ギャラリーに接続できませんでした。接続を確認してこのページを開き直してください。', noMatch: query => `「${query}」に一致するペットがありません。`, installedTag: 'インストール済み', + generatedTag: '生成', countCapped: (cap, total) => `${total} 件中 ${cap} 件を表示中——入力して絞り込めます。`, count: n => `${n} 件のペット。`, uninstall: name => `${name} をアンインストール`, + delete: name => `${name} を削除`, + deleteTitle: name => `${name} を削除しますか?`, + deleteBody: 'ペットを完全に削除します。再インストールはできません。', + deleteConfirm: '削除', + rename: name => `${name} の名前を変更`, + renameTitle: 'ペットの名前を変更', + renamePlaceholder: 'ペットに名前を付ける', + renameSave: '保存', + exportPet: name => `${name} をエクスポート`, adoptFailed: slug => `${slug} を採用できませんでした`, uninstallFailed: slug => `${slug} をアンインストールできませんでした`, + renameFailed: slug => `${slug} の名前を変更できませんでした`, + exportFailed: slug => `${slug} をエクスポートできませんでした`, noneAvailable: 'オンにできるペットがありません。', turnOnFailed: 'ペットをオンにできませんでした。', turnOffFailed: 'ペットをオフにできませんでした。' @@ -880,10 +892,32 @@ export const ja = defineLocale({ turnOff: 'オフ', turnOn: 'オン', installed: 'インストール済み', + generatedTag: '生成', adoptFailed: 'ペットを採用できませんでした。', toggleFailed: 'ペットを切り替えできませんでした。', noneAvailable: '利用可能なペットがありません。' }, + generatePet: { + title: 'ペットを生成', + placeholder: '生成するペットを説明…', + promptHint: '説明を入力して Enter を押すと、4 つの見た目を生成します。', + readyHint: 'Enter を押すと、説明から 4 つの見た目を生成します。', + generate: '生成', + generating: '生成中…', + retry: '再試行', + hatch: '孵化', + spawning: 'スポーン中…', + hatching: 'ペットを孵化しています…', + hatchingSub: 'すべてのフレームに命を吹き込んでいます。少々お待ちください。', + hatched: '孵化しました!', + hatchRow: (state, done, total) => `${state} を描画中… ${done}/${total}`, + hatchComposing: 'スプライトシートを合成中…', + hatchSaving: 'ペットを保存中…', + namePlaceholder: 'ペットに名前を付ける', + staleBackend: 'ペットを生成するには Hermes を更新してください。', + adopt: '迎え入れる', + startOver: 'やり直す' + }, installTheme: { title: 'テーマをインストール...', placeholder: 'VS Code Marketplace を検索...', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 411c7d5847f..9d1e213b97d 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -284,11 +284,23 @@ export interface Translations { unreachable: string noMatch: (query: string) => string installedTag: string + generatedTag: string countCapped: (cap: number, total: number) => string count: (n: number) => string uninstall: (name: string) => string + delete: (name: string) => string + deleteTitle: (name: string) => string + deleteBody: string + deleteConfirm: string + rename: (name: string) => string + renameTitle: string + renamePlaceholder: string + renameSave: string + exportPet: (name: string) => string adoptFailed: (slug: string) => string uninstallFailed: (slug: string) => string + renameFailed: (slug: string) => string + exportFailed: (slug: string) => string noneAvailable: string turnOnFailed: string turnOffFailed: string @@ -635,10 +647,32 @@ export interface Translations { turnOff: string turnOn: string installed: string + generatedTag: string adoptFailed: string toggleFailed: string noneAvailable: string } + generatePet: { + title: string + placeholder: string + promptHint: string + readyHint: string + generate: string + generating: string + retry: string + hatch: string + spawning: string + hatching: string + hatchingSub: string + hatched: string + hatchRow: (state: string, done: number, total: number) => string + hatchComposing: string + hatchSaving: string + namePlaceholder: string + staleBackend: string + adopt: string + startOver: string + } installTheme: { title: string placeholder: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index d5500570906..eb6e2ff7ead 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -291,11 +291,23 @@ export const zhHant = defineLocale({ unreachable: '無法連線至 petdex 畫廊。請檢查網路連線並重新開啟此頁面。', noMatch: query => `沒有符合「${query}」的寵物。`, installedTag: '已安裝', + generatedTag: '生成', countCapped: (cap, total) => `顯示 ${total} 個中的 ${cap} 個——輸入關鍵字以縮小範圍。`, count: n => `${n} 個寵物。`, uninstall: name => `解除安裝 ${name}`, + delete: name => `刪除 ${name}`, + deleteTitle: name => `刪除 ${name}?`, + deleteBody: '此操作會永久刪除寵物,且無法重新安裝。', + deleteConfirm: '刪除', + rename: name => `重新命名 ${name}`, + renameTitle: '重新命名寵物', + renamePlaceholder: '為寵物取個名字', + renameSave: '儲存', + exportPet: name => `匯出 ${name}`, adoptFailed: slug => `無法領養 ${slug}`, uninstallFailed: slug => `無法解除安裝 ${slug}`, + renameFailed: slug => `無法重新命名 ${slug}`, + exportFailed: slug => `無法匯出 ${slug}`, noneAvailable: '目前沒有可開啟的寵物。', turnOnFailed: '無法開啟寵物。', turnOffFailed: '無法關閉寵物。' @@ -850,10 +862,32 @@ export const zhHant = defineLocale({ turnOff: '關閉', turnOn: '開啟', installed: '已安裝', + generatedTag: '生成', adoptFailed: '無法領養該寵物。', toggleFailed: '無法切換寵物顯示。', noneAvailable: '尚無可用寵物——請在下方選擇一個安裝。' }, + generatePet: { + title: '生成寵物', + placeholder: '描述要生成的寵物……', + promptHint: '輸入描述,然後按 Enter 生成四種造型。', + readyHint: '按 Enter 依描述生成四種造型。', + generate: '生成', + generating: '生成中……', + retry: '重試', + hatch: '孵化', + spawning: '召喚中……', + hatching: '正在孵化你的寵物……', + hatchingSub: '正在為每一格注入生命——請稍候。', + hatched: '孵化成功!', + hatchRow: (state, done, total) => `正在繪製 ${state}…… ${done}/${total}`, + hatchComposing: '正在合成精靈表……', + hatchSaving: '正在儲存你的寵物……', + namePlaceholder: '為寵物命名', + staleBackend: '請更新 Hermes 以生成寵物。', + adopt: '領養', + startOver: '重新開始' + }, installTheme: { title: '安裝主題...', placeholder: '搜尋 VS Code Marketplace...', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 6423e1749a9..effbaf328f8 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -379,11 +379,23 @@ export const zh: Translations = { unreachable: '无法连接到 petdex 画廊。请检查网络连接并重新打开此页面。', noMatch: query => `没有匹配「${query}」的宠物。`, installedTag: '已安装', + generatedTag: '生成', countCapped: (cap, total) => `显示 ${total} 个中的 ${cap} 个——输入关键词以缩小范围。`, count: n => `${n} 个宠物。`, uninstall: name => `卸载 ${name}`, + delete: name => `删除 ${name}`, + deleteTitle: name => `删除 ${name}?`, + deleteBody: '此操作会永久删除宠物,且无法重新安装。', + deleteConfirm: '删除', + rename: name => `重命名 ${name}`, + renameTitle: '重命名宠物', + renamePlaceholder: '给宠物起个名字', + renameSave: '保存', + exportPet: name => `导出 ${name}`, adoptFailed: slug => `无法领养 ${slug}`, uninstallFailed: slug => `无法卸载 ${slug}`, + renameFailed: slug => `无法重命名 ${slug}`, + exportFailed: slug => `无法导出 ${slug}`, noneAvailable: '当前没有可开启的宠物。', turnOnFailed: '无法开启宠物。', turnOffFailed: '无法关闭宠物。' @@ -947,10 +959,32 @@ export const zh: Translations = { turnOff: '关闭', turnOn: '开启', installed: '已安装', + generatedTag: '生成', adoptFailed: '无法领养该宠物。', toggleFailed: '无法切换宠物显示。', noneAvailable: '暂无可用宠物——请在下方选择一个安装。' }, + generatePet: { + title: '生成宠物', + placeholder: '描述要生成的宠物……', + promptHint: '输入描述,然后按 Enter 生成四种造型。', + readyHint: '按 Enter 根据描述生成四种造型。', + generate: '生成', + generating: '生成中……', + retry: '重试', + hatch: '孵化', + spawning: '召唤中……', + hatching: '正在孵化你的宠物……', + hatchingSub: '正在为每一帧注入生命——请稍候。', + hatched: '孵化成功!', + hatchRow: (state, done, total) => `正在绘制 ${state}…… ${done}/${total}`, + hatchComposing: '正在合成精灵表……', + hatchSaving: '正在保存你的宠物……', + namePlaceholder: '给宠物起个名字', + staleBackend: '请更新 Hermes 以生成宠物。', + adopt: '领养', + startOver: '重新开始' + }, installTheme: { title: '安装主题...', placeholder: '搜索 VS Code Marketplace...', diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index 9e07f529ce6..8e052bd76fc 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -29,6 +29,7 @@ import { IconCopy as CopyIcon, IconCpu as Cpu, IconDownload as Download, + IconEgg as Egg, IconExternalLink as ExternalLink, IconEye as Eye, IconEyeOff as EyeOff, @@ -133,6 +134,7 @@ export { CopyIcon, Cpu, Download, + Egg, ExternalLink, Eye, EyeOff, diff --git a/apps/desktop/src/store/pet-gallery.ts b/apps/desktop/src/store/pet-gallery.ts index a9a23734b8b..d5aa9ea7d52 100644 --- a/apps/desktop/src/store/pet-gallery.ts +++ b/apps/desktop/src/store/pet-gallery.ts @@ -27,6 +27,8 @@ export interface GalleryPet { spritesheetUrl?: string /** petdex's hand-picked set — used only to rank "popular" pets first. */ curated?: boolean + /** Hatched locally by the user (createdBy=generator) — badged + ranked first. */ + generated?: boolean } export interface PetGallery { @@ -39,7 +41,12 @@ export type PetGalleryStatus = 'idle' | 'loading' | 'ready' | 'stale' | 'error' /** The recovering `requestGateway` from `useGatewayRequest` — passed in so the * store reuses the hook's reconnect/reauth handling instead of duplicating it. */ -export type GatewayRequest = (method: string, params?: Record) => Promise +export type GatewayRequest = ( + method: string, + params?: Record, + timeoutMs?: number, + signal?: AbortSignal +) => Promise /** Profile-scoped pet RPC. Pets are per-profile, so every call carries the active * profile (the gateway no-ops it for the launch profile). One chokepoint so no @@ -115,16 +122,21 @@ export function loadPetGallery(request: GatewayRequest, options: { force?: boole $petGalleryStatus.set('loading') } + let localOk = false + try { - const [next, info] = await Promise.all([ - petRpc(request, 'pet.gallery'), + // Phase 1: local pets only — instant, never blocks on the remote petdex + // manifest. The user's own/generated pets render right away. + const [local, info] = await Promise.all([ + petRpc(request, 'pet.gallery', { localOnly: true }), petRpc(request, 'pet.info') ]) - if (next) { - $petGallery.set(next) + if (local) { + $petGallery.set(local) $petGalleryStatus.set('ready') $petGalleryError.set(null) + localOk = true } if (info) { @@ -142,6 +154,21 @@ export function loadPetGallery(request: GatewayRequest, options: { force?: boole } finally { galleryLoad = null } + + // Phase 2: merge in the full petdex catalog in the background. A slow/failed + // manifest fetch never hides the local pets shown in phase 1. + if (localOk) { + try { + const full = await petRpc(request, 'pet.gallery') + + if (full) { + $petGallery.set(full) + $petGalleryStatus.set('ready') + } + } catch { + // Keep the local-only gallery; the petdex catalog just stays unmerged. + } + } })() return galleryLoad @@ -161,6 +188,24 @@ async function syncInfo(request: GatewayRequest): Promise { } } +/** + * Reflect a just-adopted *local* pet without any network: optimistically mark it + * active/installed in the cached gallery and repaint the live mascot via the + * local `pet.info`. Adopting a generated pet is a disk+config op — it must never + * wait on `pet.gallery`'s remote petdex manifest fetch. + */ +export async function applyAdoptedPet(request: GatewayRequest, slug: string, displayName: string): Promise { + patchGallery(gallery => ({ + ...gallery, + enabled: true, + active: slug, + pets: gallery.pets.some(p => p.slug === slug) + ? gallery.pets.map(p => (p.slug === slug ? { ...p, installed: true, displayName } : p)) + : [...gallery.pets, { slug, displayName, installed: true, spritesheetUrl: '' }] + })) + await syncInfo(request) +} + /** * Filter (drop the internal `clawd*` pets + apply a search query) and rank the * gallery for a picker. Ranking has no popularity data, so it leans on the @@ -175,8 +220,15 @@ export function rankedGalleryPets(gallery: PetGallery | null, query = ''): Galle const needle = query.trim().toLowerCase() + // User-generated pets first, then the active pet, then installed, then curated. + // Guard every term with a boolean — local-only pets omit curated/generated, and + // `Number(undefined)` is NaN, which poisons the sort (it would sink those pets + // below the render cap and hide them entirely). const rank = (p: GalleryPet) => - Number(gallery.enabled && p.slug === gallery.active) * 4 + Number(p.installed) * 2 + Number(p.curated) + (p.generated ? 8 : 0) + + (gallery.enabled && p.slug === gallery.active ? 4 : 0) + + (p.installed ? 2 : 0) + + (p.curated ? 1 : 0) return gallery.pets .filter( @@ -309,14 +361,111 @@ export function setPetScale(request: GatewayRequest, scale: number): void { }, 200) } +/** Export a pet as a `.zip` (pet.json + spritesheet) and save it via the browser. */ +export async function exportPet(request: GatewayRequest, slug: string, fallback: string): Promise { + $petBusy.set(slug) + $petGalleryError.set(null) + try { + const res = await petRpc<{ ok: boolean; filename: string; zipBase64: string }>(request, 'pet.export', { slug }) + if (!res?.ok || !res.zipBase64) { + throw new Error(fallback) + } + const bytes = Uint8Array.from(atob(res.zipBase64), c => c.charCodeAt(0)) + const url = URL.createObjectURL(new Blob([bytes], { type: 'application/zip' })) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = res.filename || `${slug}.zip` + anchor.click() + URL.revokeObjectURL(url) + return true + } catch (e) { + $petGalleryError.set(e instanceof Error ? e.message : fallback) + return false + } finally { + $petBusy.set(null) + } +} + +/** + * Rename a pet — optimistic. The new name shows instantly (so the dialog can + * close immediately); the RPC runs in the background and the backend also + * realigns the slug/dir, so we reconcile the slug + thumb cache when it returns, + * and roll the name back if it fails. + */ +export function renamePet(request: GatewayRequest, slug: string, name: string, fallback: string): Promise { + const trimmed = name.trim() + + if (!trimmed) { + return Promise.resolve(false) + } + + const prev = $petGallery.get()?.pets.find(p => p.slug === slug)?.displayName ?? '' + + // Optimistic: paint the new name now (slug reconciles when the RPC returns). + patchGallery(g => ({ + ...g, + pets: g.pets.map(p => (p.slug === slug ? { ...p, displayName: trimmed } : p)) + })) + $petGalleryError.set(null) + + return (async () => { + try { + const res = await petRpc<{ ok: boolean; slug: string; displayName: string }>(request, 'pet.rename', { + slug, + name: trimmed + }) + + if (!res?.ok) { + throw new Error(fallback) + } + + const newSlug = res.slug || slug + + if (newSlug !== slug) { + thumbCache.delete(slug) + patchGallery(g => ({ + ...g, + active: g.active === slug ? newSlug : g.active, + pets: g.pets + .filter(p => p.slug !== newSlug || p.slug === slug) + .map(p => (p.slug === slug ? { ...p, slug: newSlug, displayName: res.displayName || trimmed } : p)) + })) + } + + return true + } catch (e) { + // Roll the optimistic name back so the list reflects on-disk truth. + patchGallery(g => ({ + ...g, + pets: g.pets.map(p => (p.slug === slug ? { ...p, displayName: prev } : p)) + })) + $petGalleryError.set(e instanceof Error ? e.message : fallback) + + return false + } + })() +} + /** Uninstall a pet; turns the mascot off if it was the active one. */ export function removePet(request: GatewayRequest, slug: string, fallback: string): Promise { return mutate(slug, fallback, request, async () => { await petRpc(request, 'pet.remove', { slug }) + // Evict the by-slug thumb cache so a reused slug doesn't render this pet's + // stale thumbnail (the backend drops its disk thumb in parallel). + thumbCache.delete(slug) patchGallery(g => ({ ...g, enabled: g.active === slug ? false : g.enabled, - pets: g.pets.map(p => (p.slug === slug ? { ...p, installed: false } : p)) + active: g.active === slug ? '' : g.active, + // Petdex pets can be reinstalled from the manifest, so we just mark them + // uninstalled. Generated / local-only pets have no remote source — once + // deleted they're gone, so drop them from the list entirely. + pets: g.pets.flatMap(p => { + if (p.slug !== slug) { + return [p] + } + return p.generated || !p.spritesheetUrl ? [] : [{ ...p, installed: false }] + }) })) }) } diff --git a/apps/desktop/src/store/pet-generate.ts b/apps/desktop/src/store/pet-generate.ts new file mode 100644 index 00000000000..29efe3d5f0e --- /dev/null +++ b/apps/desktop/src/store/pet-generate.ts @@ -0,0 +1,527 @@ +import { atom } from 'nanostores' + +import { $gateway } from '@/store/gateway' +import { type PetInfo } from '@/store/pet' +import { type GatewayRequest, applyAdoptedPet } from '@/store/pet-gallery' + +/** + * Feature store for the "generate a pet" flow (Cmd-K → Pets → Generate). + * + * Three backend steps, mirrored as state here: + * - `pet.generate` produces N cheap base-look *drafts* keyed by a `token`. + * - `pet.hatch` turns the chosen draft into a full animated pet — installed but + * NOT active — and returns its renderer payload so we can preview all frames. + * - the user then *adopts* (`pet.select`) or *discards* (`pet.remove`) it. + * + * The store owns the draft set, the selected variant, the hatched preview, and + * the busy/error status so the page is a thin view. Retry == regenerate (new + * token). Kept separate from `pet-gallery` because its lifecycle (ephemeral + * drafts + an unadopted preview) is unrelated to the long-lived gallery cache. + */ + +// Generation is many grounded image calls — far longer than the default 30s RPC +// timeout. Drafts fan out 4 base looks; hatch fans out ~8 animation rows. Even +// parallelized, a cold provider call is slow, so we give these calls real +// headroom (the bug was "request timed out: pet.generate" on the 30s default). +const GENERATE_TIMEOUT_MS = 240_000 +const HATCH_TIMEOUT_MS = 420_000 + +// Filler words to drop when deriving a default name from a free-text prompt. +const NAME_STOPWORDS = new Set([ + 'a', + 'an', + 'and', + 'at', + 'by', + 'cute', + 'for', + 'from', + 'in', + 'of', + 'on', + 'style', + 'the', + 'to', + 'with' +]) + +/** + * Derive a short, friendly default name from a generation prompt. The prompt + * (e.g. "2d dragon in the style of ragnarok online") is grounding text, not a + * name — using it verbatim makes a terrible label + slug. We keep the first few + * meaningful words, title-cased and capped, so a blank adopt still reads well. + * The user can always override on the reveal screen or rename later. + */ +export function cleanPetName(prompt: string): string { + const words = prompt + .replace(/[^\p{L}\p{N}\s-]/gu, ' ') + .split(/\s+/) + .filter(Boolean) + const meaningful = words.filter(w => !NAME_STOPWORDS.has(w.toLowerCase())) + const picked = (meaningful.length ? meaningful : words).slice(0, 3) + const name = picked + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' ') + .slice(0, 28) + .trim() + + return name || 'Pet' +} + +export interface PetDraft { + index: number + /** Downscaled PNG data URI preview from the gateway. */ + dataUri: string +} + +export type PetGenStatus = + | 'idle' + | 'generating' + | 'ready' + | 'hatching' + | 'preview' + | 'adopting' + | 'error' + | 'stale' + +/** Live hatch step for the egg screen — which row is being drawn, then compose/save. */ +export interface PetHatchStage { + phase: 'row' | 'compose' | 'save' + state?: string + done?: number + total?: number +} + +export const $petGenStatus = atom('idle') +export const $petGenStage = atom(null) +export const $petGenError = atom(null) + +/** Whether the dedicated "Generate a pet" Pokédex overlay is open. */ +export const $petGenerateOpen = atom(false) + +export function openPetGenerate(): void { + // Always open on a clean slate — don't resurface the last run's drafts/preview. + resetPetGen() + $petGenerateOpen.set(true) +} + +export function closePetGenerate(): void { + $petGenerateOpen.set(false) +} +export const $petGenToken = atom(null) +/** Prompt that produced the current draft token; hatch uses this for consistency. */ +export const $petGenPrompt = atom('') +export const $petGenDrafts = atom([]) +export const $petGenSelected = atom(null) +/** The hatched-but-unadopted pet: its renderer payload, played in the preview. */ +export const $petGenPreview = atom(null) + +function isMissingMethod(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + + return /method not found|-32601|unknown method|no such method/i.test(message) +} + +/** Clear all generation state (on close, or before a fresh run). */ +export function resetPetGen(): void { + $petGenStatus.set('idle') + $petGenStage.set(null) + $petGenError.set(null) + $petGenToken.set(null) + $petGenPrompt.set('') + $petGenDrafts.set([]) + $petGenSelected.set(null) + $petGenPreview.set(null) +} + +/** + * Reset on palette close, deleting an unadopted preview pet first so a hatched- + * but-never-adopted creature doesn't linger in the gallery. Fire-and-forget. + */ +export function cleanupPetGen(request: GatewayRequest): void { + const preview = $petGenPreview.get() + + if ($petGenStatus.get() === 'preview' && preview?.slug) { + void request('pet.remove', { slug: preview.slug }).catch(() => {}) + } + + resetPetGen() +} + +interface GenerateOptions { + prompt: string + style?: string + count?: number +} + +// A Stop (or a fresh round) must invalidate the in-flight call. This primitive +// pairs a monotonic run id with the current run's cancel fn; `begin` opens a +// run, `isCurrent` gates stale callbacks/events, `arm` registers the aborter, +// `stop` supersedes + fires it. Drives both the draft and hatch flows. +interface Run { + begin: () => number + isCurrent: (id: number) => boolean + arm: (cancel: () => void) => void + stop: () => void + disarmIf: (id: number) => void +} + +function cancelableRun(): Run { + let id = 0 + let cancel: (() => void) | null = null + return { + begin: () => (id += 1), + isCurrent: n => n === id, + arm: fn => { + cancel = fn + }, + stop: () => { + id += 1 + cancel?.() + cancel = null + }, + disarmIf: n => { + if (n === id) { + cancel = null + } + } + } +} + +const gen = cancelableRun() + +/** + * Stop the in-flight draft generation (real abort). If any drafts have already + * streamed in, keep them and drop into the ready/picker state (no reason to wait + * for all 4) — otherwise reset to idle. + */ +export function cancelGenerate(): void { + gen.stop() + $petGenError.set(null) + + const drafts = $petGenDrafts.get() + if (drafts.length > 0) { + if ($petGenSelected.get() === null) { + $petGenSelected.set(drafts[0]?.index ?? 0) + } + $petGenStatus.set('ready') + return + } + + $petGenStatus.set('idle') + $petGenDrafts.set([]) + $petGenSelected.set(null) + $petGenToken.set(null) +} + +const hatch = cancelableRun() + +// A Stop invalidates the in-flight hatch and drops back to the draft picker (the +// server still finishes, so we delete the pet it created). +/** Stop the in-flight hatch and return to the draft picker. */ +export function cancelHatch(): void { + hatch.stop() + $petGenStage.set(null) + $petGenError.set(null) + $petGenStatus.set($petGenDrafts.get().length > 0 ? 'ready' : 'idle') +} + +/** Generate (or retry) a fresh set of base-look drafts for `prompt`. */ +export async function generateDrafts(request: GatewayRequest, options: GenerateOptions): Promise { + const prompt = options.prompt.trim() + + if (!prompt) { + return false + } + + const runId = gen.begin() + const controller = new AbortController() + gen.arm(() => { + controller.abort() + const token = $petGenToken.get() + if (token) { + void request('pet.cancel', { token }).catch(() => {}) + } + }) + + // Starting a fresh generation round supersedes any unadopted preview pet. + const preview = $petGenPreview.get() + if (preview?.slug) { + await request('pet.remove', { slug: preview.slug }).catch(() => {}) + } + + $petGenStatus.set('generating') + $petGenError.set(null) + $petGenPreview.set(null) + $petGenDrafts.set([]) + $petGenSelected.set(null) + + // Stream drafts in as the backend finishes each one (pet.generate.progress), + // so the grid fills live instead of sitting on placeholders until all N land. + const off = + $gateway.get()?.on('pet.generate.progress', event => { + const draft = event.payload + + // Token-only init event (no draft yet): learn the token immediately so an + // early Stop can still tell the backend to cancel this run. + if (draft?.token && !draft.dataUri) { + if (gen.isCurrent(runId) && $petGenStatus.get() === 'generating') { + $petGenToken.set(draft.token) + } + return + } + + if (!draft?.dataUri || typeof draft.index !== 'number') { + return + } + + // Ignore events from a superseded/stopped run, and only stream while live. + if (!gen.isCurrent(runId) || $petGenStatus.get() !== 'generating') { + return + } + + // Capture the token from the stream so a Stop can still hatch the partial set. + if (draft.token) { + $petGenToken.set(draft.token) + } + + const current = $petGenDrafts.get() + if (current.some(d => d.index === draft.index)) { + return + } + + $petGenDrafts.set( + [...current, { index: draft.index, dataUri: draft.dataUri }].sort((a, b) => a.index - b.index) + ) + }) ?? (() => {}) + + try { + const result = await request<{ ok: boolean; token: string; drafts: PetDraft[] }>( + 'pet.generate', + { + prompt, + style: options.style ?? 'auto', + count: options.count ?? 4 + }, + GENERATE_TIMEOUT_MS, + controller.signal + ) + + // Stopped (or superseded by a newer round) while the RPC was in flight. + if (!gen.isCurrent(runId)) { + return false + } + + if (!result?.ok || !result.drafts?.length) { + throw new Error('generation produced no drafts') + } + + $petGenToken.set(result.token) + $petGenPrompt.set(prompt) + $petGenDrafts.set(result.drafts) + $petGenSelected.set(result.drafts[0]?.index ?? 0) + $petGenStatus.set('ready') + + return true + } catch (e) { + if (!gen.isCurrent(runId)) { + return false + } + + if (isMissingMethod(e)) { + $petGenStatus.set('stale') + } else { + $petGenStatus.set('error') + $petGenError.set(e instanceof Error ? e.message : 'Could not generate pet drafts.') + } + + return false + } finally { + off() + gen.disarmIf(runId) + } +} + +interface HatchOptions { + name: string + description?: string + prompt?: string + style?: string +} + +/** + * Hatch the selected draft into a full pet (installed but NOT yet active) and + * load its renderer payload into the preview. Adoption is a separate, explicit + * step (`adoptHatched`) so the user sees every frame play before committing. + * Returns true when the preview is ready. + */ +export async function hatchSelected(request: GatewayRequest, options: HatchOptions): Promise { + const token = $petGenToken.get() + const index = $petGenSelected.get() + const name = options.name.trim() + const concept = ($petGenPrompt.get() || options.prompt || name).trim() + + if (token === null || index === null || !name) { + return false + } + + const hatchRunId = hatch.begin() + const controller = new AbortController() + hatch.arm(() => { + controller.abort() + void request('pet.cancel', { token }).catch(() => {}) + }) + + $petGenStatus.set('hatching') + $petGenStage.set(null) + $petGenError.set(null) + + // Stream the hatch steps (which row is drawing, then compose/save) to the egg + // screen so a multi-minute hatch shows live progress instead of a black box. + const offProgress = + $gateway + .get() + ?.on<{ event: string; state?: string; done?: string; total?: string }>('pet.hatch.progress', event => { + const p = event.payload + if (!p || !hatch.isCurrent(hatchRunId) || $petGenStatus.get() !== 'hatching') { + return + } + + if (p.event === 'row' && p.state) { + $petGenStage.set({ + phase: 'row', + state: p.state, + done: Number(p.done) || undefined, + total: Number(p.total) || undefined + }) + } else if (p.event === 'compose') { + $petGenStage.set({ phase: 'compose' }) + } else if (p.event === 'save') { + $petGenStage.set({ phase: 'save' }) + } + }) ?? (() => {}) + + try { + const result = await request<{ ok: boolean; slug: string; displayName: string; pet?: PetInfo }>( + 'pet.hatch', + { + token, + index, + name, + description: options.description ?? '', + prompt: concept, + style: options.style ?? 'auto' + }, + HATCH_TIMEOUT_MS, + controller.signal + ) + + // Stopped mid-hatch: the server created the pet anyway, so delete it. + if (!hatch.isCurrent(hatchRunId)) { + if (result?.slug) { + void request('pet.remove', { slug: result.slug }).catch(() => {}) + } + return false + } + + if (!result?.ok || !result.pet?.spritesheetBase64) { + throw new Error('hatch produced no preview') + } + + $petGenPreview.set({ ...result.pet, enabled: true }) + $petGenStatus.set('preview') + + return true + } catch (e) { + if (!hatch.isCurrent(hatchRunId)) { + return false + } + + $petGenStatus.set('error') + $petGenError.set(e instanceof Error ? e.message : 'Could not hatch the pet.') + + return false + } finally { + offProgress() + if (hatch.isCurrent(hatchRunId)) { + $petGenStage.set(null) + hatch.disarmIf(hatchRunId) + } + } +} + +export interface AdoptOutcome { + ok: boolean + slug?: string + displayName?: string +} + +/** + * Adopt the previewed pet: optionally rename it to the user's chosen name (set + * on the reveal screen), activate it (`pet.select`), refresh the gallery + live + * mascot, and clear generation state. No-op unless a preview exists. + */ +export async function adoptHatched(request: GatewayRequest, name?: string): Promise { + const preview = $petGenPreview.get() + + if (!preview?.slug) { + return { ok: false } + } + + $petGenStatus.set('adopting') + $petGenError.set(null) + + try { + // Name is collected after hatch, so apply it before activating. The rename + // also realigns the slug to the chosen name (so lists show what the user + // typed, not the prompt), so adopt the *returned* slug. Best-effort: a + // rename failure shouldn't block adopting under the provisional slug. + const finalName = name?.trim() + let adoptSlug = preview.slug + if (finalName && finalName !== preview.displayName) { + const renamed = await request<{ ok: boolean; slug: string }>('pet.rename', { + slug: preview.slug, + name: finalName + }).catch(() => null) + if (renamed?.slug) { + adoptSlug = renamed.slug + } + } + + const result = await request<{ ok: boolean; slug: string; displayName: string }>('pet.select', { + slug: adoptSlug + }) + + if (!result?.ok) { + throw new Error('adopt failed') + } + + // pet.select already set the active mascot (disk + config). Reflect it + // locally — no remote petdex manifest fetch — and close immediately. + resetPetGen() + void applyAdoptedPet(request, result.slug, result.displayName) + + return { ok: true, slug: result.slug, displayName: result.displayName } + } catch (e) { + $petGenStatus.set('preview') + $petGenError.set(e instanceof Error ? e.message : 'Could not adopt the pet.') + + return { ok: false } + } +} + +/** + * Throw away the previewed pet (`pet.remove`) and return to the draft picker so + * the user can choose another base or regenerate. Best-effort on the delete. + */ +export async function discardHatched(request: GatewayRequest): Promise { + const preview = $petGenPreview.get() + + if (preview?.slug) { + await request('pet.remove', { slug: preview.slug }).catch(() => {}) + } + + $petGenPreview.set(null) + $petGenError.set(null) + $petGenStatus.set($petGenDrafts.get().length > 0 ? 'ready' : 'idle') +} diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 58221224fbd..bd584237eea 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -1426,3 +1426,235 @@ canvas { animation: none; } } + +/* -------------------------------------------------------------------------- */ +/* Pet egg hatch (Cmd-K → Pets → Generate) */ +/* The incubation wobble + reveal flash/pop give the draft→pet step a */ +/* Pokémon-style "egg is hatching" beat instead of a bare spinner. */ +/* -------------------------------------------------------------------------- */ + +.pet-egg { + position: relative; + width: 5.5rem; + height: 7rem; + border-radius: 50% 50% 50% 50% / 62% 62% 38% 38%; + background: + radial-gradient(120% 90% at 32% 26%, color-mix(in srgb, var(--ui-accent) 14%, #fff) 0%, #f4ecd8 46%, #e4d3ad 100%); + box-shadow: + inset -0.45rem -0.6rem 1.1rem color-mix(in srgb, #000 16%, transparent), + inset 0.35rem 0.4rem 0.7rem color-mix(in srgb, #fff 70%, transparent), + 0 0.4rem 0.9rem color-mix(in srgb, #000 22%, transparent); + transform-origin: 50% 88%; + animation: pet-egg-wobble 2.4s ease-in-out infinite; +} + +/* Compact egg (empty-state hero). Children are %-based so they track the size; + only the rem box-shadow needs scaling down to stay crisp. */ +.pet-egg--sm { + width: 3.25rem; + height: 4.1rem; + box-shadow: + inset -0.28rem -0.38rem 0.7rem color-mix(in srgb, #000 16%, transparent), + inset 0.22rem 0.26rem 0.45rem color-mix(in srgb, #fff 70%, transparent), + 0 0.25rem 0.55rem color-mix(in srgb, #000 22%, transparent); +} + +.pet-egg__shine { + position: absolute; + top: 14%; + left: 22%; + width: 28%; + height: 22%; + border-radius: 50%; + background: color-mix(in srgb, #fff 85%, transparent); + filter: blur(2px); + opacity: 0.85; +} + +.pet-egg__spot { + position: absolute; + border-radius: 50%; + background: color-mix(in srgb, var(--ui-accent) 70%, #b89b63); + opacity: 0.55; +} + +.pet-egg__glow { + position: absolute; + inset: -35%; + border-radius: 50%; + background: radial-gradient(circle, color-mix(in srgb, var(--ui-accent) 55%, transparent) 0%, transparent 62%); + animation: pet-egg-glow 2.4s ease-in-out infinite; + pointer-events: none; +} + +.pet-egg-shadow { + width: 4.5rem; + height: 0.8rem; + border-radius: 50%; + background: radial-gradient(circle, color-mix(in srgb, #000 32%, transparent) 0%, transparent 72%); + animation: pet-egg-shadow 2.4s ease-in-out infinite; +} + +/* Contact shadow sized for the compact incubator egg (roughly its footprint). */ +.pet-egg-shadow--sm { + width: 3rem; + height: 0.6rem; +} + +/* Hatch wiggle for the pixel egg (rocks around its base). */ +.pet-wobble { + transform-origin: 50% 85%; + animation: pet-egg-wobble 2.4s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .pet-wobble { + animation: none; + } +} + +@keyframes pet-egg-wobble { + 0%, + 62%, + 100% { + transform: rotate(0deg); + } + 8% { + transform: rotate(-7deg); + } + 16% { + transform: rotate(6deg); + } + 24% { + transform: rotate(-5deg); + } + 32% { + transform: rotate(4deg); + } + 40% { + transform: rotate(0deg); + } + /* the "almost out" burst */ + 70% { + transform: rotate(-12deg); + } + 76% { + transform: rotate(12deg); + } + 82% { + transform: rotate(-9deg); + } + 88% { + transform: rotate(7deg); + } + 94% { + transform: rotate(-3deg); + } +} + +@keyframes pet-egg-glow { + 0%, + 100% { + opacity: 0.35; + transform: scale(0.92); + } + 70% { + opacity: 0.4; + } + 84% { + opacity: 0.85; + transform: scale(1.08); + } +} + +@keyframes pet-egg-shadow { + 0%, + 62%, + 100% { + transform: scaleX(1); + opacity: 0.6; + } + 76% { + transform: scaleX(0.8); + opacity: 0.45; + } +} + +.pet-reveal { + animation: pet-reveal-pop 620ms cubic-bezier(0.22, 1.4, 0.4, 1) both; +} + +@keyframes pet-reveal-pop { + 0% { + opacity: 0; + transform: scale(0.35) translateY(0.4rem); + } + 60% { + opacity: 1; + transform: scale(1.12) translateY(0); + } + 100% { + transform: scale(1) translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .pet-egg, + .pet-egg__glow, + .pet-egg-shadow, + .pet-reveal { + animation: none; + } + .pet-reveal { + opacity: 1; + transform: none; + } +} + +/* Pet generation progress bar — determinate (hatch rows: done/total) or */ +/* indeterminate (drafts, which return together so a % would just snap). */ +.pet-progress { + position: relative; + height: 0.25rem; + width: 100%; + overflow: hidden; + border-radius: 9999px; + background: color-mix(in srgb, var(--ui-accent) 15%, transparent); +} + +.pet-progress__fill { + position: absolute; + inset: 0 auto 0 0; + height: 100%; + border-radius: 9999px; + background: var(--ui-accent); + transition: width 320ms ease; +} + +.pet-progress__indeterminate { + position: absolute; + top: 0; + bottom: 0; + width: 40%; + border-radius: 9999px; + background: var(--ui-accent); + animation: pet-progress-slide 1.15s ease-in-out infinite; +} + +@keyframes pet-progress-slide { + 0% { + left: -42%; + } + 100% { + left: 100%; + } +} + +@media (prefers-reduced-motion: reduce) { + .pet-progress__indeterminate { + animation: none; + left: 0; + width: 100%; + opacity: 0.4; + } +}