import { useActionMutation } from "@agent-native/core/client/hooks"; import { normalizeWorkspaceAppId } from "@agent-native/core/shared"; import { IconArrowUpRight, IconCircleCheck, IconPlus, IconPlugConnected, } from "@tabler/icons-react"; import { useEffect, useMemo, useState, type ReactNode } from "react"; import { toast } from "sonner"; import { cn } from "../lib/utils"; import { AppIcon } from "./app-icon"; import { AppListRow } from "./app-list-row"; import { AppOpenActions } from "./app-open-actions"; import { AppRowSettings } from "./app-row-settings"; import { Button } from "./ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "./ui/dialog"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; import { Spinner } from "./ui/spinner"; export interface CuratedWorkspaceTemplate { id?: string | null; templateId?: string | null; appId?: string | null; name: string; description?: string | null; source?: string | null; sourceDescription?: string | null; icon?: string | null; color?: string | null; integrationSetup?: string | null; setupNote?: string | null; installed?: boolean | null; installedAppId?: string | null; liveUrl?: string | null; productUrl?: string | null; } export type CuratedWorkspaceTemplatesResult = | CuratedWorkspaceTemplate[] | { templates: CuratedWorkspaceTemplate[]; }; export interface WorkspaceTemplateLabels { appId: string; appIdDescription: string; cancel: string; integrationSetup: string; installed: string; remix: string; remixing: string; remixSuccess: string; remixError: string; appIdRequired: string; source: string; openApp: string; viewLiveApp: string; } const DEFAULT_LABELS: WorkspaceTemplateLabels = { appId: "App ID", appIdDescription: "Choose the URL-safe id for the new workspace app.", cancel: "Cancel", integrationSetup: "Integration setup", installed: "Installed", remix: "Add app", remixing: "Creating app…", remixSuccess: "Template app creation started.", remixError: "Could not add this app", appIdRequired: "App ID is required.", source: "Source", openApp: "Open app", viewLiveApp: "View the live app", }; export interface WorkspaceTemplateCardProps { template: CuratedWorkspaceTemplate; defaultAppId?: string; labels?: Partial; className?: string; catalog?: boolean; onRemixSuccess?: ( result: unknown, template: CuratedWorkspaceTemplate, ) => void; } function templateIdFor(template: CuratedWorkspaceTemplate): string { return template.templateId || template.id || template.appId || template.name; } function defaultAppIdFor( template: CuratedWorkspaceTemplate, defaultAppId?: string, ): string { if (defaultAppId) return normalizeWorkspaceAppId(defaultAppId) || "new-app"; const sourceId = normalizeWorkspaceAppId(template.appId || template.name) || "new-app"; return `${sourceId}-app`; } function stringifyError(error: unknown): string { if (error instanceof Error && error.message) return error.message; if (typeof error === "string") return error; return "Unknown error"; } function mergeLabels( labels?: Partial, ): WorkspaceTemplateLabels { return { ...DEFAULT_LABELS, ...labels }; } export function WorkspaceTemplateCard({ template, defaultAppId, labels: labelOverrides, className, catalog = false, onRemixSuccess, }: WorkspaceTemplateCardProps) { const labels = useMemo(() => mergeLabels(labelOverrides), [labelOverrides]); const [open, setOpen] = useState(false); const [appId, setAppId] = useState(() => defaultAppIdFor(template, defaultAppId), ); const isInstalled = Boolean(template.installed || template.installedAppId); const liveUrl = template.liveUrl || template.productUrl; const setupNote = template.integrationSetup || template.setupNote; const remix = useActionMutation("remix-workspace-template", { onSuccess: (result) => { const mode = (result as { mode?: string } | null)?.mode; const message = (result as { message?: string } | null)?.message; if (mode === "builder") { toast.success(labels.remixSuccess); } else if (mode === "builder-unavailable") { toast.error(message || labels.remixError); } else if (mode === "coming-soon") { toast.info(message || labels.remixSuccess); } else { toast.success(labels.remixSuccess); } setOpen(false); onRemixSuccess?.(result, template); }, onError: (error) => toast.error(`${labels.remixError}: ${stringifyError(error)}`), }); useEffect(() => { if (open) { setAppId(defaultAppIdFor(template, defaultAppId)); } }, [defaultAppId, open, template]); function submitRemix() { const normalizedAppId = normalizeWorkspaceAppId(appId); if (!normalizedAppId) { toast.error(labels.appIdRequired); return; } setAppId(normalizedAppId); remix.mutate({ templateId: templateIdFor(template), appId: normalizedAppId, }); } return (

{template.name}

{isInstalled ? ( {labels.installed} ) : null}
{template.description ? (

{template.description}

) : null}
{catalog ? ( <> {liveUrl ? ( ) : null} setOpen(true)} /> ) : liveUrl ? ( ) : null} { if (!remix.isPending) setOpen(nextOpen); }} > {!catalog ? ( ) : null} {template.name} {labels.appIdDescription}
{ event.preventDefault(); submitRemix(); }} >
setAppId(event.target.value)} onBlur={() => setAppId(normalizeWorkspaceAppId(appId))} disabled={remix.isPending} />
{setupNote ? (
{labels.integrationSetup}: {" "} {setupNote}
) : null}
); } export interface WorkspaceTemplatesSectionProps { templates: CuratedWorkspaceTemplatesResult; title?: ReactNode; defaultAppId?: string; labels?: Partial; className?: string; cardClassName?: string; listClassName?: string; catalog?: boolean; onRemixSuccess?: ( result: unknown, template: CuratedWorkspaceTemplate, ) => void; } function getTemplateItems( result: CuratedWorkspaceTemplatesResult, ): CuratedWorkspaceTemplate[] { return Array.isArray(result) ? result : result.templates; } export function WorkspaceTemplatesSection({ templates: result, title, defaultAppId, labels, className, cardClassName, listClassName, catalog = false, onRemixSuccess, }: WorkspaceTemplatesSectionProps) { const templates = getTemplateItems(result); if (templates.length === 0) return null; return (
{title ?
{title}
: null}
{templates.map((template) => ( ))}
); }