Brings the canvas onto the warm-paper design system already shipped to landing, marketplace, and SaaS surfaces, and migrates the build from Tailwind v3 → v4 to match molecule-app. Plumbing: - swap tailwindcss v3 → v4, drop autoprefixer, add @tailwindcss/postcss - delete tailwind.config.ts (v4 reads tokens from @theme blocks in CSS) - globals.css: @import "tailwindcss" + @plugin "@tailwindcss/typography" - two @theme blocks: warm-paper light defaults + always-dark surface tokens (bg-bg / ink-mute / line-strong) for terminal/console panels - [data-theme="dark"] cascade overrides the warm-paper tokens for dark - React Flow edge stroke + scrollbar + selection colour pull from semantic tokens so they flip with the theme Theme infra (ported from molecule-app, identical contracts): - lib/theme-cookie.ts: mol_theme cookie + boot script (no "use client" so server components can read the constants) - lib/theme-provider.tsx: ThemeProvider + useTheme + cookie writer with Domain=.moleculesai.app so the preference follows the user across canvas/app/market/landing subdomains AND tenant subdomains - lib/theme.ts: ColorToken union + cssVar() helper - components/ThemeToggle.tsx: 3-way System/Light/Dark picker - layout.tsx: SSR cookie read + nonce'd inline boot script (CSP needs the explicit nonce — strict-dynamic doesn't forgive an un-nonce'd inline sibling) + ThemeProvider wrapper + bg-surface/text-ink body Component migration (62 files): - Mechanical bg-zinc-* / text-zinc-* / border-zinc-* / text-white → semantic surface/ink/line tokens via perl negative-lookahead pass (preserves opacity modifiers like /80, /60) - bg-blue-500/600 → bg-accent / bg-accent-strong - text-red-* / amber-* / emerald-* → text-bad / warm / good - Tinted-state banner backgrounds (bg-red-950, bg-amber-950, bg-blue-950 etc.) intentionally left literal — they remain readable on warm-paper in light mode without inventing new state-soft tokens - TerminalTab.tsx skipped — xterm renders to canvas, not DOM - 3 unit-test assertions updated to match new token strings (credits pillTone, AuthGate overlay class, A2AEdge accent) Verification: - pnpm test: 1214/1214 pass - pnpm tsc --noEmit: clean - next build: ✓ Compiled successfully (8 routes) - dev server inspection: html data-theme stamped, body uses bg-surface text-ink, boot script carries nonce, compiled CSS contains both @theme blocks + [data-theme="dark"] override Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
593 lines
23 KiB
TypeScript
593 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import { flushSync } from "react-dom";
|
|
import { api } from "@/lib/api";
|
|
import { useCanvasStore } from "@/store/canvas";
|
|
import type { WorkspaceData } from "@/store/socket";
|
|
import { type Template } from "@/lib/deploy-preflight";
|
|
import { useTemplateDeploy } from "@/hooks/useTemplateDeploy";
|
|
import {
|
|
OrgImportPreflightModal,
|
|
type EnvRequirement,
|
|
} from "./OrgImportPreflightModal";
|
|
import { ConfirmDialog } from "./ConfirmDialog";
|
|
import { Spinner } from "./Spinner";
|
|
import { showToast } from "./Toaster";
|
|
import { TIER_CONFIG } from "@/lib/design-tokens";
|
|
import { listSecrets } from "@/lib/api/secrets";
|
|
|
|
// `Template` type and `resolveRuntime` helper now live in
|
|
// `@/lib/deploy-preflight` so EmptyState can import the same ones. Was
|
|
// redeclared here + a narrower redeclaration in EmptyState; the
|
|
// narrower one dropped `runtime`, `models`, `required_env`, which is
|
|
// exactly the data the preflight needs. See reviewer's "runtime
|
|
// fallback drift" note — single source of truth closes the drift.
|
|
export interface OrgTemplate {
|
|
dir: string;
|
|
name: string;
|
|
description: string;
|
|
workspaces: number;
|
|
/** Env vars that MUST be set as global secrets before the org can
|
|
* import. Server refuses the import with 412 if any are missing;
|
|
* the canvas preflights against /secrets/list to avoid the round
|
|
* trip. Aggregated from org-level + every workspace in the tree.
|
|
*
|
|
* Each entry is either a key name (strict) or an `{any_of: [...]}`
|
|
* group (any one of the listed members satisfies the requirement —
|
|
* e.g. `ANTHROPIC_API_KEY` OR `CLAUDE_CODE_OAUTH_TOKEN`). */
|
|
required_env?: EnvRequirement[];
|
|
/** "Nice-to-have" tier. Import proceeds without them but features
|
|
* may degrade — a channel's webhook posts get dropped, a fallback
|
|
* LLM isn't available, etc. Surfaced to the user as a non-blocking
|
|
* warning with an "add now" affordance. Same union shape as
|
|
* `required_env`. */
|
|
recommended_env?: EnvRequirement[];
|
|
}
|
|
|
|
/** 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 [];
|
|
}
|
|
}
|
|
|
|
/** Server response from POST /org/import. The handler returns 207
|
|
* (StatusMultiStatus) with a populated `error` field when only some of
|
|
* the workspaces in the tree could be created — the HTTP status alone
|
|
* isn't enough to detect a partial failure. */
|
|
interface OrgImportResponse {
|
|
org: string;
|
|
workspaces: Array<{ id: string; name: string }>;
|
|
count: number;
|
|
error?: string;
|
|
}
|
|
|
|
/** Import an org template by directory name. Throws on platform error
|
|
* so the caller can surface the message in its error state. Also throws
|
|
* on 2xx-with-error-body (StatusMultiStatus) — without this check a
|
|
* partial failure (e.g. first workspace INSERT fails, 0 created)
|
|
* appears as a green success toast and the user sees no canvas update.
|
|
*
|
|
* Uses a long timeout because createWorkspaceTree paces sibling DB
|
|
* inserts by `workspaceCreatePacingMs` (2s) to avoid overwhelming
|
|
* Docker — a 15-workspace tree sleeps ~28s in the handler alone,
|
|
* which blows past the default 15s and makes the client report a
|
|
* spurious "signal timed out" error even though the server finished
|
|
* successfully. 2min covers trees up to ~60 workspaces. */
|
|
const ORG_IMPORT_TIMEOUT_MS = 120_000;
|
|
|
|
export async function importOrgTemplate(dir: string): Promise<OrgImportResponse> {
|
|
const resp = await api.post<OrgImportResponse>(
|
|
"/org/import",
|
|
{ dir },
|
|
{ timeoutMs: ORG_IMPORT_TIMEOUT_MS },
|
|
);
|
|
if (resp && resp.error) {
|
|
throw new Error(`${resp.error} (created ${resp.count ?? 0} workspaces)`);
|
|
}
|
|
return resp;
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
// Preflight modal state. `preflight` is non-null when the user
|
|
// clicked Import on an org with declared required/recommended envs
|
|
// and we're waiting for them to confirm; null otherwise (direct
|
|
// import path for orgs with zero env requirements).
|
|
const [preflight, setPreflight] = useState<{
|
|
org: OrgTemplate;
|
|
configuredKeys: Set<string>;
|
|
} | null>(null);
|
|
// Collapsed by default — org templates are multi-workspace imports
|
|
// that most new users don't reach for first. Keeping them
|
|
// expand-on-demand frees ~400 px of vertical space for the
|
|
// individual workspace templates above, which is the primary
|
|
// deploy path. The count in the header still makes discovery
|
|
// obvious: "Org Templates (4) ▸".
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
const loadOrgs = useCallback(async () => {
|
|
setLoading(true);
|
|
setOrgs(await fetchOrgTemplates());
|
|
setLoading(false);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadOrgs();
|
|
}, [loadOrgs]);
|
|
|
|
/** Fetch the set of global secret KEYS that are already configured.
|
|
* Used to strike through already-set entries in the preflight modal
|
|
* and to decide whether the import needs the modal at all. */
|
|
const loadConfiguredKeys = useCallback(async (): Promise<Set<string>> => {
|
|
try {
|
|
const secrets = await listSecrets("global");
|
|
return new Set(secrets.map((s) => s.name));
|
|
} catch {
|
|
// Secrets endpoint unreachable → assume nothing configured.
|
|
// The server will refuse the import with 412 and the user
|
|
// retries; safer than letting the import fly blind.
|
|
return new Set();
|
|
}
|
|
}, []);
|
|
|
|
/** Actually run the import. Split out so both the "no preflight
|
|
* needed" fast path and the "preflight modal approved" path can
|
|
* share the fetch + hydrate + toast sequence. */
|
|
const doImport = useCallback(async (org: OrgTemplate) => {
|
|
setImporting(org.dir);
|
|
setError(null);
|
|
try {
|
|
await importOrgTemplate(org.dir);
|
|
// Hydrate is the safety net for the "WS is offline" case —
|
|
// without live events the canvas stays empty. But calling it
|
|
// immediately wipes the org-deploy animation (hydrate rebuilds
|
|
// the node array from scratch, dropping the spawn / shimmer
|
|
// classes and position tweens). So:
|
|
// 1. If the number of nodes on the canvas already matches
|
|
// (or exceeds) the template's workspace count, WS
|
|
// delivered everything — skip hydrate.
|
|
// 2. Otherwise, wait a short window to let any in-flight WS
|
|
// events land, then hydrate only if still behind.
|
|
const expectedCount = org.workspaces;
|
|
// Nodes transition through WORKSPACE_REMOVED which physically
|
|
// drops them from the store — there is no "removed" status in
|
|
// WorkspaceNodeData — so a simple length check is enough here.
|
|
const hasAll = () => useCanvasStore.getState().nodes.length >= expectedCount;
|
|
if (!hasAll()) {
|
|
await new Promise((r) => setTimeout(r, 1500));
|
|
}
|
|
if (!hasAll()) {
|
|
try {
|
|
const workspaces = await api.get<WorkspaceData[]>("/workspaces");
|
|
useCanvasStore.getState().hydrate(workspaces);
|
|
} catch {
|
|
// WS (if alive) or the next health-check cycle will
|
|
// eventually pick the new workspaces up.
|
|
}
|
|
}
|
|
showToast(`Imported "${org.name || org.dir}" (${org.workspaces} workspaces)`, "success");
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : "Import failed";
|
|
setError(msg);
|
|
showToast(`Import failed: ${msg}`, "error");
|
|
} finally {
|
|
setImporting(null);
|
|
}
|
|
}, []);
|
|
|
|
/** Entry point for the Import button. Two paths:
|
|
*
|
|
* 1. No env declared by the template (required_env + recommended_env
|
|
* both empty) → fire doImport directly. Matches the pre-preflight
|
|
* behaviour for existing templates.
|
|
*
|
|
* 2. Any env declared → load the configured-keys set and open the
|
|
* preflight modal. doImport runs only when the user clicks
|
|
* Import inside the modal, which is gated to "required envs all
|
|
* configured" by the modal itself. */
|
|
const handleImport = useCallback(async (org: OrgTemplate) => {
|
|
const hasEnvDeclarations =
|
|
(org.required_env && org.required_env.length > 0) ||
|
|
(org.recommended_env && org.recommended_env.length > 0);
|
|
if (!hasEnvDeclarations) {
|
|
void doImport(org);
|
|
return;
|
|
}
|
|
// Flip the button to its "Importing…" state while the secrets
|
|
// lookup runs — on a tenant with 500+ global secrets the round
|
|
// trip can be > 200 ms and the user otherwise gets zero visual
|
|
// feedback after clicking. Cleared on modal close / error.
|
|
setImporting(org.dir);
|
|
try {
|
|
const configuredKeys = await loadConfiguredKeys();
|
|
setPreflight({ org, configuredKeys });
|
|
} finally {
|
|
setImporting(null);
|
|
}
|
|
}, [doImport, loadConfiguredKeys]);
|
|
|
|
/** Called by the preflight modal after a successful key save so the
|
|
* strike-through re-renders and canProceed recomputes. */
|
|
const refreshConfiguredKeys = useCallback(async () => {
|
|
const keys = await loadConfiguredKeys();
|
|
setPreflight((prev) => (prev ? { ...prev, configuredKeys: keys } : prev));
|
|
}, [loadConfiguredKeys]);
|
|
|
|
return (
|
|
<div className="space-y-2" data-testid="org-templates-section">
|
|
<div className="flex items-center justify-between">
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded((v) => !v)}
|
|
aria-expanded={expanded}
|
|
aria-controls="org-templates-body"
|
|
className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-ink-soft hover:text-ink-mid font-semibold transition-colors"
|
|
>
|
|
<span
|
|
aria-hidden="true"
|
|
className={`inline-block text-[8px] transition-transform duration-150 ${expanded ? "rotate-90" : ""}`}
|
|
>
|
|
▶
|
|
</span>
|
|
Org Templates
|
|
{orgs.length > 0 && (
|
|
<span className="text-ink-soft normal-case tracking-normal">
|
|
({orgs.length})
|
|
</span>
|
|
)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={loadOrgs}
|
|
aria-label="Refresh org templates"
|
|
className="text-[10px] text-ink-soft hover:text-ink-mid"
|
|
>
|
|
↻
|
|
</button>
|
|
</div>
|
|
|
|
{expanded && (
|
|
<div id="org-templates-body" className="space-y-2">
|
|
{loading && (
|
|
<div role="status" aria-live="polite" className="flex items-center gap-1.5 text-[10px] text-ink-soft">
|
|
<Spinner size="sm" />
|
|
Loading…
|
|
</div>
|
|
)}
|
|
|
|
{!loading && orgs.length === 0 && (
|
|
<div className="text-[10px] text-ink-soft">
|
|
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-bad">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{orgs.map((o) => {
|
|
const isImporting = importing === o.dir;
|
|
return (
|
|
<div
|
|
key={o.dir}
|
|
className="bg-surface-sunken/50 border border-line/60 rounded-xl p-3 hover:border-line/60 transition-all"
|
|
>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="text-[12px] font-semibold text-ink truncate">
|
|
{o.name || o.dir}
|
|
</span>
|
|
<span className="text-[9px] font-mono text-sky-400 bg-sky-950/40 px-1.5 py-0.5 rounded-md shrink-0">
|
|
{o.workspaces} workspaces
|
|
</span>
|
|
</div>
|
|
{o.description && (
|
|
<p className="text-[10px] text-ink-soft mb-2.5 line-clamp-2 leading-relaxed">
|
|
{o.description}
|
|
</p>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => handleImport(o)}
|
|
disabled={isImporting}
|
|
className="w-full px-2 py-1.5 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[10px] text-accent font-medium transition-colors disabled:opacity-50"
|
|
>
|
|
{isImporting ? "Importing…" : "Import org"}
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{preflight && (
|
|
<OrgImportPreflightModal
|
|
open
|
|
orgName={preflight.org.name || preflight.org.dir}
|
|
workspaceCount={preflight.org.workspaces}
|
|
requiredEnv={preflight.org.required_env ?? []}
|
|
recommendedEnv={preflight.org.recommended_env ?? []}
|
|
configuredKeys={preflight.configuredKeys}
|
|
onSecretSaved={refreshConfiguredKeys}
|
|
onProceed={() => {
|
|
const org = preflight.org;
|
|
// flushSync guarantees the modal unmounts BEFORE we kick
|
|
// off the import network call. Without it, React batches
|
|
// setPreflight(null) with the setImporting(...) from
|
|
// doImport's synchronous prefix, both commit at the end
|
|
// of this handler, AND the await import() POST may yield
|
|
// a microtask before React schedules the paint. Net
|
|
// effect: the modal backdrop sat over the canvas during
|
|
// the first wave of WORKSPACE_PROVISIONING WS events,
|
|
// hiding the spawn animation. Force the close to land
|
|
// first so the user sees the canvas reveal + agents
|
|
// popping into place.
|
|
flushSync(() => setPreflight(null));
|
|
void doImport(org);
|
|
}}
|
|
onCancel={() => setPreflight(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const TIER_LABELS = TIER_CONFIG;
|
|
|
|
function ImportAgentButton({ onImported }: { onImported: () => void }) {
|
|
const [importing, setImporting] = useState(false);
|
|
const [notice, setNotice] = useState<string | null>(null);
|
|
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) {
|
|
setNotice("No files found in the selected folder");
|
|
return;
|
|
}
|
|
|
|
const name = agentName || "Imported Agent";
|
|
await api.post("/templates/import", { name, files });
|
|
onImported();
|
|
} catch (e) {
|
|
setNotice(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
|
|
type="button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={importing}
|
|
className="w-full px-3 py-2 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[11px] text-accent font-medium transition-colors disabled:opacity-50"
|
|
>
|
|
{importing ? "Importing..." : "Import Agent Folder"}
|
|
</button>
|
|
<ConfirmDialog
|
|
open={!!notice}
|
|
title="Import"
|
|
message={notice ?? ""}
|
|
confirmLabel="OK"
|
|
confirmVariant="primary"
|
|
singleButton
|
|
onConfirm={() => setNotice(null)}
|
|
onCancel={() => setNotice(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function TemplatePalette() {
|
|
const [open, setOpen] = useState(false);
|
|
// Publish palette-open state to the canvas store so Legend (and any
|
|
// future floating left-bottom UI) can shift right to avoid being
|
|
// hidden behind the 280 px palette drawer.
|
|
const setTemplatePaletteOpen = useCanvasStore((s) => s.setTemplatePaletteOpen);
|
|
useEffect(() => {
|
|
setTemplatePaletteOpen(open);
|
|
}, [open, setTemplatePaletteOpen]);
|
|
|
|
const [templates, setTemplates] = useState<Template[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
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]);
|
|
|
|
// Preflight + POST + modal wiring moved into useTemplateDeploy so
|
|
// this component and EmptyState use one implementation. The sidebar
|
|
// uses the hook's default random canvas placement (no override) —
|
|
// an already-populated canvas shouldn't have new deploys stacking on
|
|
// a single fixed point. No post-deploy side effect either: the
|
|
// palette is operator-triggered, so auto-selecting would yank
|
|
// focus off whatever the user was already looking at.
|
|
const { deploy: handleDeploy, deploying: creating, error, modal } =
|
|
useTemplateDeploy();
|
|
|
|
return (
|
|
<>
|
|
{/* Toggle button */}
|
|
<button
|
|
type="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-accent-strong text-ink"
|
|
: "bg-surface-sunken/90 border border-line/50 text-ink-mid hover:text-ink hover:border-line"
|
|
}`}
|
|
title="Template Palette"
|
|
aria-label={open ? "Close template palette" : "Open 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 — rendered by the shared hook. Same
|
|
instance shape used by EmptyState. */}
|
|
{modal}
|
|
|
|
{/* Sidebar */}
|
|
{open && (
|
|
<div className="fixed top-0 left-0 h-full w-[280px] bg-surface-sunken/95 backdrop-blur-md border-r border-line/60 z-30 flex flex-col shadow-2xl shadow-black/40">
|
|
<div className="px-4 pt-14 pb-3 border-b border-line/60">
|
|
<h2 className="text-sm font-semibold text-ink">Templates</h2>
|
|
<p className="text-[10px] text-ink-soft mt-0.5">Click to deploy a workspace</p>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
|
{/* Org templates live INSIDE the scroll container so an
|
|
* expanded list (15+ entries) is reachable instead of
|
|
* overflowing the fixed footer below. */}
|
|
<OrgTemplatesSection />
|
|
|
|
{loading && (
|
|
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 text-xs text-ink-soft text-center py-8">
|
|
<Spinner />
|
|
Loading…
|
|
</div>
|
|
)}
|
|
|
|
{!loading && templates.length === 0 && (
|
|
<div role="status" aria-live="polite" className="text-xs text-ink-soft 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-bad">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{templates.map((t) => {
|
|
const tierCfg = TIER_LABELS[t.tier] || TIER_LABELS[1];
|
|
const isDeploying = creating === t.id;
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
key={t.id}
|
|
onClick={() => void handleDeploy(t)}
|
|
disabled={isDeploying}
|
|
className="w-full text-left bg-surface-card/40 hover:bg-surface-card/70 border border-line/40 hover:border-line/50 rounded-xl p-3 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-surface-card/40 disabled:hover:border-line/40 group focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/70"
|
|
>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="text-[12px] font-semibold text-ink group-hover:text-ink 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-ink-soft 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-ink-mid bg-surface-card/40 px-1.5 py-0.5 rounded">
|
|
{s}
|
|
</span>
|
|
))}
|
|
{t.skills.length > 3 && (
|
|
<span className="text-[8px] text-ink-soft">+{t.skills.length - 3}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{isDeploying && (
|
|
<div className="text-[10px] text-sky-400 mt-1.5 motion-safe:animate-pulse">Deploying...</div>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="px-4 py-3 border-t border-line/60 space-y-3">
|
|
<ImportAgentButton onImported={loadTemplates} />
|
|
<button
|
|
type="button"
|
|
onClick={loadTemplates}
|
|
className="text-[10px] text-ink-soft hover:text-ink-mid transition-colors block"
|
|
>
|
|
Refresh templates
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|