import { sendToAgentChat } from "@agent-native/core/client/agent-chat"; import { appApiPath } from "@agent-native/core/client/api-path"; import { useActionQuery, useActionMutation, } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import { openAgentSidebar } from "@agent-native/core/client/navigation"; import { withBuilderUtmTrackingParams } from "@agent-native/core/shared"; import { IconWorld, IconPalette, IconLoader2, IconBrandGithub, IconBrandFigma, IconFolder, IconX, IconFileDescription, IconPhoto, IconCheck, IconExternalLink, } from "@tabler/icons-react"; import { useState, useCallback, useEffect, useRef, useMemo } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Textarea } from "@/components/ui/textarea"; import { MAX_BUILDER_INDEX_UPLOAD_BYTES, readBuilderIndexResponse, formatFileSize, type BuilderIndexResult, } from "./builder-index-response"; interface DesignSystemSetupProps { open: boolean; onClose: () => void; onComplete: () => void; editingId?: string; } interface GitHubLink { id: string; url: string; } interface UploadedFile { id: string; name: string; type: string; size: number; textContent?: string; } function normalizeWebsiteUrlInput(input: string): string | null { const trimmed = input.trim(); if (!trimmed) return null; const withProtocol = /^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) ? trimmed : /^[a-z\d-]+$/i.test(trimmed) ? `https://${trimmed}.com` : `https://${trimmed}`; try { const parsed = new URL(withProtocol); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { return null; } if (!parsed.hostname || /\s/.test(parsed.hostname)) return null; const normalized = parsed.toString(); return normalized.endsWith("/") && !parsed.pathname.slice(1) ? normalized.slice(0, -1) : normalized; } catch { return null; } } function isDesignMdFile(file: UploadedFile) { const name = file.name.split(/[\\/]/).pop()?.toLowerCase() ?? file.name; return name === "design.md" || name === "design.mdx"; } export function DesignSystemSetup({ open, onClose, onComplete, editingId, }: DesignSystemSetupProps) { const t = useT(); const [companyName, setCompanyName] = useState(""); const [websiteUrl, setWebsiteUrl] = useState(""); const [websiteUrls, setWebsiteUrls] = useState([]); const [githubUrl, setGithubUrl] = useState(""); const [githubLinks, setGithubLinks] = useState([]); const [codeFiles, setCodeFiles] = useState([]); const [docFiles, setDocFiles] = useState([]); const [imageFiles, setImageFiles] = useState([]); const [brandNotes, setBrandNotes] = useState(""); const [customInstructions, setCustomInstructions] = useState(""); const [generating, setGenerating] = useState(false); const [builderIndexing, setBuilderIndexing] = useState(false); const [builderIndexResult, setBuilderIndexResult] = useState(null); const [builderIndexError, setBuilderIndexError] = useState( null, ); const codeInputRef = useRef(null); const docInputRef = useRef(null); const imageInputRef = useRef(null); const figInputRef = useRef(null); const updateSystemMutation = useActionMutation("update-design-system"); const { data: existingDs } = useActionQuery<{ title?: string; description?: string; data?: string | null; customInstructions?: string; }>("get-design-system", editingId ? { id: editingId } : undefined, { enabled: !!editingId && open, }); const { data: designSystemsData } = useActionQuery<{ designSystems: Array<{ id: string; title: string }>; }>("list-design-systems"); const existingSystems = designSystemsData?.designSystems ?? []; const [selectedSystemId, setSelectedSystemId] = useState(""); useEffect(() => { if (existingDs && editingId) { setCompanyName(existingDs.title ?? ""); setBrandNotes(existingDs.description ?? ""); setCustomInstructions(existingDs.customInstructions ?? ""); try { const parsed = existingDs.data ? JSON.parse(existingDs.data) : null; if (parsed?.notes) setBrandNotes(parsed.notes); } catch { // ignore } } }, [existingDs, editingId]); useEffect(() => { if (!open) { setCompanyName(""); setWebsiteUrl(""); setWebsiteUrls([]); setGithubUrl(""); setGithubLinks([]); setCodeFiles([]); setDocFiles([]); setImageFiles([]); setBrandNotes(""); setCustomInstructions(""); setSelectedSystemId(""); setBuilderIndexing(false); setBuilderIndexResult(null); setBuilderIndexError(null); } }, [open]); const hasAnySources = useMemo(() => { return ( companyName.trim() || websiteUrls.length > 0 || githubLinks.length > 0 || codeFiles.length > 0 || builderIndexResult || docFiles.length > 0 || imageFiles.length > 0 || selectedSystemId || brandNotes.trim() || customInstructions.trim() ); }, [ companyName, websiteUrls, githubLinks, codeFiles, builderIndexResult, docFiles, imageFiles, selectedSystemId, brandNotes, customInstructions, ]); const addWebsiteUrl = useCallback(() => { const url = normalizeWebsiteUrlInput(websiteUrl); if (!url) return; setWebsiteUrls((prev) => (prev.includes(url) ? prev : [...prev, url])); setWebsiteUrl(""); }, [websiteUrl]); const addGithubLink = useCallback(() => { const url = githubUrl.trim(); if (!url) return; setGithubLinks((prev) => [...prev, { id: crypto.randomUUID(), url }]); setGithubUrl(""); }, [githubUrl]); const readTextFiles = useCallback( ( fileList: FileList, setter: React.Dispatch>, ) => { const newFiles: UploadedFile[] = []; const promises: Promise[] = []; Array.from(fileList).forEach((f) => { const file: UploadedFile = { id: crypto.randomUUID(), name: f.name, type: f.type, size: f.size, }; if ( f.size < 200 * 1024 && (f.name.match( /\.(css|scss|sass|less|ts|tsx|js|jsx|json|html|svg|xml|md|markdown|mdx|txt)$/i, ) || f.type.startsWith("text/")) ) { promises.push( f.text().then((text) => { file.textContent = text; }), ); } newFiles.push(file); }); Promise.all(promises).then(() => { setter((prev) => [...prev, ...newFiles]); }); }, [t], ); const handleBuilderIndexUpload = useCallback( async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; if (!file.name.toLowerCase().endsWith(".fig")) { setBuilderIndexError(t("designSystemSetup.figFileRequired")); return; } if (file.size > MAX_BUILDER_INDEX_UPLOAD_BYTES) { setBuilderIndexError( t("designSystemSetup.figFileTooLarge", { maxSize: formatFileSize(MAX_BUILDER_INDEX_UPLOAD_BYTES), }), ); return; } setBuilderIndexError(null); setBuilderIndexResult(null); setBuilderIndexing(true); try { const body = new FormData(); body.append("file", file); const res = await fetch( appApiPath("/api/index-design-system-with-builder"), { method: "POST", body, }, ); const parsed = await readBuilderIndexResponse(res); setBuilderIndexResult(parsed); } catch (err) { setBuilderIndexError( err instanceof Error ? err.message : t("designSystemSetup.figParseFailed"), ); } finally { setBuilderIndexing(false); } }, [t], ); const handleEditSave = async () => { if (!editingId) return; setGenerating(true); try { await updateSystemMutation.mutateAsync({ id: editingId, title: companyName || "My Brand", description: brandNotes || undefined, customInstructions, }); onComplete(); toast.success(t("designSystemSetup.updated")); } catch { toast.error(t("designSystemSetup.updateFailed")); } finally { setGenerating(false); } }; const handleGenerate = useCallback(() => { if (editingId) { handleEditSave(); return; } // Cap inlined file content so a giant pasted README doesn't blow the // prompt budget. Append a marker so the agent doesn't treat the // truncation point as the end of the document. const TEXT_INLINE_MAX = 5000; const inlineText = (text: string) => text.length > TEXT_INLINE_MAX ? `${text.slice(0, TEXT_INLINE_MAX)}\n…[truncated, ${text.length - TEXT_INLINE_MAX} more chars]` : text; const parts: string[] = []; parts.push( "Set up a design system from the following sources. Use Builder Design System Intelligence (DSI) as the source of truth for reusable Figma/code/design.md indexing. Analyze each source, extract design tokens (colors, fonts, spacing, borders), and create a cohesive design system for my slide decks.", ); if (companyName.trim()) { parts.push(`\n## Company / Brand\n${companyName.trim()}`); } if (websiteUrls.length > 0) { parts.push( `\n## Website URLs\nAnalyze these websites for design tokens. Call \`import-from-url\` for each:\n${websiteUrls.map((u) => `- ${u}`).join("\n")}`, ); } if (githubLinks.length > 0) { parts.push( `\n## Connect Code: GitHub Repositories\nStart Builder DSI indexing for each repository with \`index-design-system-with-builder\`:\n${githubLinks.map((l) => `- ${l.url}`).join("\n")}\n\nBuilder is the source of truth for repo/code design-system indexing. The action also creates a local selectable proxy design system for Slides flows. If Builder is not connected, stop and tell me to connect Builder from Settings.`, ); } const designMdFiles = [...codeFiles, ...docFiles].filter( (file) => file.textContent && isDesignMdFile(file), ); if (codeFiles.length > 0) { const withContent = codeFiles.filter( (f) => f.textContent && !isDesignMdFile(f), ); if (withContent.length > 0) { parts.push( `\n## Connect Code: Code Files (${withContent.length} files)\nStart Builder DSI indexing with \`index-design-system-with-builder\` using these files as the \`codeFiles\` argument:`, ); for (const f of withContent) { parts.push( `\n### ${f.name}\n\`\`\`\n${inlineText(f.textContent!)}\n\`\`\``, ); } } } if (designMdFiles.length > 0) { parts.push( `\n## Optional design.md (${designMdFiles.length} file${designMdFiles.length === 1 ? "" : "s"})\nPass this content as the \`designMd\` argument to \`index-design-system-with-builder\` alongside any Figma/code sources:`, ); for (const f of designMdFiles) { parts.push( `\n### ${f.name}\n\`\`\`md\n${inlineText(f.textContent!)}\n\`\`\``, ); } } if (builderIndexResult) { parts.push( `\n## Connect Figma: Builder-Indexed Figma File\nBuilder DSI indexing has already started.\n- Design system: ${builderIndexResult.designSystemId}\n- Local selectable design system: ${builderIndexResult.localDesignSystemId ?? "(not returned)"}\n- Project: ${builderIndexResult.projectId}\n- Job: ${builderIndexResult.jobId}\n- URL: ${builderIndexResult.builderUrl}\n\nUse Builder as the source of truth for indexed tokens, assets, components, and guidance. Do not call \`create-design-system\` again for this Builder-indexed source.`, ); } if (docFiles.length > 0) { const inlined = docFiles.filter( (f) => f.textContent && !isDesignMdFile(f), ); const binary = docFiles.filter((f) => !f.textContent); if (inlined.length > 0) { parts.push( `\n## Documents (${inlined.length} text files — content inlined)\nExtract brand cues from the content below.`, ); for (const f of inlined) { parts.push( `\n### ${f.name}\n\`\`\`\n${inlineText(f.textContent!)}\n\`\`\``, ); } } if (binary.length > 0) { parts.push( `\n## Documents\nExtract brand cues. Call \`import-document\` with metadata:\n${binary.map((f) => `- ${f.name} (${f.type}, ${formatSize(f.size)})`).join("\n")}`, ); } } if (imageFiles.length > 0) { parts.push( `\n## Visual References\n${imageFiles.map((f) => `- ${f.name}`).join("\n")}`, ); } if (selectedSystemId) { const system = existingSystems.find((s) => s.id === selectedSystemId); if (system) { parts.push( `\n## Fork Existing Design System\nClone "${system.title}" as a starting point. Call \`import-design-project --designSystemId ${selectedSystemId}\``, ); } } if (brandNotes.trim()) { parts.push(`\n## Additional Notes\n${brandNotes.trim()}`); } if (customInstructions.trim()) { parts.push( `\n## Custom Instructions (durable — store on the design system)\nIf you create a local design system from non-Builder sources, pass these verbatim as the \`customInstructions\` argument. They will be re-applied every time the design system is used to generate slides:\n\n${customInstructions.trim()}`, ); } parts.push( `\n---\nAfter processing all sources, if you started Builder DSI indexing, report the Builder job/design-system URL plus the local selectable design-system id returned by \`index-design-system-with-builder\`. Do not call \`create-design-system\` again for Builder-indexed Figma/code/design.md sources. If you processed non-Builder sources into concrete tokens, call \`create-design-system\` with the combined tokens${ customInstructions.trim() ? " AND the verbatim --customInstructions string from above" : "" }. Present a summary for review.`, ); openAgentSidebar(); sendToAgentChat({ message: parts.join("\n"), submit: true }); toast(t("designSystemSetup.generationStarted"), { description: t("designSystemSetup.generationStartedDescription"), }); onComplete(); }, [ editingId, companyName, websiteUrls, githubLinks, codeFiles, builderIndexResult, docFiles, imageFiles, selectedSystemId, existingSystems, brandNotes, customInstructions, onComplete, t, ]); return ( !isOpen && onClose()}> {editingId ? t("designSystemSetup.editTitle") : t("designSystemSetup.newTitle")} {editingId ? t("designSystemSetup.editDescription") : t("designSystemSetup.newDescription")}
{/* Company Name */}
setCompanyName(e.target.value)} placeholder={t("designSystemSetup.companyBrandPlaceholder")} className="bg-accent border-border text-foreground placeholder:text-muted-foreground" />
{!editingId && ( <> {/* Figma .fig */}
{!builderIndexResult ? ( <> {builderIndexError && (
{builderIndexError}
)} ) : ( { setBuilderIndexResult(null); setBuilderIndexError(null); }} /> )}
{/* Website URL */}
setWebsiteUrl(e.target.value)} placeholder={t("designSystemSetup.websitePlaceholder")} className="bg-accent border-border text-foreground placeholder:text-muted-foreground" onBlur={() => { const normalized = normalizeWebsiteUrlInput(websiteUrl); if (normalized) setWebsiteUrl(normalized); }} onKeyDown={(e) => { if (e.key === "Enter") addWebsiteUrl(); }} />
setWebsiteUrls((p) => p.filter((_, j) => j !== i)) } />
{/* GitHub */}
setGithubUrl(e.target.value)} placeholder="https://github.com/org/repo" className="bg-accent border-border text-foreground placeholder:text-muted-foreground" onKeyDown={(e) => { if (e.key === "Enter") addGithubLink(); }} />
l.url)} onRemove={(i) => setGithubLinks((p) => p.filter((_, j) => j !== i)) } />
{/* Code Files */}
{ if (e.target.files) readTextFiles(e.target.files, setCodeFiles); e.target.value = ""; }} className="hidden" /> setCodeFiles((p) => p.filter((f) => f.id !== id)) } />
{/* Documents */}
{ if (e.target.files) readTextFiles(e.target.files, setDocFiles); e.target.value = ""; }} className="hidden" /> setDocFiles((p) => p.filter((f) => f.id !== id)) } />
{/* Images */}
{ if (!e.target.files) return; const newFiles = Array.from(e.target.files).map((f) => ({ id: crypto.randomUUID(), name: f.name, type: f.type, size: f.size, })); setImageFiles((p) => [...p, ...newFiles]); e.target.value = ""; }} className="hidden" /> setImageFiles((p) => p.filter((f) => f.id !== id)) } />
{/* Fork existing */} {existingSystems.length > 0 && (
{existingSystems .filter((s) => s.id !== editingId) .map((ds) => ( ))}
)} )} {/* Brand Notes */}