diff --git a/apps/desktop/src/app/hooks/use-route-overlay-active.ts b/apps/desktop/src/app/hooks/use-route-overlay-active.ts new file mode 100644 index 00000000000..261f9de15d5 --- /dev/null +++ b/apps/desktop/src/app/hooks/use-route-overlay-active.ts @@ -0,0 +1,19 @@ +import { useLocation } from 'react-router-dom' + +import { appViewForPath, isOverlayView } from '@/app/routes' + +/** + * True while a full-screen route overlay (settings, agents, command-center, …) + * is showing. + * + * A portaled Radix modal sits above the app shell, so it would cover such a + * route. Any modal that sends the user to one (e.g. "set up image generation" → + * `/settings`) can `if (useRouteOverlayActive()) return null` to *yield* the + * screen — its open state lives in a store, so it stays open — and reappear, + * re-running its mount effects (a free refresh), when the route overlay closes. + */ +export function useRouteOverlayActive(): boolean { + const { pathname } = useLocation() + + return isOverlayView(appViewForPath(pathname)) +} diff --git a/apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx b/apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx index 2ba12a22bc0..954dac23bfd 100644 --- a/apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx +++ b/apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx @@ -10,8 +10,11 @@ import { useStore } from '@nanostores/react' import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { SETTINGS_ROUTE } from '@/app/routes' import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' +import { useRouteOverlayActive } from '@/app/hooks/use-route-overlay-active' import { PetEggHatch } from '@/components/pet/pet-egg-hatch' import { PetStarShower } from '@/components/pet/pet-star-shower' import { PetSprite } from '@/components/pet/pet-sprite' @@ -22,12 +25,14 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u import { GenerateButton } from '@/components/ui/generate-button' import { Input } from '@/components/ui/input' import { useI18n } from '@/i18n' +import { ExternalLink } from '@/lib/external-link' import { triggerHaptic } from '@/lib/haptics' -import { Egg, Loader2, PawPrint, RefreshCw } from '@/lib/icons' +import { Egg, Loader2, PawPrint, RefreshCw, Settings2 } from '@/lib/icons' import { selectableCardClass } from '@/lib/selectable-card' import { cn } from '@/lib/utils' import { type PetInfo } from '@/store/pet' import { + $petGenAvailable, $petGenDrafts, $petGenerateOpen, $petGenError, @@ -38,6 +43,7 @@ import { adoptHatched, cancelGenerate, cancelHatch, + checkPetGenAvailable, cleanPetName, cleanupPetGen, closePetGenerate, @@ -87,6 +93,13 @@ export function PetGenerateOverlay() { const status = useStore($petGenStatus) const { requestGateway } = useGatewayRequest() + // 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) { // Deletes a hatched-but-unadopted preview pet so it doesn't linger, then @@ -116,9 +129,14 @@ 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) @@ -126,6 +144,13 @@ function PetGenerateContent() { const [prompt, setPrompt] = useState('') + // 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' @@ -176,14 +201,23 @@ function PetGenerateContent() { // 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' + // 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 ( <> - - {headerTitle} - + {unavailable ? ( + {copy.title} + ) : ( + + {headerTitle} + + )}
{/* Concept prompt with the inline sparkle generate/stop affordance (the @@ -215,13 +249,15 @@ function PetGenerateContent() {
)} - {error && status !== 'preview' && status !== 'adopting' && ( + {error && !unavailable && status !== 'preview' && status !== 'adopting' && ( {error} )} - {status === 'stale' ? ( + {unavailable ? ( + + ) : status === 'stale' ? ( {copy.staleBackend} @@ -257,6 +293,51 @@ function PetGenerateContent() { // 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'] +// 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. +function GenerateUnavailable({ onSetup }: { onSetup: () => void }) { + return ( +
+ + + +
+

Add an image backend to generate

+

+ Hatching a custom pet needs a provider that can ground on a reference image. +

+
+ +

+ Grab a key from + + Nous Portal + + · + + OpenRouter + + · + + OpenAI + +

+
+ ) +} + function EmptyHint({ onExample }: { onExample: (prompt: string) => void }) { return (
diff --git a/apps/desktop/src/store/pet-generate.ts b/apps/desktop/src/store/pet-generate.ts index 29efe3d5f0e..bfcd7117d50 100644 --- a/apps/desktop/src/store/pet-generate.ts +++ b/apps/desktop/src/store/pet-generate.ts @@ -96,6 +96,22 @@ export const $petGenStatus = atom('idle') export const $petGenStage = atom(null) export const $petGenError = atom(null) +// Whether a reference-capable image backend is configured. `null` = not yet +// probed (treat as available so the prompt shows optimistically); the overlay +// re-probes on open and on return from settings. +export const $petGenAvailable = atom(null) + +/** Probe whether generation is possible (a reference-capable backend exists). */ +export async function checkPetGenAvailable(request: GatewayRequest): Promise { + try { + const res = await request<{ available: boolean }>('pet.generate.status') + $petGenAvailable.set(Boolean(res?.available)) + } catch { + // Unknown (old backend / transient) — don't gate the UI on a failed probe. + $petGenAvailable.set(true) + } +} + /** Whether the dedicated "Generate a pet" Pokédex overlay is open. */ export const $petGenerateOpen = atom(false) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index f29ef972017..750a6840270 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -6144,6 +6144,27 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"ok": True}) +@method("pet.generate.status") +def _(rid, params: dict) -> dict: + """Whether pet generation is possible right now. + + True only when a reference-capable image backend (OpenRouter / Nous Portal / + OpenAI gpt-image) is configured — the desktop checks this on open so it can + offer setup instead of a dead prompt. Cheap (config + plugin discovery). + """ + try: + from agent.pet.generate.imagegen import GenerationError, resolve_provider + + try: + resolve_provider(require_references=True) + return _ok(rid, {"available": True}) + except GenerationError: + return _ok(rid, {"available": False}) + except Exception as exc: # noqa: BLE001 - never break the surface + logger.debug("pet.generate.status failed: %s", exc) + return _ok(rid, {"available": False}) + + @method("pet.generate") def _(rid, params: dict) -> dict: """Generate candidate base looks for a new pet (the draft/variant step).