import { type PromptComposerSubmitOptions } from "@agent-native/core/client/composer"; import { useFeatureFlag } from "@agent-native/core/client/feature-flags"; import { useActionQuery, useActionMutation, } from "@agent-native/core/client/hooks"; import { injectSessionReplayIframeBootstrap, SESSION_REPLAY_IFRAME_ATTRIBUTE, } from "@agent-native/core/client/host"; import { useT } from "@agent-native/core/client/i18n"; import { CreativeContextShareSheet } from "@agent-native/creative-context/client"; import { useSetHeaderActions, useSetPageTitle, } from "@agent-native/toolkit/app-shell"; import { FULL_APP_BUILDING } from "@shared/full-app"; import { derivePromptTitle } from "@shared/prompt-title"; import { IconChecks, IconPlus, IconSearch, IconDots, IconTrash, IconCopy, IconCode, IconX, IconPencil, } from "@tabler/icons-react"; import { useQueryClient } from "@tanstack/react-query"; import { nanoid } from "nanoid"; import { useState, useRef, useCallback, useEffect, useMemo } from "react"; import { useNavigate, Link, useSearchParams } from "react-router"; import { toast } from "sonner"; import PromptPopover from "@/components/editor/PromptDialog"; import type { PromptTemplateOption, UploadedFile, } from "@/components/editor/PromptDialog"; import { QueryErrorState } from "@/components/QueryErrorState"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { useDesignSystems } from "@/hooks/use-design-systems"; import { sendToDesignAgentChat } from "@/lib/agent-chat"; import { clearPendingGeneration, writePendingGeneration, } from "@/lib/pending-generation"; type ProjectType = "prototype" | "other"; interface Design { id: string; title: string; description?: string; projectType: ProjectType; designSystemId?: string | null; createdAt?: string; updatedAt?: string; /** Preview HTML for the thumbnail. Only present when the list query asks * for `includePreview: 'true'`. Truncated server-side. */ previewHtml?: string | null; } export default function Index() { const t = useT(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const queryClient = useQueryClient(); const [search, setSearch] = useState(""); const [deleteId, setDeleteId] = useState(null); const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); const [selectedDesignIds, setSelectedDesignIds] = useState>( () => new Set(), ); const [showNewPrompt, setShowNewPrompt] = useState(false); const fullAppBuildingEnabled = useFeatureFlag(FULL_APP_BUILDING.key); const [newDesignHandoffPending, setNewDesignHandoffPending] = useState(false); const [newDesignSystemId, setNewDesignSystemId] = useState< string | null | undefined >(undefined); const [newTemplateId, setNewTemplateId] = useState(null); // "Design" (default, inline prototype) vs "Full app" (Builder Fusion // cloud container). Only reachable behind the full-app-building flag — the // popover renders no mode control at all when the flag is off, so this // state is always "design" in that case. const [newDesignMode, setNewDesignMode] = useState<"design" | "app">( "design", ); const [renameId, setRenameId] = useState(null); const [renameDraft, setRenameDraft] = useState(""); const [contextDesigns, setContextDesigns] = useState([]); const anchorElRef = useRef(null); const anchorRef = useRef(null); const skipToEditorPendingRef = useRef(false); const newDesignSystemWasChosenRef = useRef(false); // Keep anchorRef.current in sync so PromptPopover can read it anchorRef.current = anchorElRef.current; const { data: designsData, isLoading, isError, isFetching, refetch, } = useActionQuery("list-designs", { includePreview: "true" }); const { data: templatesData, isLoading: templatesLoading } = useActionQuery( "list-design-templates", { includePreview: "true" }, ); const createMutation = useActionMutation("create-design"); const createFromTemplateMutation = useActionMutation( "create-design-from-template", ); // Fires the fusion-backed cloud container build; only ever called when // runtime flag is true and the user picked "Full app". const createFusionAppMutation = useActionMutation("create-fusion-app"); const deleteMutation = useActionMutation("delete-design"); const duplicateMutation = useActionMutation("duplicate-design"); const updateMutation = useActionMutation("update-design"); const generateTitleMutation = useActionMutation("generate-design-title"); // Designs the user has manually renamed since creation — an AI-generated // title that resolves later must never clobber an explicit rename. const userRenamedDesignIdsRef = useRef>(new Set()); const { designSystems, defaultSystem, isLoading: designSystemsLoading, } = useDesignSystems(); const designs = (designsData?.designs ?? []) as Design[]; const templateOptions = useMemo( () => (templatesData?.templates ?? []).map((template) => ({ id: template.id, title: template.title, description: template.description, category: template.category, width: template.width, height: template.height, previewHtml: template.previewHtml, designSystemId: template.designSystemId, isBuiltIn: template.isBuiltIn, })), [templatesData?.templates], ); const selectedTemplate = templateOptions.find((template) => template.id === newTemplateId) ?? null; const filtered = search ? designs.filter((d) => d.title.toLowerCase().includes(search.toLowerCase()), ) : designs; const selectedDesignCount = selectedDesignIds.size; const isSelectingDesigns = selectedDesignCount > 0; const allVisibleSelected = filtered.length > 0 && filtered.every((design) => selectedDesignIds.has(design.id)); const resolveDefaultDesignSystemId = useCallback( () => defaultSystem?.id ?? designSystems[0]?.id ?? null, [defaultSystem?.id, designSystems], ); const syncSelectedTemplate = useCallback( (templateId: string | null) => { setNewTemplateId(templateId); const next = new URLSearchParams(searchParams); if (templateId) next.set("templateId", templateId); else next.delete("templateId"); setSearchParams(next, { replace: true }); }, [searchParams, setSearchParams], ); const openNewDesign = useCallback( (e: React.MouseEvent) => { anchorElRef.current = e.currentTarget; newDesignSystemWasChosenRef.current = false; syncSelectedTemplate(null); setNewDesignSystemId( designSystemsLoading ? undefined : resolveDefaultDesignSystemId(), ); setShowNewPrompt(true); }, [designSystemsLoading, resolveDefaultDesignSystemId, syncSelectedTemplate], ); const handleNewPromptOpenChange = useCallback( (open: boolean) => { setShowNewPrompt(open); if (!open) { newDesignSystemWasChosenRef.current = false; syncSelectedTemplate(null); setNewDesignSystemId(undefined); setNewDesignMode("design"); } }, [syncSelectedTemplate], ); useEffect(() => { if ( !showNewPrompt || newDesignSystemId !== undefined || designSystemsLoading ) return; setNewDesignSystemId(resolveDefaultDesignSystemId()); }, [ designSystemsLoading, newDesignSystemId, resolveDefaultDesignSystemId, showNewPrompt, ]); const handleTemplateChange = useCallback( (templateId: string | null) => { syncSelectedTemplate(templateId); const template = templateOptions.find( (candidate) => candidate.id === templateId, ); if (newDesignSystemWasChosenRef.current) return; const linkedSystemId = template?.designSystemId && designSystems.some((system) => system.id === template.designSystemId) ? template.designSystemId : null; setNewDesignSystemId( linkedSystemId ?? (designSystemsLoading ? undefined : resolveDefaultDesignSystemId()), ); }, [ designSystems, designSystemsLoading, resolveDefaultDesignSystemId, syncSelectedTemplate, templateOptions, ], ); const handleNewDesignSystemChange = useCallback( (designSystemId: string | null) => { newDesignSystemWasChosenRef.current = true; setNewDesignSystemId(designSystemId); }, [], ); const toggleDesignSelection = useCallback((id: string) => { setSelectedDesignIds((current) => { const next = new Set(current); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); }, []); const toggleVisibleSelection = useCallback(() => { setSelectedDesignIds((current) => { const next = new Set(current); const shouldClear = filtered.length > 0 && filtered.every((design) => next.has(design.id)); filtered.forEach((design) => { if (shouldClear) { next.delete(design.id); } else { next.add(design.id); } }); return next; }); }, [filtered]); const handleSearchChange = useCallback((query: string) => { setSearch(query); setSelectedDesignIds((current) => current.size === 0 ? current : new Set(), ); }, []); const clearSelection = useCallback(() => { setSelectedDesignIds(new Set()); }, []); const createDesign = useCallback( ( title: string, designSystemId?: string | null, ): { id: string; title: string; ready: Promise } => { const id = nanoid(); const projectType: ProjectType = "prototype"; const finalTitle = title.trim() || "Untitled Design"; const linkedDesignSystemId = designSystemId ?? null; // Optimistic update queryClient.setQueryData( ["action", "list-designs", { includePreview: "true" }], (old: any) => { const newDesign: Design = { id, title: finalTitle, projectType, designSystemId: linkedDesignSystemId, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; return { count: (old?.count ?? 0) + 1, designs: [newDesign, ...(old?.designs ?? [])], }; }, ); const ready = createMutation .mutateAsync({ id, title: finalTitle, projectType, ...(linkedDesignSystemId ? { designSystemId: linkedDesignSystemId } : {}), } as any) .then(() => undefined) .catch((error) => { clearPendingGeneration(id); queryClient.invalidateQueries({ queryKey: ["action", "list-designs"], }); throw error; }); // Fire mutation in background; keep the optimistic navigation instant. void ready.catch(() => {}); return { id, title: finalTitle, ready }; }, [queryClient, createMutation], ); // Mirrors the chat-title flow: the placeholder (derivePromptTitle) shows // immediately, then a short AI-generated name replaces it in the // background once it resolves. Never blocks navigation or generation. const handleGenerateDesignTitle = useCallback( (designId: string, prompt: string, previousTitle: string) => { generateTitleMutation .mutateAsync({ designId, prompt, previousTitle } as any) .then((result: any) => { if (!result?.updated || !result.title) return; if (userRenamedDesignIdsRef.current.has(designId)) return; queryClient.setQueriesData( { queryKey: ["action", "list-designs"] }, (old: any) => { if (!old || typeof old !== "object") return old; return { ...old, count: old.count ?? (old.designs ?? []).length, designs: (old.designs ?? []).map((d: Design) => d.id === designId ? { ...d, title: result.title } : d, ), }; }, ); }) .catch(() => { // Best-effort background enhancement — the placeholder title // already saved at creation time stays as the final title. }); }, [generateTitleMutation, queryClient], ); const handleSubmitPrompt = useCallback( async ( prompt: string, files: UploadedFile[], options: PromptComposerSubmitOptions, pendingOptions?: { skipQuestions?: boolean }, ) => { const trimmedPrompt = prompt.trim(); const designSystemId = newDesignSystemId === undefined ? resolveDefaultDesignSystemId() : newDesignSystemId; if (selectedTemplate && newDesignMode === "design") { setNewDesignHandoffPending(true); const title = trimmedPrompt ? derivePromptTitle(trimmedPrompt) : selectedTemplate.title; try { const result = await createFromTemplateMutation.mutateAsync({ templateId: selectedTemplate.id, title, designSystemId, ...(trimmedPrompt ? { prompt: trimmedPrompt } : {}), }); if (!result.id) { throw new Error("Template copy did not return a design ID"); } const effectiveDesignSystemId = result.designSystemId ?? null; if (result.adaptationPending) { const effectiveSystemTitle = designSystems.find( (system) => system.id === effectiveDesignSystemId, )?.title ?? t("promptDialog.designSystem"); writePendingGeneration(result.id, { prompt: trimmedPrompt || t("promptDialog.reskinTemplatePrompt", { title: selectedTemplate.title, system: effectiveSystemTitle, }), files, title: result.title ?? title, source: selectedTemplate.title, templateId: selectedTemplate.id, templateBaselineFiles: result.templateBaselineFiles, designSystemId: effectiveDesignSystemId, skipQuestions: true, ...options, }); } if (trimmedPrompt) { handleGenerateDesignTitle( result.id, trimmedPrompt, result.title ?? title, ); } void queryClient .invalidateQueries({ queryKey: ["action", "list-designs"], }) .catch(() => {}); navigate(`/design/${result.id}`); return; } catch (error) { setNewDesignHandoffPending(false); toast.error( error instanceof Error ? error.message : t("templatesPage.createFailed"), ); throw error; } } // Derive a short title from the prompt — first line, ~40 chars max, // word-boundary truncated. The full prompt still drives generation; // the title is just a label, so longer is worse. const derivedTitle = derivePromptTitle(prompt); const { id, title, ready } = createDesign(derivedTitle, designSystemId); handleGenerateDesignTitle(id, prompt, title); if (fullAppBuildingEnabled && newDesignMode === "app") { // Full-app designs are backed by a real running container, not a // queued inline generation — skip writePendingGeneration and let the // fusion app mutation (and its own status/progress banner in the // editor) drive the build instead. void ready .then(() => createFusionAppMutation.mutateAsync({ designId: id, prompt, } as any), ) .then((result: any) => { if (result?.status !== "not-configured") return; // Builder isn't connected/configured, so no fusionApp linkage was // written and no banner will render. Hand off to the agent chat, // which owns the connect-Builder card flow, keeping the user's // prompt so nothing is lost. sendToDesignAgentChat({ message: `I want to build this design as a full app: ${prompt}`, context: `create-fusion-app returned status "not-configured" for design ` + `${id}. ${result?.message ?? ""} Help the user connect ` + `Builder.io (see connect-builder-app), then retry ` + `create-fusion-app with the user's prompt.`, submit: true, }); }) .catch((error) => { const message = error instanceof Error && error.message ? error.message : String(error); sendToDesignAgentChat({ message: `I want to build this design as a full app: ${prompt}`, context: `Starting the full-app build for design ${id} failed: ` + `${message}. Check whether the design row exists, Builder is ` + `connected, and create-fusion-app can be retried safely.`, submit: true, }); }); } else { writePendingGeneration(id, { prompt, files, title, designSystemId, skipQuestions: pendingOptions?.skipQuestions, ...options, }); } setNewDesignHandoffPending(true); navigate(`/design/${id}`); }, [ createDesign, createFromTemplateMutation, createFusionAppMutation, designSystems, handleGenerateDesignTitle, navigate, newDesignMode, newDesignSystemId, queryClient, resolveDefaultDesignSystemId, selectedTemplate, t, ], ); const handleSkipToEditor = useCallback(async () => { if (selectedTemplate && newDesignMode === "design") { await handleSubmitPrompt("", [], {}); return false; } if (skipToEditorPendingRef.current) return; skipToEditorPendingRef.current = true; setNewDesignHandoffPending(true); const designSystemId = newDesignSystemId === undefined ? resolveDefaultDesignSystemId() : newDesignSystemId; const { id, ready } = createDesign( t("home.untitledDesign"), designSystemId, ); try { // Unlike prompt-backed creation, an empty shell has no pending-generation // marker to keep the editor polling across its route remount. Wait for the // row to persist so the first get-design read cannot briefly return 404. await ready; navigate(`/design/${id}`); return false; } catch (error) { skipToEditorPendingRef.current = false; setNewDesignHandoffPending(false); toast.error(t("home.failedToCreateDesign")); throw error; } }, [ createDesign, handleSubmitPrompt, navigate, newDesignMode, newDesignSystemId, resolveDefaultDesignSystemId, selectedTemplate, t, ]); const handleDelete = useCallback(() => { if (!deleteId) return; const id = deleteId; // Optimistic update queryClient.setQueryData( ["action", "list-designs", { includePreview: "true" }], (old: any) => ({ count: Math.max((old?.count ?? 1) - 1, 0), designs: (old?.designs ?? []).filter((d: Design) => d.id !== id), }), ); setDeleteId(null); deleteMutation.mutate({ id } as any, { onError: () => { queryClient.invalidateQueries({ queryKey: ["action", "list-designs"], }); }, }); }, [deleteId, queryClient, deleteMutation]); const handleBulkDelete = useCallback(() => { const ids = Array.from(selectedDesignIds); if (ids.length === 0) return; const idsToDelete = new Set(ids); queryClient.setQueryData( ["action", "list-designs", { includePreview: "true" }], (old: any) => ({ count: Math.max( (old?.count ?? (old?.designs ?? []).length) - ids.length, 0, ), designs: (old?.designs ?? []).filter( (d: Design) => !idsToDelete.has(d.id), ), }), ); setBulkDeleteOpen(false); setSelectedDesignIds(new Set()); void Promise.all(ids.map((id) => deleteMutation.mutateAsync({ id } as any))) .then(() => undefined) .catch(() => { queryClient.invalidateQueries({ queryKey: ["action", "list-designs"], }); }); }, [selectedDesignIds, queryClient, deleteMutation]); const handleDuplicate = useCallback( (id: string) => { duplicateMutation.mutate({ id } as any, { onSuccess: (data: any) => { queryClient.invalidateQueries({ queryKey: ["action", "list-designs"], }); if (data?.id) { navigate(`/design/${data.id}`); } }, }); }, [duplicateMutation, queryClient, navigate], ); const startRename = useCallback((design: Design) => { setRenameId(design.id); setRenameDraft(design.title); }, []); const commitRename = useCallback(() => { if (!renameId) return; const id = renameId; const next = renameDraft.trim(); setRenameId(null); if (!next) return; userRenamedDesignIdsRef.current.add(id); queryClient.setQueriesData( { queryKey: ["action", "list-designs"] }, (old: any) => { if (!old || typeof old !== "object") return old; return { ...old, count: old.count ?? (old.designs ?? []).length, designs: (old.designs ?? []).map((d: Design) => d.id === id ? { ...d, title: next } : d, ), }; }, ); updateMutation.mutate({ id, title: next } as any, { onError: () => { queryClient.invalidateQueries({ queryKey: ["action", "list-designs"], }); }, }); }, [renameId, renameDraft, queryClient, updateMutation]); const formatDate = (dateStr?: string) => { if (!dateStr) return ""; const d = new Date(dateStr); return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", }); }; useSetPageTitle(t("home.pageTitle")); useSetHeaderActions( designs.length > 0 ? (
handleSearchChange(e.target.value)} placeholder={t("home.searchPlaceholder")} className="ps-8 h-8 w-48 bg-accent/50 border-border text-sm text-foreground/90 placeholder:text-muted-foreground/70" />
) : null, ); return ( <> {newDesignHandoffPending ? : null}
{isLoading ? ( ) : isError ? ( void refetch()} retrying={isFetching} /> ) : designs.length === 0 ? ( handleSubmitPrompt(prompt, [], {}, { skipQuestions: true }) } /> ) : ( <> {isSelectingDesigns ? (
{t("home.selected", { count: selectedDesignCount })}
{allVisibleSelected ? t("home.clearVisibleSelection") : t("home.selectVisibleDesigns")} {t("home.clearSelection")}
) : null} {/* Grid */}
{/* New design card */} {/* Design cards */} {filtered.map((design) => { const isSelected = selectedDesignIds.has(design.id); const cardContent = ( <>

{design.title}

{formatDate(design.updatedAt || design.createdAt)}
); return (
{cardContent}
toggleDesignSelection(design.id) } onClick={(event) => event.stopPropagation()} aria-label={t("home.selectDesign", { title: design.title, })} className="h-5 w-5 border-white/70 bg-black/65 text-white shadow-sm data-[state=checked]:border-[#609FF8] data-[state=checked]:bg-[#609FF8]" /> {t("home.selectDesign", { title: design.title })}
{/* Three-dot menu */}
startRename(design)} className="cursor-pointer" > {t("home.rename")} handleDuplicate(design.id)} className="cursor-pointer" > {t("home.duplicate")} { event.preventDefault(); setContextDesigns([design]); }} className="cursor-pointer" > {t( "creativeContext.addToContext" /* i18n-key-ignore */, )} setDeleteId(design.id)} className="text-red-400 focus:text-red-400 cursor-pointer" > {t("home.delete")}
); })}
)}
0} onOpenChange={(open) => { if (!open) setContextDesigns([]); }} resources={contextDesigns.map((design) => ({ appId: "design", resourceType: "design", resourceId: design.id, title: design.title, updatedAt: design.updatedAt ?? design.createdAt, preview: { kind: "document", label: "Design" }, }))} /> { handleNewPromptOpenChange(false); navigate("/design-systems/setup"); }} creationMode={fullAppBuildingEnabled ? newDesignMode : undefined} onCreationModeChange={ fullAppBuildingEnabled ? setNewDesignMode : undefined } /> {/* Delete Confirmation */} { if (!open) { setDeleteId(null); setBulkDeleteOpen(false); } }} > {bulkDeleteOpen ? selectedDesignCount === 1 ? t("home.deleteSingleDesignsTitle", { count: selectedDesignCount, }) : t("home.deleteDesignsTitle", { count: selectedDesignCount, }) : t("home.deleteDesignTitle")} {bulkDeleteOpen ? selectedDesignCount === 1 ? t("home.deleteDesignDescription") : t("home.deleteDesignsDescription", { count: selectedDesignCount, }) : t("home.deleteDesignDescription")} {t("home.cancel")} {t("home.delete")} {/* Rename Dialog */} { if (!open) setRenameId(null); }} > {t("home.renameDesign")} setRenameDraft(e.target.value)} autoFocus onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); commitRename(); } }} placeholder={t("home.designName")} className="h-9 text-sm" /> {t("home.cancel")} {t("home.save")} ); } /** * Render the design's index.html as a non-interactive thumbnail. The iframe * renders at a fixed natural size (so designs that assume a desktop viewport * still look right) and is then scaled to fill the card via a transform. * * The size is recomputed via ResizeObserver so the same component works in * 1, 2, 3 and 4-column grid layouts. We use a sandboxed iframe with only * allow-scripts (no allow-same-origin) so Tailwind/Alpine CDN render without * granting arbitrary design HTML access to the host origin. */ function DesignThumbnail({ html }: { html: string | null }) { const t = useT(); const containerRef = useRef(null); const [scale, setScale] = useState(0.25); // Designs are generated for a desktop-ish viewport. Render at 1280×720 then // shrink — close enough to 16:10 for the aspect-video card without leaving // a sliver of letterbox at the bottom. const NATURAL_WIDTH = 1280; const NATURAL_HEIGHT = 720; useEffect(() => { const el = containerRef.current; if (!el) return; const update = () => { const w = el.clientWidth; if (w > 0) setScale(w / NATURAL_WIDTH); }; update(); const observer = new ResizeObserver(update); observer.observe(el); return () => observer.disconnect(); }, []); if (!html) { return (
); } return (