"use client"; import { useState, useEffect, useRef, useCallback, useId, useMemo } from "react"; import * as Dialog from "@radix-ui/react-dialog"; import { api } from "@/lib/api"; import { isSaaSTenant } from "@/lib/tenant"; import { ExternalConnectModal, type ExternalConnectionInfo } from "./ExternalConnectModal"; interface WorkspaceOption { id: string; name: string; tier: number; } // Subset of the /templates row used here. Mirrors the shape ConfigTab // reads. `providers` is the per-template declarative list of supported // LLM providers — sourced from the template's // runtime_config.providers (config.yaml). When present, it filters // the modal's provider . Empty/missing list falls back to the full HERMES_PROVIDERS // catalog so older templates without the field keep working. const [templateSpecs, setTemplateSpecs] = useState([]); // External-runtime path: skip docker provision, mint a workspace_auth_token, // and surface the connection snippet in a modal after create. When // isExternal is true the template / model / hermes-provider fields are // hidden (they're meaningless for BYO-compute agents). const [isExternal, setIsExternal] = useState(false); const [externalConnection, setExternalConnection] = useState(null); // Hermes-specific state const [hermesProvider, setHermesProvider] = useState("anthropic"); const [hermesApiKey, setHermesApiKey] = useState(""); // Model slug is sent to CP as `model` and plumbed to the workspace EC2 // as HERMES_DEFAULT_MODEL env var. template-hermes's derive-provider.sh // reads the prefix (`minimax/…`, `anthropic/…`) to set // HERMES_INFERENCE_PROVIDER at install time. Missing model → provider // falls back to "auto" and hermes picks its compiled-in default // (Anthropic), which 401s if the user's key is for a different // provider. Hence: require model when template=hermes. const [hermesModel, setHermesModel] = useState(""); // Tier picker: on SaaS every workspace gets its own EC2 VM (Full Access // by construction), so we hide the T1/T2/T3 Docker-sandbox tiers and // lock to T4 — the full-host access tier, which maps to t3.large at the // CP level. On self-hosted we still offer T1/T2/T3 because the Docker- // sandbox distinction is a real choice there; T4 is available too for // operators who want the full-host tier. // // SSR-safe via isSaaSTenant() contract (returns false on server); first // client render may flip the picker — acceptable one-frame reflow. const isSaaS = useMemo(() => isSaaSTenant(), []); const TIERS = useMemo( () => isSaaS ? [{ value: 4, label: "T4", desc: "Full Access" }] : [ { value: 1, label: "T1", desc: "Sandboxed" }, { value: 2, label: "T2", desc: "Standard" }, { value: 3, label: "T3", desc: "Privileged" }, { value: 4, label: "T4", desc: "Full Access" }, ], [isSaaS], ); // T3 ("Privileged") is the self-hosted default — gives agents the // read_write workspace mount + Docker daemon access most templates // expect to do real work. T1 sandboxed and T2 standard are kept as // explicit opt-ins for low-trust agents. SaaS still defaults to T4 // because every SaaS workspace gets its own EC2 (sibling VMs, no // shared blast radius — see isSaaSTenant() / tier picker hide logic). const defaultTier = isSaaS ? 4 : 3; const [tier, setTier] = useState(defaultTier); // Refs for roving tabIndex on the tier radio group (WCAG 2.1 arrow-key nav) const radioRefs = useRef>([]); const handleRadioKeyDown = useCallback( (e: React.KeyboardEvent, currentIndex: number) => { if (e.key === "ArrowDown" || e.key === "ArrowRight") { e.preventDefault(); const next = (currentIndex + 1) % TIERS.length; setTier(TIERS[next].value); radioRefs.current[next]?.focus(); } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") { e.preventDefault(); const prev = (currentIndex - 1 + TIERS.length) % TIERS.length; setTier(TIERS[prev].value); radioRefs.current[prev]?.focus(); } }, // TIERS is stable (module-level constant pattern), setTier is stable from useState // eslint-disable-next-line react-hooks/exhaustive-deps [] ); const isHermes = template.trim().toLowerCase() === "hermes"; // Resolve the selected template's spec from the /templates response. // The `template` input is free-text; templates can be matched by id, // name, or runtime so any of those work. Lower-cased compare keeps // "Hermes" / "hermes" / "HERMES" interchangeable. const selectedTemplateSpec = useMemo(() => { const t = template.trim().toLowerCase(); if (!t) return null; return ( templateSpecs.find( (s) => (s.id || "").toLowerCase() === t || (s.name || "").toLowerCase() === t || (s.runtime || "").toLowerCase() === t, ) ?? null ); }, [template, templateSpecs]); // Filter HERMES_PROVIDERS by what the template declares it supports. // Empty/missing declared list → fall back to the full catalog so // templates that haven't migrated to the explicit `providers:` field // (and self-hosted setups without /templates) keep working unchanged. const availableProviders = useMemo(() => { const declared = selectedTemplateSpec?.providers; if (!declared || declared.length === 0) return HERMES_PROVIDERS; const allowed = new Set(declared.map((p) => p.toLowerCase())); const filtered = HERMES_PROVIDERS.filter((p) => allowed.has(p.id.toLowerCase())); // Defensive: if the template's declared list doesn't match anything // in our static catalog (e.g. brand-new provider id we don't have // metadata for yet), fall back to the full list rather than render // an empty setIsExternal(e.target.checked)} className="mt-0.5" />
External agent (bring your own compute)
Skip the container. We'll return a workspace_id + auth token + ready-to-paste snippet so an agent running on your laptop / server / CI can register via A2A.
{!isExternal && ( )}
Tier{isSaaS ? " — dedicated VM" : ""}
{TIERS.map((t, idx) => ( ))}
{/* Hermes provider configuration — shown only when template === "hermes" */} {isHermes && (

Hermes Provider

Choose the AI provider and paste your API key. The key is stored as an encrypted workspace secret.

setHermesApiKey(e.target.value)} placeholder="sk-…" aria-label="Hermes API key" autoComplete="off" className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors font-mono" />
setHermesModel(e.target.value)} placeholder="e.g. minimax/MiniMax-M2.7" aria-label="Hermes model slug" autoComplete="off" spellCheck={false} list="hermes-model-suggestions" className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors font-mono" /> {HERMES_PROVIDERS.find((p) => p.id === hermesProvider)?.models.map( (m) =>

Slug determines which provider hermes routes to at install time.

)} {error && (
{error}
)}
{/* Rendered as a sibling so it stays mounted after the create dialog closes. Without this the auth_token would disappear the moment the create modal unmounted its React subtree — the operator would never see the copy-paste snippet. */} setExternalConnection(null)} /> ); } function InputField({ label, value, onChange, placeholder, required, mono, type = "text", helper, }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string; required?: boolean; mono?: boolean; type?: string; helper?: string; }) { // useId() generates a stable, unique ID for the label↔input association, // satisfying WCAG 2.1 SC 1.3.1 (Info and Relationships, Level A). const inputId = useId(); return (
onChange(e.target.value)} placeholder={placeholder} min={type === "number" ? "0" : undefined} step={type === "number" ? "0.01" : undefined} className={`w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors ${mono ? "font-mono text-xs" : ""}`} /> {helper && (

{helper}

)}
); }