import { setAgentChatContextItem } from "@agent-native/core/client/agent-chat"; import { readClientAppState, useActionMutation, useActionQuery, } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import { IconChevronLeft, IconChevronRight, IconDeviceFloppy, IconMessageCircle, IconPhoto, IconTrash, IconX, } from "@tabler/icons-react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useRef, useState } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle, } from "@/components/ui/dialog"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { assetPreviewSources } from "@/lib/asset-preview-sources"; import type { AssetVariantState, ImageLibrarySummary, } from "../../../shared/api"; type LibraryListResult = { libraries?: ImageLibrarySummary[]; }; type VariantSlot = AssetVariantState["slots"][number]; function variantStateKey(threadId: string | null) { return threadId ? `asset-variants:${threadId}` : "asset-variants"; } // Only reconcile a still-pending slot once it is old enough that the run cannot // plausibly still be generating. Keep this above the server generation budget // (IMAGE_GENERATION_REQUEST_TIMEOUT_MS, default 300s) and in step with // STALE_IMAGE_RUN_MS in refresh-generation-run, so a slow-but-healthy run (e.g. // gpt-image-2) is not flagged "interrupted" and flipped to an error slot before // its finished image arrives. const STALE_PENDING_RUN_MS = 10 * 60 * 1000; function slotTime(slot: VariantSlot): number { const raw = slot.createdAt ?? slot.updatedAt ?? ""; const time = Date.parse(raw); return Number.isNaN(time) ? 0 : time; } function stalePendingRunId(slot: VariantSlot): string | null { if (slot.status !== "pending") return null; if (!slot.runId) return null; const timestamp = slotTime(slot); if (!timestamp) return null; return Date.now() - timestamp >= STALE_PENDING_RUN_MS ? slot.runId : null; } export function GenerationResults({ threadId }: { threadId: string | null }) { const t = useT(); const queryClient = useQueryClient(); const [clearAllOpen, setClearAllOpen] = useState(false); const [previewSlotId, setPreviewSlotId] = useState(null); const stateKey = variantStateKey(threadId); const stateQueryKey = useMemo(() => ["app-state", stateKey], [stateKey]); const { data: variants } = useQuery({ queryKey: stateQueryKey, queryFn: async ({ signal }) => { return readClientAppState(stateKey, { signal }); }, refetchInterval: (query) => { const state = query.state.data as AssetVariantState | undefined; const hasPendingSlot = (state?.slots ?? []).some( (slot) => slot.status === "pending", ); return hasPendingSlot ? 1000 : false; }, }); const { data: librariesData } = useActionQuery("list-libraries", { compact: true, } as any) as { data?: LibraryListResult }; const saveGenerated = useActionMutation("save-generated-image"); const dismissSlot = useActionMutation("dismiss-variant-slots"); const refreshGeneration = useActionMutation("refresh-generation-run"); const refreshingRunIds = useRef>(new Set()); const libraryTitle = useMemo(() => { if (!variants?.libraryId) return null; return ( librariesData?.libraries?.find((item) => item.id === variants.libraryId) ?.title ?? null ); }, [librariesData?.libraries, variants?.libraryId]); const slots = useMemo( () => (variants?.slots ?? []) .slice() .sort( (left, right) => slotTime(right) - slotTime(left) || right.slotId.localeCompare(left.slotId), ), [variants?.slots], ); const belongsToThread = Boolean( variants && (threadId ? variants.threadId === threadId : !variants.threadId), ); useEffect(() => { if (!belongsToThread) return; if (!slots.length || refreshGeneration.isPending) return; const runId = slots .map(stalePendingRunId) .find((id): id is string => Boolean(id && !refreshingRunIds.current.has(id)), ); if (!runId) return; refreshingRunIds.current.add(runId); refreshGeneration.mutate( { runId }, { onSettled: () => { window.setTimeout(() => { refreshingRunIds.current.delete(runId); }, 30_000); void queryClient.invalidateQueries({ queryKey: stateQueryKey, refetchType: "active", }); }, }, ); }, [belongsToThread, queryClient, refreshGeneration, slots, stateQueryKey]); const previewSlot = useMemo( () => slots.find((slot) => slot.slotId === previewSlotId) ?? null, [previewSlotId, slots], ); if (!belongsToThread || !variants) return null; if (slots.length === 0) return null; const pendingCount = slots.filter((slot) => slot.status === "pending").length; const readyCount = slots.filter((slot) => slot.status === "ready").length; const failedCount = slots.filter((slot) => slot.status === "failed").length; const statusSummary = [ pendingCount > 0 ? t("library.generatingCount", { count: pendingCount }) : null, readyCount > 0 ? t("library.readyCount", { count: readyCount }) : null, failedCount > 0 ? t("library.failedCount", { count: failedCount }) : null, ] .filter(Boolean) .join(" / "); function clearAllCandidates() { dismissSlot.mutate( { threadId, scope: "all" }, { onSuccess: () => { setClearAllOpen(false); void queryClient.invalidateQueries({ queryKey: stateQueryKey, }); }, onError: (error) => toast.error(error.message || t("library.couldNotClearCandidates")), }, ); } function saveSlot(slot: VariantSlot, onDone?: () => void) { if (!slot.assetId && !slot.slotId) return; saveGenerated.mutate( { ...(slot.assetId ? { assetId: slot.assetId } : {}), ...(slot.slotId ? { slotId: slot.slotId } : {}), threadId, }, { onSuccess: () => { toast.success(t("library.savedGeneratedAsset")); void queryClient.invalidateQueries({ queryKey: stateQueryKey }); void queryClient.invalidateQueries({ queryKey: ["action", "get-library"], }); onDone?.(); }, onError: (error) => toast.error(error.message || t("library.couldNotSaveCandidate")), }, ); } function dismissSlotById(slot: VariantSlot, onDone?: () => void) { dismissSlot.mutate( { slotId: slot.slotId, threadId }, { onSuccess: () => { void queryClient.invalidateQueries({ queryKey: stateQueryKey }); onDone?.(); }, onError: (error) => toast.error(error.message || t("library.couldNotDismissCandidate")), }, ); } function refineSlot(slot: VariantSlot, variantNumber: number) { if (!slot.assetId) return; const variantLabel = t("library.variantWithNumber", { number: variantNumber, }); setAgentChatContextItem({ key: "refine-asset:" + slot.assetId, title: t("library.refine") + " : " + variantLabel, context: [ "## Assets candidate", `Asset ID: ${slot.assetId}`, `Run ID: ${slot.runId ?? "unknown"}`, `Prompt: ${variants?.prompt ?? ""}`, libraryTitle ? `Brand kit: ${libraryTitle}` : "", "Use refine-image with assetId when the user describes the change.", ] .filter(Boolean) .join("\n"), openSidebar: true, }); } return ( <> {t("library.clearGeneratedCandidatesTitle")} {t("library.clearGeneratedCandidatesDescription")} {t("library.cancel")} { event.preventDefault(); clearAllCandidates(); }} > {dismissSlot.isPending ? ( <> {t("library.clearing")} ) : ( t("library.clearCandidates") )} { if (!open) setPreviewSlotId(null); }} onSelect={setPreviewSlotId} onSave={(slot) => saveSlot(slot, () => setPreviewSlotId(null))} onRefine={(slot, variantNumber) => { refineSlot(slot, variantNumber); setPreviewSlotId(null); }} onDismiss={(slot) => dismissSlotById(slot, () => setPreviewSlotId(null)) } />
{pendingCount > 0 ? ( ) : ( )}

