Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
yilsem committed 2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 files changed
+3442369

No files matched your search

@@ -0,0 +1,125 @@
import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { PawPrint } from '@/lib/icons'
import { selectableCardClass } from '@/lib/selectable-card'
import { cn } from '@/lib/utils'
const VARIANT_COUNT = 4
interface DraftGridProps {
drafts: { index: number; dataUri: string }[]
generating: boolean
hasDrafts: boolean
onCancel: () => void
onHatch: () => void
onRemix: (draft: { index: number; dataUri: string }) => void
onSelect: (index: number) => void
selected: number | null
}
export function DraftGrid({
drafts,
generating,
hasDrafts,
onCancel,
onHatch,
onRemix,
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 (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span className={cn(generating && 'shimmer shimmer-color-primary opacity-40', !generating && 'invisible')}>
{copy.generating}
</span>
<span className="tabular-nums">
{Math.min(drafts.length, VARIANT_COUNT)}/{VARIANT_COUNT}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
{slots.map((draft, i) => {
// A streamed draft is selectable immediately — even mid-generation —
// so the user can commit to one without waiting for the rest.
const isSelected = draft != null && selected === draft.index
return (
<div className="group relative aspect-[192/208]" key={draft ? `draft-${draft.index}` : `slot-${i}`}>
<button
className={cn(
'absolute inset-0 flex items-center justify-center overflow-hidden',
selectableCardClass({ active: isSelected, prominent: true })
)}
disabled={draft == null}
onClick={() => draft != null && onSelect(draft.index)}
type="button"
>
{draft != null ? (
// Hatches into place as each draft streams back.
<img
alt=""
className="pet-reveal size-full object-contain p-1.5"
draggable={false}
src={draft.dataUri}
/>
) : (
// Incubating: a creme egg bouncing on its contact shadow.
<div className="relative z-10 flex flex-col items-center">
<PixelEggSprite index={i} mode="bounce" size={48} />
<span className="pet-egg-shadow pet-egg-shadow--sm" style={{ marginTop: '-0.3rem' }} />
</div>
)}
</button>
{/* Remix: branch a new round off this look. Revealed on hover/focus. */}
{draft != null && !generating && (
<Tip label={copy.remix}>
<Button
aria-label={copy.remix}
className={cn(
'absolute right-1 top-1 z-20',
'text-(--ui-text-tertiary) opacity-10 transition',
'hover:bg-transparent hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100'
)}
onClick={event => {
event.stopPropagation()
onRemix(draft)
}}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="git-branch" size={12} />
</Button>
</Tip>
)}
</div>
)
})}
</div>
{/* Same abort/go-back text link in both states (sits right under the grid);
once drafts land, the full-width Hatch drops in below it. */}
<Button className="self-center" onClick={onCancel} size="xs" variant="text">
{t.common.cancel}
</Button>
{hasDrafts && (
<Button className="w-full" disabled={selected === null} onClick={onHatch}>
<PawPrint />
{copy.hatch}
</Button>
)}
</div>
)
}
@@ -0,0 +1,27 @@
import { Button } from '@/components/ui/button'
interface EmptyHintProps {
onExample: (prompt: string) => void
}
// Creative seed prompts — specifics make better pets (petdex's own advice).
// Short chips that wrap into a tight, centered cluster (capped width → 2 rows).
const EXAMPLE_PROMPTS = ['bubble-tea otter', 'sock elf', 'pixel dragon', 'office cat', 'neon axolotl', 'moss golem']
export function EmptyHint({ onExample }: EmptyHintProps) {
return (
<div className="flex max-w-[300px] flex-wrap place-content-center place-items-center gap-2">
{EXAMPLE_PROMPTS.map(example => (
<Button
className="h-auto w-fit rounded-full font-normal"
key={example}
onClick={() => onExample(`a ${example}`)}
size="xs"
variant="outline"
>
{example}
</Button>
))}
</div>
)
}
@@ -0,0 +1,54 @@
import { Button } from '@/components/ui/button'
import { ExternalLink } from '@/lib/external-link'
import { PawPrint, Settings2 } from '@/lib/icons'
interface GenerateUnavailableProps {
onSetup: () => void
}
// Shown when no reference-capable image backend is configured: generation is
// impossible, so we replace the prompt entirely with a friendly path to set one
// up (in-app) plus where to grab a key.
export function GenerateUnavailable({ onSetup }: GenerateUnavailableProps) {
return (
<div className="flex flex-col items-center gap-4 text-center">
<span className="grid size-11 place-items-center rounded-full bg-primary/10 text-primary">
<PawPrint className="size-5" />
</span>
<div className="space-y-1.5">
<p className="text-[length:var(--conversation-text-font-size)] font-semibold">
Add an image backend to generate
</p>
<p className="mx-auto max-w-[19rem] text-[length:var(--conversation-caption-font-size)] leading-relaxed text-(--ui-text-tertiary)">
Hatching a custom pet needs a provider that can ground on a reference image.
</p>
</div>
<Button onClick={onSetup} size="sm">
<Settings2 className="size-4" />
Set up image generation
</Button>
<p className="flex flex-wrap items-center justify-center gap-x-1.5 text-[0.6875rem] text-(--ui-text-tertiary)">
<span>Grab a key from</span>
<ExternalLink href="https://portal.nousresearch.com" showExternalIcon={false}>
Nous Portal
</ExternalLink>
<span>·</span>
<ExternalLink
className="opacity-40 transition-opacity hover:opacity-100"
href="https://openrouter.ai/keys"
showExternalIcon={false}
>
OpenRouter
</ExternalLink>
<span>·</span>
<ExternalLink
className="opacity-40 transition-opacity hover:opacity-100"
href="https://platform.openai.com/api-keys"
showExternalIcon={false}
>
OpenAI
</ExternalLink>
</p>
</div>
)
}
@@ -0,0 +1,154 @@
import { useEffect, useState } from 'react'
import { PetSprite } from '@/components/pet/pet-sprite'
import { PetStarShower } from '@/components/pet/pet-star-shower'
import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Loader2, PawPrint, RefreshCw } from '@/lib/icons'
import { type PetInfo } from '@/store/pet'
import { frameCountForRow } from '../lib/frame-count'
const PREVIEW_SCALE = 0.7
const PREVIEW_STATE_MS = 1400
const PREVIEW_ROWS = [
'idle',
'waving',
'running-right',
'running-left',
'running',
'review',
'jumping',
'failed',
'waiting'
]
interface HatchPreviewProps {
pet: PetInfo
adopting: boolean
error: string | null
onAdopt: (name: string) => void
onDiscard: () => void
}
export 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 (
<div className="flex flex-col items-center gap-2">
{/* Fills the (now narrow) dialog so the pet frame is the screen width. */}
<div className="relative flex aspect-[192/208] w-full items-center justify-center overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary)">
{revealed ? (
<>
<div className="relative inline-block">
<span aria-hidden className="pet-contact-shadow" />
<div className="pet-reveal relative z-10">
<PetSprite info={previewInfo} rowOverride={rowOverride} />
</div>
</div>
<PetStarShower />
</>
) : (
// The egg cracks open, then we swap in the live pet.
<PixelEggSprite
mode="hatch"
onDone={() => {
setRevealed(true)
triggerHaptic('crisp')
}}
size={150}
/>
)}
</div>
<Input
autoFocus
className="w-full"
onChange={event => setName(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
onAdopt(name)
}
}}
placeholder={copy.namePlaceholder}
value={name}
/>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex w-full items-center gap-1.5">
<Button disabled={adopting} onClick={onDiscard} variant="ghost">
<RefreshCw />
{copy.startOver}
</Button>
<Button className="flex-1" disabled={adopting} onClick={() => onAdopt(name)}>
{adopting ? <Loader2 className="animate-spin" /> : <PawPrint />}
{copy.adopt}
</Button>
</div>
</div>
)
}
@@ -0,0 +1,24 @@
import { PetEggHatch } from '@/components/pet/pet-egg-hatch'
import { useI18n } from '@/i18n'
import { cancelHatch, type PetHatchStage } from '@/store/pet-generate'
interface HatchingViewProps {
stage: PetHatchStage | null
}
// The hatch progress screen — a beating egg with a phase-tracking subtitle
// (per-row → composing → saving).
export function HatchingView({ stage }: HatchingViewProps) {
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 <PetEggHatch cancelLabel={t.common.cancel} onCancel={cancelHatch} subtitle={subtitle} />
}
@@ -0,0 +1,51 @@
import { useStore } from '@nanostores/react'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Check, ChevronDown } from '@/lib/icons'
import { $petGenProvider, $petGenProviders, setPetGenProvider } from '@/store/pet-generate'
// Image-backend picker for pet generation — the composer's model-pill pattern:
// a quiet trigger + a dropdown of options. No per-option notes: every backend
// resolves to the same faithful OpenAI image model, so there's no tradeoff to
// describe. Hidden unless there are 2+ reference-capable backends (nothing to pick).
export function ProviderPicker() {
const providers = useStore($petGenProviders)
const picked = useStore($petGenProvider)
if (providers.length < 2) {
return null
}
const fallback = providers.find(p => p.default) ?? providers[0]
const current = providers.find(p => p.name === picked) ?? fallback
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* Plain text affordance (matches "Add a reference"), not a padded pill. */}
<button
className="flex h-6 items-center gap-1 text-[0.6875rem] text-(--ui-text-tertiary) transition hover:text-foreground"
type="button"
>
{current?.label}
<ChevronDown className="size-3" />
</button>
</DropdownMenuTrigger>
{/* The picker lives inside the pet-gen Dialog and portals to body, so its
menu needs the rung above the modal or it opens behind the dialog. */}
<DropdownMenuContent align="start" className="z-(--z-modal-popover)">
{providers.map(provider => (
<DropdownMenuItem
className="flex items-center gap-1.5"
key={provider.name}
// Picking the default clears the override (no need to pin it).
onSelect={() => setPetGenProvider(provider.default ? '' : provider.name)}
>
<span className="min-w-0 flex-1 truncate font-medium text-foreground">{provider.label}</span>
{provider.name === current?.name && <Check className="size-3.5 text-primary" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,48 @@
import { useState } from 'react'
import { ImageLightbox } from '@/components/chat/zoomable-image'
import { useImageDownload } from '@/hooks/use-image-download'
import { useI18n } from '@/i18n'
import { X } from '@/lib/icons'
interface ReferenceChipProps {
name: string
onRemove: () => void
src: string
}
// The reference photo as an attachment chip: filename + thumbnail that opens
// the shared image viewer (lightbox), with a remove affordance.
export function ReferenceChip({ name, onRemove, src }: ReferenceChipProps) {
const { t } = useI18n()
const { download, saving } = useImageDownload(src)
const [viewing, setViewing] = useState(false)
return (
<div className="ml-auto flex h-6 items-center gap-2 self-start rounded-lg border border-border/60 bg-background/50 pl-1 pr-2">
<button className="shrink-0" onClick={() => setViewing(true)} title={t.desktop.openImage} type="button">
<img alt={name} className="size-4 rounded-md object-cover" src={src} />
</button>
<span className="max-w-40 truncate text-[0.64rem] font-medium text-foreground/50">{name || 'Reference'}</span>
<button
aria-label="Remove reference"
className="text-(--ui-text-tertiary) transition not-hover:opacity-50"
onClick={onRemove}
type="button"
>
<X className="size-3" />
</button>
<ImageLightbox
alt={name}
copy={t.desktop}
onClick={download}
onOpenChange={setViewing}
open={viewing}
saving={saving}
src={src}
/>
</div>
)
}
@@ -0,0 +1,32 @@
import { type PetInfo } from '@/store/pet'
// Sprite row → the PetInfo frame-count key it resolves to (directional walks and
// aliases collapse onto their base state).
const ROW_TO_FRAME_KEY: Record<string, string> = {
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'
}
// Real frame count for a row, preferring the concrete per-row count, then the
// per-state count, then the mapped base state, then the sheet-wide default.
export function frameCountForRow(pet: PetInfo, row: string): number {
const mapped = ROW_TO_FRAME_KEY[row]
return (
pet.framesByRow?.[row] ??
pet.framesByState?.[row] ??
(mapped ? pet.framesByState?.[mapped] : undefined) ??
pet.framesPerState ??
0
)
}
@@ -0,0 +1,49 @@
const DEFAULT_MAX_INPUT_BYTES = 16 * 1024 * 1024
function loadImage(url: string): Promise<HTMLImageElement> {
const img = new Image()
return new Promise((resolve, reject) => {
img.onload = () => resolve(img)
img.onerror = () => reject(new Error('unreadable image'))
img.src = url
})
}
// Read an image file as a downscaled PNG data URL. We decode from an object URL
// (not readAsDataURL) so large files don't inflate into giant base64 strings
// before we scale them down for generation.
export async function readReferenceImage(
file: File,
max = 1024,
maxInputBytes = DEFAULT_MAX_INPUT_BYTES
): Promise<string> {
if (file.size > maxInputBytes) {
throw new Error('reference image too large')
}
const objectUrl = URL.createObjectURL(file)
try {
const img = await loadImage(objectUrl)
const scale = Math.min(1, max / Math.max(img.width, img.height))
const width = Math.max(1, Math.round(img.width * scale))
const height = Math.max(1, Math.round(img.height * scale))
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('could not create canvas context')
}
ctx.drawImage(img, 0, 0, width, height)
return canvas.toDataURL('image/png')
} finally {
URL.revokeObjectURL(objectUrl)
}
}
@@ -0,0 +1,336 @@
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<HTMLInputElement>(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 ? (
<DialogTitle className="sr-only">{copy.title}</DialogTitle>
) : (
<DialogHeader>
<DialogTitle icon={Egg}>{headerTitle}</DialogTitle>
</DialogHeader>
)}
<div className={cn('flex min-h-0 flex-1 flex-col', isEmptyState ? 'gap-4' : 'gap-2.5')}>
{/* Concept prompt with the inline sparkle generate/stop affordance (the
same primitive as the commit-message + project-idea fields). */}
{showPrompt && (
<div className="flex flex-col gap-1.5">
<div className="relative">
<Input
autoFocus
className="pr-9"
onChange={event => $petGenInput.set(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.preventDefault()
generate()
}
}}
placeholder={copy.placeholder}
value={prompt}
/>
<GenerateButton
className="absolute right-1 top-1/2 -translate-y-1/2"
disabled={!prompt.trim() && !refImage}
generating={generating}
generatingLabel={t.common.cancel}
label={copy.generate}
// Inline cancel should match step-2 cancel semantics: abort and
// return to step 1 (prompt retained for quick tweaks).
onCancel={discardDrafts}
onGenerate={generate}
/>
</div>
<div className="flex items-center gap-2">
<ProviderPicker />
{refImage ? (
<ReferenceChip name={refName} onRemove={clearReference} src={refImage} />
) : (
<button
className="ml-auto flex h-6 items-center gap-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition hover:text-foreground"
onClick={() => fileRef.current?.click()}
type="button"
>
<ImageIcon className="size-3" />
Add a reference
</button>
)}
</div>
{/* Optional reference photo — make a pet from the user's own image.
Styled like the chat composer's attachment pill. */}
<Input
accept="image/*"
className="hidden"
onChange={event => {
pickReference(event.target.files?.[0])
event.target.value = ''
}}
ref={fileRef}
type="file"
/>
</div>
)}
{/* 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 && (
<Alert variant="destructive">
<AlertDescription>{error || copy.genericError}</AlertDescription>
</Alert>
)}
{unavailable ? (
<GenerateUnavailable onSetup={setupImageGen} />
) : status === 'stale' ? (
<Alert variant="destructive">
<AlertDescription>{copy.staleBackend}</AlertDescription>
</Alert>
) : status === 'hatching' ? (
<HatchingView stage={stage} />
) : (status === 'preview' || status === 'adopting') && preview ? (
<HatchPreview
adopting={status === 'adopting'}
error={error}
onAdopt={adopt}
onDiscard={() => 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.
<EmptyHint onExample={runExample} />
) : (
<DraftGrid
drafts={drafts}
generating={generating}
hasDrafts={hasDrafts}
onCancel={discardDrafts}
onHatch={hatch}
onRemix={remixDraft}
onSelect={index => $petGenSelected.set(index)}
selected={selected}
/>
)}
</div>
<ConfirmDialog
confirmLabel={copy.remix}
description={copy.remixConfirmBody}
onClose={() => setRemixPending(null)}
onConfirm={() => {
markRemixConfirmed()
if (remixPending) {
runRemix(remixPending)
}
}}
open={remixPending !== null}
title={copy.remixConfirmTitle}
/>
</>
)
}
@@ -0,0 +1,94 @@
/**
* "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.
*
* This file is just the dialog shell + sizing; the flow lives in
* `PetGenerateContent`, and each screen is its own atomic component under
* `./components`.
*/
import { useStore } from '@nanostores/react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { useRouteOverlayActive } from '@/app/hooks/use-route-overlay-active'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import {
$petGenDrafts,
$petGenerateOpen,
$petGenError,
$petGenStatus,
cleanupPetGenOnClose,
closePetGenerate
} from '@/store/pet-generate'
import { PetGenerateContent } from './pet-generate-content'
export function PetGenerateOverlay() {
const { t } = useI18n()
const { requestGateway } = useGatewayRequest()
const open = useStore($petGenerateOpen)
const status = useStore($petGenStatus)
const error = useStore($petGenError)
const drafts = useStore($petGenDrafts)
// Yield the screen to a full-screen route overlay (e.g. /settings while the
// user adds an image-gen key) without tearing down — the store keeps us open,
// and we reappear + re-check on return.
if (useRouteOverlayActive()) {
return null
}
const handleOpenChange = (next: boolean) => {
if (!next) {
cleanupPetGenOnClose(requestGateway)
// Never interrupt in-flight work. Generating/hatching continues in the
// background; only an unadopted finished preview is discarded on close.
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.
// `fitContent` lets the dialog size to content; the `min-w` floors each phase.
const single = status === 'hatching' || status === 'preview' || status === 'adopting'
const copy = t.commandCenter.generatePet
// The footer banner narrates the dialog's async state: the failure reason on a
// dead-end error, else the "you can close this, we'll notify you" reassurance
// while a generate/hatch runs in the background. On step 1, show a neutral ETA.
const working = status === 'generating' || status === 'hatching'
const errored = status === 'error' && drafts.length === 0
const stepOne = status === 'idle' || status === 'ready'
const banner = errored
? error || copy.genericError
: working
? copy.backgroundHint
: stepOne
? copy.slowProviderHint
: undefined
return (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogContent
aria-describedby={undefined}
banner={banner}
bannerTone={errored ? 'error' : 'info'}
bodyClassName="gap-4 text-center"
// Cap the width so a long banner (e.g. a provider refusal) wraps instead
// of stretching the dialog out; the min-w floors each phase.
className={cn(single ? 'min-w-[17rem] max-w-[20rem]' : 'min-w-[19rem] max-w-[22rem]')}
fitContent
>
{open && <PetGenerateContent />}
</DialogContent>
</Dialog>
)
}