molecule-core/canvas/src/components/TemplatePalette.tsx
Hongming Wang 24fec62d7f initial commit — Molecule AI platform
Forked clean from public hackathon repo (Starfire-AgentTeam, BSL 1.1)
with full rebrand to Molecule AI under github.com/Molecule-AI/molecule-monorepo.

Brand: Starfire → Molecule AI.
Slug: starfire / agent-molecule → molecule.
Env vars: STARFIRE_* → MOLECULE_*.
Go module: github.com/agent-molecule/platform → github.com/Molecule-AI/molecule-monorepo/platform.
Python packages: starfire_plugin → molecule_plugin, starfire_agent → molecule_agent.
DB: agentmolecule → molecule.

History truncated; see public repo for prior commits and contributor
attribution. Verified green: go test -race ./... (platform), pytest
(workspace-template 1129 + sdk 132), vitest (canvas 352), build (mcp).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:55:37 -07:00

416 lines
14 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { api } from "@/lib/api";
import { checkDeploySecrets, type PreflightResult } from "@/lib/deploy-preflight";
import { MissingKeysModal } from "./MissingKeysModal";
interface Template {
id: string;
name: string;
description: string;
tier: number;
model: string;
skills: string[];
skill_count: number;
}
export interface OrgTemplate {
dir: string;
name: string;
description: string;
workspaces: number;
}
/** Fetch the list of org templates from the platform. Returns [] on error
* so the UI shows the empty state instead of crashing. */
export async function fetchOrgTemplates(): Promise<OrgTemplate[]> {
try {
return await api.get<OrgTemplate[]>("/org/templates");
} catch {
return [];
}
}
/** Import an org template by directory name. Throws on platform error so the
* caller can surface the message in its error state. */
export async function importOrgTemplate(dir: string): Promise<void> {
await api.post("/org/import", { dir });
}
/**
* Section listing org templates (multi-workspace hierarchies). Click "Import"
* to instantiate the entire tree via `POST /org/import { dir }`. PLAN.md §20.3.
*
* Exported separately so the org import flow has a focused unit-test surface
* without re-rendering the full palette.
*/
export function OrgTemplatesSection() {
const [orgs, setOrgs] = useState<OrgTemplate[]>([]);
const [loading, setLoading] = useState(false);
const [importing, setImporting] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const loadOrgs = useCallback(async () => {
setLoading(true);
setOrgs(await fetchOrgTemplates());
setLoading(false);
}, []);
useEffect(() => {
loadOrgs();
}, [loadOrgs]);
const handleImport = async (org: OrgTemplate) => {
setImporting(org.dir);
setError(null);
try {
await importOrgTemplate(org.dir);
} catch (e) {
setError(e instanceof Error ? e.message : "Import failed");
} finally {
setImporting(null);
}
};
return (
<div className="space-y-2" data-testid="org-templates-section">
<div className="flex items-center justify-between">
<h3 className="text-[10px] uppercase tracking-wide text-zinc-500 font-semibold">
Org Templates
</h3>
<button
onClick={loadOrgs}
className="text-[9px] text-zinc-500 hover:text-zinc-300"
>
</button>
</div>
{loading && <div className="text-[10px] text-zinc-500">Loading</div>}
{!loading && orgs.length === 0 && (
<div className="text-[10px] text-zinc-500">
No org templates in <code>org-templates/</code>
</div>
)}
{error && (
<div className="px-2 py-1 bg-red-950/40 border border-red-800/50 rounded text-[10px] text-red-400">
{error}
</div>
)}
{orgs.map((o) => {
const isImporting = importing === o.dir;
return (
<div
key={o.dir}
className="bg-zinc-800/30 border border-zinc-700/40 rounded-lg p-2.5"
>
<div className="flex items-center justify-between mb-0.5">
<span className="text-[11px] font-semibold text-zinc-200 truncate">
{o.name || o.dir}
</span>
<span className="text-[9px] font-mono text-violet-400 bg-violet-950/40 px-1.5 py-0.5 rounded shrink-0">
{o.workspaces}w
</span>
</div>
{o.description && (
<p className="text-[10px] text-zinc-500 mb-2 line-clamp-2 leading-relaxed">
{o.description}
</p>
)}
<button
onClick={() => handleImport(o)}
disabled={isImporting}
className="w-full px-2 py-1 bg-violet-600/20 hover:bg-violet-600/30 border border-violet-500/30 rounded text-[10px] text-violet-300 font-medium transition-colors disabled:opacity-50"
>
{isImporting ? "Importing…" : "Import org"}
</button>
</div>
);
})}
</div>
);
}
const TIER_LABELS: Record<number, { label: string; color: string }> = {
1: { label: "T1", color: "text-zinc-400 bg-zinc-800/60" },
2: { label: "T2", color: "text-sky-400 bg-sky-950/40" },
3: { label: "T3", color: "text-violet-400 bg-violet-950/40" },
4: { label: "T4", color: "text-amber-400 bg-amber-950/40" },
};
function ImportAgentButton({ onImported }: { onImported: () => void }) {
const [importing, setImporting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFiles = async (fileList: FileList) => {
setImporting(true);
try {
const files: Record<string, string> = {};
let agentName = "";
for (const file of Array.from(fileList)) {
// webkitRelativePath gives us "folder/file.md"
const path = file.webkitRelativePath || file.name;
// Strip the top-level folder name
const parts = path.split("/");
if (!agentName && parts.length > 1) {
agentName = parts[0];
}
const relPath = parts.length > 1 ? parts.slice(1).join("/") : parts[0];
// Only import text files
if (file.size > 1_000_000) continue; // skip files > 1MB
try {
const content = await file.text();
files[relPath] = content;
} catch {
// Skip binary files
}
}
if (Object.keys(files).length === 0) {
alert("No files found in the selected folder");
return;
}
const name = agentName || "Imported Agent";
await api.post("/templates/import", { name, files });
onImported();
} catch (e) {
alert(e instanceof Error ? e.message : "Import failed");
} finally {
setImporting(false);
}
};
return (
<div>
<input
ref={fileInputRef}
type="file"
// @ts-expect-error webkitdirectory is non-standard but widely supported
webkitdirectory=""
multiple
className="hidden"
onChange={(e) => e.target.files && handleFiles(e.target.files)}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={importing}
className="w-full px-3 py-2 bg-blue-600/20 hover:bg-blue-600/30 border border-blue-500/30 rounded-lg text-[11px] text-blue-300 font-medium transition-colors disabled:opacity-50"
>
{importing ? "Importing..." : "Import Agent Folder"}
</button>
</div>
);
}
export function TemplatePalette() {
const [open, setOpen] = useState(false);
const [templates, setTemplates] = useState<Template[]>([]);
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Missing keys modal state
const [missingKeysInfo, setMissingKeysInfo] = useState<{
template: Template;
preflight: PreflightResult;
} | null>(null);
const loadTemplates = useCallback(async () => {
setLoading(true);
try {
const data = await api.get<Template[]>("/templates");
setTemplates(data);
} catch {
setTemplates([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) loadTemplates();
}, [open, loadTemplates]);
/** Resolve runtime from template ID (e.g., "langgraph", "claude-code-default" → "claude-code") */
const resolveRuntime = (templateId: string): string => {
const runtimeMap: Record<string, string> = {
langgraph: "langgraph",
"claude-code-default": "claude-code",
openclaw: "openclaw",
deepagents: "deepagents",
crewai: "crewai",
autogen: "autogen",
};
return runtimeMap[templateId] ?? templateId.replace(/-default$/, "");
};
/** Actually execute the deploy API call */
const executeDeploy = useCallback(async (template: Template) => {
setCreating(template.id);
setError(null);
try {
await api.post("/workspaces", {
name: template.name,
template: template.id,
tier: template.tier,
canvas: {
x: Math.random() * 400 + 100,
y: Math.random() * 300 + 100,
},
});
setCreating(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to deploy");
setCreating(null);
}
}, []);
/** Pre-deploy check: validate secrets before deploying */
const handleDeploy = async (template: Template) => {
setCreating(template.id);
setError(null);
const runtime = resolveRuntime(template.id);
const preflight = await checkDeploySecrets(runtime);
if (!preflight.ok) {
// Missing keys — show the modal instead of deploying
setMissingKeysInfo({ template, preflight });
setCreating(null);
return;
}
// All keys present — deploy directly
await executeDeploy(template);
};
return (
<>
{/* Toggle button */}
<button
onClick={() => setOpen(!open)}
className={`fixed top-4 left-4 z-40 w-9 h-9 flex items-center justify-center rounded-lg transition-colors ${
open
? "bg-blue-600 text-white"
: "bg-zinc-900/90 border border-zinc-700/50 text-zinc-400 hover:text-zinc-200 hover:border-zinc-600"
}`}
title="Template Palette"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1" y="1" width="6" height="6" rx="1" stroke="currentColor" strokeWidth="1.5" />
<rect x="9" y="1" width="6" height="6" rx="1" stroke="currentColor" strokeWidth="1.5" />
<rect x="1" y="9" width="6" height="6" rx="1" stroke="currentColor" strokeWidth="1.5" />
<rect x="9" y="9" width="6" height="6" rx="1" stroke="currentColor" strokeWidth="1.5" />
</svg>
</button>
{/* Missing Keys Modal */}
<MissingKeysModal
open={!!missingKeysInfo}
missingKeys={missingKeysInfo?.preflight.missingKeys ?? []}
runtime={missingKeysInfo?.preflight.runtime ?? ""}
onKeysAdded={() => {
if (missingKeysInfo) {
const template = missingKeysInfo.template;
setMissingKeysInfo(null);
executeDeploy(template);
}
}}
onCancel={() => setMissingKeysInfo(null)}
/>
{/* Sidebar */}
{open && (
<div className="fixed top-0 left-0 h-full w-[280px] bg-zinc-900/95 backdrop-blur-md border-r border-zinc-800/60 z-30 flex flex-col shadow-2xl shadow-black/40">
<div className="px-4 pt-14 pb-3 border-b border-zinc-800/60">
<h2 className="text-sm font-semibold text-zinc-100">Templates</h2>
<p className="text-[10px] text-zinc-500 mt-0.5">Click to deploy a workspace</p>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{loading && (
<div className="text-xs text-zinc-500 text-center py-8">Loading...</div>
)}
{!loading && templates.length === 0 && (
<div className="text-xs text-zinc-500 text-center py-8">
No templates found in<br />workspace-configs-templates/
</div>
)}
{error && (
<div className="px-3 py-1.5 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-red-400">
{error}
</div>
)}
{templates.map((t) => {
const tierCfg = TIER_LABELS[t.tier] || TIER_LABELS[1];
const isDeploying = creating === t.id;
return (
<button
key={t.id}
onClick={() => handleDeploy(t)}
disabled={isDeploying}
className="w-full text-left bg-zinc-800/40 hover:bg-zinc-800/70 border border-zinc-700/40 hover:border-zinc-600/50 rounded-xl p-3 transition-all disabled:opacity-50 group"
>
<div className="flex items-center justify-between mb-1">
<span className="text-[12px] font-semibold text-zinc-200 group-hover:text-zinc-100 truncate">
{t.name}
</span>
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded-md shrink-0 ${tierCfg.color}`}>
{tierCfg.label}
</span>
</div>
{t.description && (
<p className="text-[10px] text-zinc-500 mb-2 line-clamp-2 leading-relaxed">
{t.description}
</p>
)}
{t.skills?.length > 0 && (
<div className="flex flex-wrap gap-1">
{t.skills.slice(0, 3).map((s) => (
<span key={s} className="text-[8px] text-zinc-400 bg-zinc-700/40 px-1.5 py-0.5 rounded">
{s}
</span>
))}
{t.skills.length > 3 && (
<span className="text-[8px] text-zinc-500">+{t.skills.length - 3}</span>
)}
</div>
)}
{isDeploying && (
<div className="text-[10px] text-sky-400 mt-1.5 animate-pulse">Deploying...</div>
)}
</button>
);
})}
</div>
<div className="px-4 py-3 border-t border-zinc-800/60 space-y-3">
<OrgTemplatesSection />
<ImportAgentButton onImported={loadTemplates} />
<button
onClick={loadTemplates}
className="text-[10px] text-zinc-500 hover:text-zinc-300 transition-colors block"
>
Refresh templates
</button>
</div>
</div>
)}
</>
);
}