{t("library.generatedCandidatesTitle")}

{statusSummary}

{libraryTitle || t("library.noBrandKit")} / {variants.prompt}

{slots.map((slot, index) => ( setPreviewSlotId(slot.slotId)} onSave={() => saveSlot(slot)} onRefine={() => refineSlot(slot, index + 1)} onDismiss={() => dismissSlotById(slot)} /> ))}
); } function GenerationDraftCard({ slot, variantNumber, isSaving, isDismissing, onPreview, onSave, onRefine, onDismiss, }: { slot: VariantSlot; variantNumber: number; isSaving: boolean; isDismissing: boolean; onPreview: () => void; onSave: () => void; onRefine: () => void; onDismiss: () => void; }) { const t = useT(); const ready = slot.status === "ready" && Boolean(slot.assetId); const variantLabel = t("library.variantWithNumber", { number: variantNumber, }); return (
{slot.status !== "ready" ? (
{slot.status === "pending" ? : null} {slot.status === "pending" ? t("library.generating") : t("library.failed")}
) : null} {ready ? (
{t("library.save")} {t("library.refine")} {t("library.deleteCandidate")}
) : ( )}
{variantLabel}
); } function GenerationPreviewDialog({ slot, slots, prompt, libraryTitle, isSaving, isDismissing, onOpenChange, onSelect, onSave, onRefine, onDismiss, }: { slot: VariantSlot | null; slots: VariantSlot[]; prompt: string; libraryTitle: string | null; isSaving: boolean; isDismissing: boolean; onOpenChange: (open: boolean) => void; onSelect: (slotId: string) => void; onSave: (slot: VariantSlot) => void; onRefine: (slot: VariantSlot, variantNumber: number) => void; onDismiss: (slot: VariantSlot) => void; }) { const t = useT(); const previewableSlots = useMemo( () => slots.filter((item) => item.status === "ready" && Boolean(item.assetId)), [slots], ); const previewIndex = slot ? previewableSlots.findIndex((item) => item.slotId === slot.slotId) : -1; const hasPrev = previewIndex > 0; const hasNext = previewIndex >= 0 && previewIndex < previewableSlots.length - 1; const showPreviousSlot = () => { if (hasPrev) onSelect(previewableSlots[previewIndex - 1].slotId); }; const showNextSlot = () => { if (hasNext) onSelect(previewableSlots[previewIndex + 1].slotId); }; const sources = slot ? assetPreviewSources(slot, "preview") : []; const src = sources[0]; const variantNumber = slot ? slots.findIndex((item) => item.slotId === slot.slotId) + 1 : 0; const variantLabel = t("library.variantWithNumber", { number: variantNumber, }); return ( {slot ? ( { if (event.key === "ArrowLeft") showPreviousSlot(); if (event.key === "ArrowRight") showNextSlot(); }} className="flex max-h-[85vh] w-[calc(100vw-24px)] max-w-4xl flex-col gap-0 overflow-hidden p-0" > {variantLabel} {prompt || t("library.readyToSave")}

{variantLabel}

{slot.assetId ? ( ) : null}
{src ? ( {prompt ) : (
)}
{hasPrev || hasNext ? (
) : null}
) : null}
); } function GenerationSlotPreview({ slot }: { slot: VariantSlot }) { const t = useT(); const sources = assetPreviewSources(slot, "thumbnail"); const src = sources[0]; if (src) { return ( ); } if (slot.status === "failed") { return (
{slot.error || t("library.failed")}
); } return (
); }