import { useStore } from '@nanostores/react' import { useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router' import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' import { SETTINGS_ROUTE } from '@/app/routes' import { Alert, AlertDescription } from '@/components/ui/alert' import { ConfirmDialog } from '@/components/ui/confirm-dialog' import { 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, ImageIcon } from '@/lib/icons' import { cn } from '@/lib/utils' import { $petGenAvailable, $petGenDrafts, $petGenError, $petGenInput, $petGenPreview, $petGenRefImage, $petGenRefName, $petGenRemixConfirmed, $petGenSelected, $petGenStage, $petGenStatus, adoptHatched, cancelGenerate, checkPetGenAvailable, cleanPetName, closePetGenerate, discardDrafts, discardHatched, generateDrafts, hatchSelected, markRemixConfirmed } from '@/store/pet-generate' import { DraftGrid } from './components/draft-grid' import { EmptyHint } from './components/empty-hint' import { GenerateUnavailable } from './components/generate-unavailable' import { HatchPreview } from './components/hatch-preview' import { HatchingView } from './components/hatching-view' import { ProviderPicker } from './components/provider-picker' import { ReferenceChip } from './components/reference-chip' import { readReferenceImage } from './lib/read-reference-image' // The generate → hatch → adopt controller. A thin view over the `pet-generate` // store; the store owns the steps and persists inputs across close/reopen. export function PetGenerateContent() { const { t } = useI18n() const copy = t.commandCenter.generatePet const { requestGateway } = useGatewayRequest() const navigate = useNavigate() const status = useStore($petGenStatus) const error = useStore($petGenError) const available = useStore($petGenAvailable) // `null` = not yet probed → stay optimistic (show the prompt); only the // confirmed-no-backend case swaps in the setup card. const unavailable = available === false const drafts = useStore($petGenDrafts) const selected = useStore($petGenSelected) const preview = useStore($petGenPreview) const stage = useStore($petGenStage) // Inputs live in atoms so they survive a close/reopen (and background runs). const prompt = useStore($petGenInput) const refImage = useStore($petGenRefImage) const refName = useStore($petGenRefName) const fileRef = useRef(null) // The draft awaiting the one-time "remix regenerates" confirmation. const [remixPending, setRemixPending] = useState<{ dataUri: string } | null>(null) // Probe backend availability on open — and again whenever the content // remounts (e.g. after returning from the providers settings), so adding a // key flips the setup card to the prompt with no manual refresh. useEffect(() => { void checkPetGenAvailable(requestGateway) }, [requestGateway]) 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-4) from the prompt; the working states stay compact. const isEmptyState = !hasDrafts && !generating && status !== 'hatching' && status !== 'preview' && status !== 'adopting' && status !== 'stale' const generate = () => { if ((prompt.trim() || refImage) && !busy) { void generateDrafts(requestGateway, { prompt: prompt.trim(), referenceImage: refImage ?? undefined }) } } const clearReference = () => { $petGenRefImage.set(null) $petGenRefName.set('') } const pickReference = (file: File | undefined) => { if (!file) { return } const mapReferenceError = (reason: unknown): string => { const message = reason instanceof Error ? reason.message.toLowerCase() : '' return message.includes('too large') ? copy.referenceImageTooLarge : copy.referenceImageInvalid } void readReferenceImage(file) .then(dataUrl => { $petGenRefImage.set(dataUrl) $petGenRefName.set(file.name) // Clear picker-only errors once the reference is valid again. if ($petGenStatus.get() === 'error' && $petGenDrafts.get().length === 0) { $petGenStatus.set('idle') $petGenError.set(null) } }) .catch(reason => { $petGenRefImage.set(null) $petGenRefName.set('') $petGenError.set(mapReferenceError(reason)) if (!busy) { $petGenStatus.set('error') } }) } // One-click an example prompt straight into a draft round. const runExample = (example: string) => { $petGenInput.set(example) void generateDrafts(requestGateway, { prompt: example }) } // A remix re-runs generation grounded on an existing draft — same prompt, stay // on step 2 — so the user explores variations without starting over. const runRemix = (draft: { dataUri: string }) => { void generateDrafts(requestGateway, { prompt: prompt.trim(), referenceImage: draft.dataUri }) } // Slow, and it replaces the current drafts — so confirm once, then remember it. const remixDraft = (draft: { dataUri: string }) => { if (busy) { return } if ($petGenRemixConfirmed.get()) { runRemix(draft) return } setRemixPending(draft) } // Hatch the selected draft. The user can pick one before the rest stream in — // if so, abort the remaining generations first (keeping the drafts we have). // The prompt is grounding text, not a label; the user names it on reveal. const hatch = () => { if (selected === null) { return } if (generating) { cancelGenerate() } void hatchSelected(requestGateway, { name: cleanPetName(prompt), prompt: prompt.trim() }) } const adopt = (finalName: string) => { void adoptHatched(requestGateway, finalName).then(out => { if (out.ok) { triggerHaptic('crisp') closePetGenerate() } }) } // 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 // Send the user to set up a key without closing — the overlay yields to the // settings route (useRouteOverlayActive) and reappears + re-checks on return. const setupImageGen = () => navigate(`${SETTINGS_ROUTE}?tab=providers`) // Prompt input only belongs on the describe/draft screens (and never when // there's no backend to generate with). const showPrompt = !unavailable && status !== 'hatching' && status !== 'preview' && status !== 'adopting' return ( <> {unavailable ? ( {copy.title} ) : ( {headerTitle} )}
{/* Concept prompt with the inline sparkle generate/stop affordance (the same primitive as the commit-message + project-idea fields). */} {showPrompt && (
$petGenInput.set(event.target.value)} onKeyDown={event => { if (event.key === 'Enter') { event.preventDefault() generate() } }} placeholder={copy.placeholder} value={prompt} />
{refImage ? ( ) : ( )}
{/* Optional reference photo — make a pet from the user's own image. Styled like the chat composer's attachment pill. */} { pickReference(event.target.files?.[0]) event.target.value = '' }} ref={fileRef} type="file" />
)} {/* Hatch failed but the drafts are still here — show why above the grid so the user can re-pick and retry without losing their options. */} {status === 'error' && hasDrafts && ( {error || copy.genericError} )} {unavailable ? ( ) : status === 'stale' ? ( {copy.staleBackend} ) : status === 'hatching' ? ( ) : (status === 'preview' || status === 'adopting') && preview ? ( void discardHatched(requestGateway)} pet={preview} /> ) : !hasDrafts && !generating ? ( // Doubles as the error-empty state — the failure reason rides the // dialog's footer banner, so here we just offer the retry sparks. ) : ( $petGenSelected.set(index)} selected={selected} /> )}
setRemixPending(null)} onConfirm={() => { markRemixConfirmed() if (remixPending) { runRemix(remixPending) } }} open={remixPending !== null} title={copy.remixConfirmTitle} /> ) }