feat(pets): polish generate flow and reduce hatch CPU pressure

Ship the final pet-generation UX polish (provider picker behavior, step-2 cancel flow, banner integration, and visual consistency) and make saturated-chroma background removal C-op driven so hatch processing no longer hammers the machine during long runs.
This commit is contained in:
Brooklyn Nicholson 2026-06-24 19:08:06 -05:00
parent b674f7ba28
commit 1fe013ee16
35 changed files with 2013 additions and 729 deletions

View file

@ -22,6 +22,25 @@ interface Point {
y: number
}
interface PetInfoMeta {
enabled: boolean
slug?: string
displayName?: string
scale?: number
spritesheetRevision?: string
}
function samePetRevision(info: PetInfo, meta: PetInfoMeta): boolean {
return (
info.enabled &&
Boolean(info.spritesheetBase64) &&
info.slug === meta.slug &&
info.displayName === meta.displayName &&
info.scale === meta.scale &&
info.spritesheetRevision === meta.spritesheetRevision
)
}
function clampToViewport({ x, y }: Point): Point {
const maxX = Math.max(0, (window.innerWidth || 800) - 80)
const maxY = Math.max(0, (window.innerHeight || 600) - 80)
@ -63,12 +82,15 @@ function loadPosition(): Point {
* Adopting a pet is fully in-app: type `/pet boba` in the composer. That
* writes `display.pet.*` from the slash worker, so we keep polling `pet.info`
* while no pet is active and the mascot pops in within a few seconds no
* reload, no CLI. Once a pet is live we stop polling.
* reload, no CLI. Once a pet is live we still refresh more slowly so generated
* pets rewritten on disk (or renamed/rebuilt by the hatch flow) repaint without
* restarting the app.
*
* Promotion to a separate frameless OS-level window is a follow-up the
* sprite + state logic here is reused as-is, only the host changes.
*/
const PET_POLL_MS = 3000
const PET_ACTIVE_REFRESH_MS = 15000
export function FloatingPet() {
const { requestGateway } = useGatewayRequest()
@ -93,11 +115,12 @@ export function FloatingPet() {
// state is only committed on release.
const dragRef = useRef<{ dx: number; dy: number; x: number; y: number } | null>(null)
// Fetch pet.info on connect, then keep polling while no pet is active so an
// in-app `/pet <slug>` shows up live. Stops polling once a pet is enabled.
// Fetch pet.info on connect. Poll quickly while inactive so an in-app
// `/pet <slug>` appears, then slowly while active so regenerated spritesheets
// and row-count metadata replace the cached base64 payload.
const active = info.enabled && Boolean(info.spritesheetBase64)
useEffect(() => {
if (gatewayState !== 'open' || active) {
if (gatewayState !== 'open') {
return
}
@ -105,9 +128,39 @@ export function FloatingPet() {
const pull = async () => {
try {
if (active) {
try {
const meta = await requestGateway<PetInfoMeta>('pet.info.meta', { profile: petProfile() })
if (cancelled || !meta) {
return
}
if (!meta.enabled) {
setPetInfo({ enabled: false })
return
}
if (samePetRevision($petInfo.get(), meta)) {
return
}
} catch {
// Older gateways may not have pet.info.meta yet; fall back to pet.info.
}
}
const next = await requestGateway<PetInfo>('pet.info', { profile: petProfile() })
if (!cancelled && next) {
const current = $petInfo.get()
if (
next.enabled &&
current.enabled &&
current.slug === next.slug &&
current.displayName === next.displayName &&
current.scale === next.scale &&
current.spritesheetRevision &&
current.spritesheetRevision === next.spritesheetRevision
) {
return
}
setPetInfo(next)
}
} catch {
@ -116,10 +169,12 @@ export function FloatingPet() {
}
void pull()
const timer = window.setInterval(() => void pull(), PET_POLL_MS)
const timer = window.setInterval(() => void pull(), active ? PET_ACTIVE_REFRESH_MS : PET_POLL_MS)
window.addEventListener('focus', pull)
return () => {
cancelled = true
window.removeEventListener('focus', pull)
window.clearInterval(timer)
}
}, [gatewayState, active, requestGateway])

View file

@ -44,14 +44,16 @@ export function PetProgress({ done, total }: { done?: number; total?: number })
export function PetEggHatch({ subtitle, onCancel, cancelLabel }: PetEggHatchProps) {
return (
<div className="flex flex-col items-center justify-center gap-3 px-2 py-5">
<div className="flex flex-col items-center justify-center gap-3">
<div className="flex flex-col items-center">
<PixelEggSprite mode="bounce" size={88} />
<span className="pet-egg-shadow mt-1.5" />
{/* The egg sprite has transparent canvas below the art, so pull the
shadow up ~a fifth of its size to sit at the egg's base. */}
<span className="pet-egg-shadow" style={{ marginTop: '-0.55rem' }} />
</div>
{subtitle && (
<p className="shimmer max-w-[15rem] text-center text-[length:var(--conversation-caption-font-size)] leading-snug">
<p className="shimmer shimmer-color-primary whitespace-nowrap text-center text-[length:var(--conversation-caption-font-size)] leading-snug text-(--ui-text-tertiary)">
{subtitle}
</p>
)}

View file

@ -91,6 +91,7 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
const frameH = info.frameH ?? DEFAULT_FRAME_H
const frames = info.framesPerState ?? DEFAULT_FRAMES
const framesByState = info.framesByState
const framesByRow = info.framesByRow
const loopMs = info.loopMs ?? DEFAULT_LOOP_MS
const scale = (info.scale ?? DEFAULT_SCALE) * zoom
const rows = info.stateRows ?? DEFAULT_STATE_ROWS
@ -134,6 +135,8 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
let lastStep = performance.now()
let drawnFrame = -1
let drawnRow = -1
let activeRow = -1
let activeCount = -1
const rowIndexForState = (s: PetState): number => {
for (const key of STATE_ALIASES[s] ?? [s]) {
@ -161,13 +164,25 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
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)
const count = Math.max(
1,
framesByRow?.[rowName] ?? framesByState?.[rowName] ?? (state ? framesByState?.[state] : 0) ?? frames
)
return { row: row >= 0 ? row : rowIndexForState(state ?? 'idle'), count }
}
const render = (now: number) => {
const forcedRow = rowOverrideRef.current
const { row, count } = forcedRow ? resolveRow(forcedRow) : resolve(overrideRef.current ?? stateRef.current)
if (row !== activeRow || count !== activeCount) {
activeRow = row
activeCount = count
frame = 0
lastStep = now
drawnFrame = -1
}
// 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
@ -201,7 +216,7 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
cancelAnimationFrame(raf)
unsubState()
}
}, [image, frameW, frameH, frames, framesByState, loopMs, drawW, drawH, rows])
}, [image, frameW, frameH, frames, framesByState, framesByRow, loopMs, drawW, drawH, rows])
return (
<canvas

View file

@ -35,16 +35,98 @@ function DialogOverlay({ className, ...props }: React.ComponentProps<typeof Dial
)
}
type DialogBannerTone = 'error' | 'warn' | 'info'
// Tinted, edge-to-edge bottom banner per tone. Error/warn keep their semantic
// destructive/primary tokens; info derives from the dialog's own bubble
// background so it reads as part of the themed dialog — lifted 30% toward white
// in light mode, deepened 20% toward black in dark mode.
const DIALOG_BANNER_TONES: Record<DialogBannerTone, string> = {
error: 'bg-destructive/12 text-destructive',
warn: 'bg-primary/12 text-primary',
info: 'bg-[color-mix(in_srgb,var(--ui-chat-bubble-background),white_30%)] text-[color-mix(in_srgb,var(--ui-chat-bubble-background),black_60%)] dark:bg-[color-mix(in_srgb,var(--ui-chat-bubble-background),black_20%)] dark:text-[color-mix(in_srgb,var(--ui-chat-bubble-background),white_60%)]'
}
function DialogContent({
className,
children,
showCloseButton = true,
fitContent = false,
banner,
bannerTone = 'error',
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
// Size the dialog to its content (capped at the viewport) instead of the
// default fixed `max-w-lg`. For content that has no intrinsic width (grids,
// full-width inputs) pair it with a `min-w-*` in `className`.
fitContent?: boolean
// A dialog-level notice rendered as a banner flush to the bottom edge (tinted,
// inherited bottom radius) so it reads as part of the dialog, not a floating
// alert. Falsy → no banner. Tone picks the colour.
banner?: React.ReactNode
bannerTone?: DialogBannerTone
}) {
const { t } = useI18n()
const widthClass = fitContent ? 'w-auto max-w-[92vw]' : 'w-full max-w-lg'
const closeButton = showCloseButton ? (
<DialogPrimitive.Close asChild data-slot="dialog-close-button">
<Button
aria-label={t.common.close}
className="absolute right-2.5 top-2.5 z-20 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
size="icon-xs"
variant="ghost"
>
<X className="size-4" />
<span className="sr-only">{t.common.close}</span>
</Button>
</DialogPrimitive.Close>
) : null
// With a banner, the border can't live on the scroll/clip box (it would draw a
// line around the banner too). The white body keeps its own bottom radius and
// sits over the tinted footer; the outer shell only clips the banner to the
// dialog's rounded bottom edge.
if (banner) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto flex max-h-[85vh] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl bg-(--ui-chat-bubble-background) text-[length:var(--conversation-text-font-size)] text-foreground shadow-nous duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
widthClass,
className,
// Callers often pass `gap-*` for the no-banner grid layout — suppress
// it here so the banner can tuck under the body's rounded bottom edge.
'gap-0'
)}
data-slot="dialog-content"
{...props}
>
{/* Scroll lives on an inner box so this shell keeps a painted bottom radius. */}
<div className="relative z-10 overflow-hidden rounded-xl border border-b-0 border-(--stroke-nous) bg-(--ui-chat-bubble-background)">
<div className="grid max-h-[calc(85vh-5rem)] min-h-0 gap-3 overflow-y-auto p-4">{children}</div>
</div>
<div
className={cn(
// Overlap by one corner radius so the white bottom lobes read clearly
// over the tint instead of meeting it on a straight seam.
'relative z-0 -mt-[var(--radius-xl)] px-4 pb-2.5 pt-[calc(var(--radius-xl)+0.625rem)] text-center text-[length:var(--conversation-tool-font-size)] leading-relaxed shadow-[inset_0_7px_7px_-4px_rgb(0_0_0/0.28)]',
DIALOG_BANNER_TONES[bannerTone]
)}
data-slot="dialog-banner"
role={bannerTone === 'error' ? 'alert' : 'status'}
>
{banner}
</div>
{closeButton}
</DialogPrimitive.Content>
</DialogPortal>
)
}
return (
<DialogPortal>
<DialogOverlay />
@ -53,26 +135,15 @@ function DialogContent({
// Cap height at 85vh and let long content scroll inside the dialog
// instead of overflowing off-screen (long cron titles, tool detail
// dumps, etc.). Individual dialogs can still override via className.
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid max-h-[85vh] w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-3 overflow-y-auto rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-nous duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid max-h-[85vh] -translate-x-1/2 -translate-y-1/2 gap-3 overflow-y-auto rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-nous duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
widthClass,
className
)}
data-slot="dialog-content"
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild data-slot="dialog-close-button">
<Button
aria-label={t.common.close}
className="absolute right-2.5 top-2.5 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
size="icon-xs"
variant="ghost"
>
<X className="size-4" />
<span className="sr-only">{t.common.close}</span>
</Button>
</DialogPrimitive.Close>
)}
{closeButton}
</DialogPrimitive.Content>
</DialogPortal>
)