feat(pets): remix a draft into a fresh round

Add a hover/focus "Remix" action on each completed draft card in the
generation grid. It re-runs generation with the chosen draft fed back in
as the reference image, keeping the same prompt and staying on step 2 so
the user can explore variations without starting over.

Because regenerating is slow and replaces the current drafts, the first
remix shows a one-time confirmation; the acknowledgement is persisted so
subsequent remixes fire immediately.
This commit is contained in:
Brooklyn Nicholson 2026-06-25 01:09:19 -05:00
parent 4362c1a3af
commit 5196575d40
8 changed files with 139 additions and 29 deletions

View file

@ -1,5 +1,7 @@
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'
@ -13,11 +15,21 @@ interface DraftGridProps {
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, onSelect, selected }: DraftGridProps) {
export function DraftGrid({
drafts,
generating,
hasDrafts,
onCancel,
onHatch,
onRemix,
onSelect,
selected
}: DraftGridProps) {
const { t } = useI18n()
const copy = t.commandCenter.generatePet
@ -43,32 +55,58 @@ export function DraftGrid({ drafts, generating, hasDrafts, onCancel, onHatch, on
const isSelected = draft != null && selected === draft.index
return (
<button
className={cn(
'relative flex aspect-[192/208] items-center justify-center overflow-hidden',
selectableCardClass({ active: isSelected, prominent: true })
<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>
{/* Branch off this look reuses the draft as the next reference so
the user can explore variations without leaving the grid. Hidden
until hover/focus to keep the grid clean. */}
{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>
)}
disabled={draft == null}
key={draft ? `draft-${draft.index}` : `slot-${i}`}
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>
</div>
)
})}
</div>

View file

@ -1,10 +1,11 @@
import { useStore } from '@nanostores/react'
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
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'
@ -20,6 +21,7 @@ import {
$petGenPreview,
$petGenRefImage,
$petGenRefName,
$petGenRemixConfirmed,
$petGenSelected,
$petGenStage,
$petGenStatus,
@ -31,7 +33,8 @@ import {
discardDrafts,
discardHatched,
generateDrafts,
hatchSelected
hatchSelected,
markRemixConfirmed
} from '@/store/pet-generate'
import { DraftGrid } from './components/draft-grid'
@ -68,6 +71,9 @@ export function PetGenerateContent() {
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.
@ -139,6 +145,29 @@ export function PetGenerateContent() {
void generateDrafts(requestGateway, { prompt: example })
}
// Branch a fresh draft round off an existing look by feeding it back in as the
// reference image. Keeps the user on step 2 with the same prompt, just grounded
// on the chosen draft.
const runRemix = (draft: { dataUri: string }) => {
void generateDrafts(requestGateway, { prompt: prompt.trim(), referenceImage: draft.dataUri })
}
// "Remix" affordance: regenerating is slow and replaces the current drafts, so
// warn once (then remember the acknowledgement) before kicking off the round.
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.
@ -283,11 +312,28 @@ export function PetGenerateContent() {
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={() => {
const draft = remixPending
markRemixConfirmed()
if (draft) {
runRemix(draft)
}
}}
open={remixPending !== null}
title={copy.remixConfirmTitle}
/>
</>
)
}

View file

@ -799,6 +799,10 @@ export const en: Translations = {
staleBackend: 'Update Hermes to generate pets.',
backgroundHint: 'You can close this — Hermes will notify you when its done.',
slowProviderHint: 'This can take several minutes',
remix: 'Remix',
remixConfirmTitle: 'Remix this look?',
remixConfirmBody:
'This generates a fresh set of drafts using this one as the starting point. It can take several minutes.',
genericError: 'Generation failed — try again or pick a suggestion.',
referenceImageTooLarge: 'Reference image is too large. Use one under 16 MB.',
referenceImageInvalid: 'Could not read that reference image. Try a PNG, JPG, WebP, or GIF.',

View file

@ -917,6 +917,9 @@ export const ja = defineLocale({
staleBackend: 'ペットを生成するには Hermes を更新してください。',
backgroundHint: 'このウィンドウは閉じても大丈夫です。完了したら Hermes が通知します。',
slowProviderHint: '数分かかることがあります',
remix: 'リミックス',
remixConfirmTitle: 'この見た目でリミックスしますか?',
remixConfirmBody: 'これを起点に新しい候補を生成します。数分かかることがあります。',
genericError: '生成に失敗しました。もう一度試すか、候補を選んでください。',
referenceImageTooLarge: '参照画像が大きすぎます。16 MB 未満の画像を使ってください。',
referenceImageInvalid: '参照画像を読み込めませんでした。PNG/JPG/WebP/GIF を試してください。',

View file

@ -703,6 +703,9 @@ export interface Translations {
staleBackend: string
backgroundHint: string
slowProviderHint: string
remix: string
remixConfirmTitle: string
remixConfirmBody: string
genericError: string
referenceImageTooLarge: string
referenceImageInvalid: string

View file

@ -889,6 +889,9 @@ export const zhHant = defineLocale({
staleBackend: '請更新 Hermes 以生成寵物。',
backgroundHint: '你可以關閉此視窗——完成後 Hermes 會通知你。',
slowProviderHint: '這可能需要幾分鐘',
remix: '混合生成',
remixConfirmTitle: '以此造型混合生成?',
remixConfirmBody: '將以此造型為起點生成一組新草圖,可能需要幾分鐘。',
genericError: '生成失敗——請重試或選一個建議。',
referenceImageTooLarge: '參考圖片過大。請使用小於 16 MB 的圖片。',
referenceImageInvalid: '無法讀取該參考圖片。請嘗試 PNG、JPG、WebP 或 GIF。',

View file

@ -987,6 +987,9 @@ export const zh: Translations = {
staleBackend: '请更新 Hermes 以生成宠物。',
backgroundHint: '你可以关闭此窗口——完成后 Hermes 会通知你。',
slowProviderHint: '这可能需要几分钟',
remix: '混合生成',
remixConfirmTitle: '以此造型混合生成?',
remixConfirmBody: '将以此造型为起点生成一组新草图,可能需要几分钟。',
genericError: '生成失败——请重试或选择一个建议。',
referenceImageTooLarge: '参考图过大。请使用小于 16 MB 的图片。',
referenceImageInvalid: '无法读取该参考图。请尝试 PNG、JPG、WebP 或 GIF。',

View file

@ -1,6 +1,6 @@
import { atom } from 'nanostores'
import { persistString, storedString } from '@/lib/storage'
import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage'
import { $gateway } from '@/store/gateway'
import { dispatchNativeNotification } from '@/store/native-notifications'
import { notify } from '@/store/notifications'
@ -118,6 +118,7 @@ export interface PetGenProvider {
}
const PROVIDER_KEY = 'hermes.desktop.petgen.provider'
const REMIX_CONFIRMED_KEY = 'hermes.desktop.petgen.remixConfirmed'
/** Reference-capable providers available to pick (from `pet.generate.status`). */
export const $petGenProviders = atom<PetGenProvider[]>([])
@ -130,6 +131,15 @@ export function setPetGenProvider(name: string): void {
persistString(PROVIDER_KEY, name || null)
}
/** Whether the user has acknowledged the one-time "remix regenerates" notice. */
export const $petGenRemixConfirmed = atom(storedBoolean(REMIX_CONFIRMED_KEY, false))
/** Remember that the remix notice has been shown so we don't ask again. */
export function markRemixConfirmed(): void {
$petGenRemixConfirmed.set(true)
persistBoolean(REMIX_CONFIRMED_KEY, true)
}
/** Probe whether generation is possible (a reference-capable backend exists). */
export async function checkPetGenAvailable(request: GatewayRequest): Promise<void> {
try {