+
+ Templates
+
+
+ {TEMPLATES.map((t) => (
+
openDeploy({ name: t.agentName, model: t.model, skills: t.skills })}
+ onMouseEnter={(e) => { (e.currentTarget as HTMLDivElement).style.borderColor = "rgba(91,141,239,0.3)"; }}
+ onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.borderColor = "var(--arcana-card-border)"; }}
+ >
+
+
+ {t.name}
-
-
-
- ))}
-
-
-
-
} onClick={() => setYamlModalOpen(true)}>
- View YAML example for custom agent
-
+
+ {t.desc}
+
+
+
+ Deploy
+
+
+ ))}
+
+
+ } onClick={() => setYamlOpen(true)}>
+ View YAML example
+
+
+ {/* Deploy modal — simplified, no type selector */}
setDeployOpen(false)}
aria-labelledby="deploy-agent-title"
>
-
+
{submitError && (
-
+
{submitError}
)}
{submitSuccess && (
-
- {submitSuccess}
-
+
)}
-
@@ -660,23 +369,20 @@ export const AgentsPage = () => {
>
Deploy
-
+ setDeployOpen(false)}>
Cancel
+ {/* YAML example modal */}
setYamlModalOpen(false)}
- aria-labelledby="agent-yaml-title"
+ isOpen={yamlOpen}
+ onClose={() => setYamlOpen(false)}
+ aria-labelledby="yaml-title"
>
-
+
{
borderRadius: 4,
fontSize: 12,
overflow: "auto",
- }}
- >
+ }}>
{ARCANA_AGENT_YAML}
- setYamlModalOpen(false)}>
- Close
-
+ setYamlOpen(false)}>Close
>
diff --git a/services/studio/src/pages/ApprovalsPage.tsx b/services/studio/src/pages/ApprovalsPage.tsx
index fed929d..0cf117b 100644
--- a/services/studio/src/pages/ApprovalsPage.tsx
+++ b/services/studio/src/pages/ApprovalsPage.tsx
@@ -150,7 +150,7 @@ export const ApprovalsPage = () => {
try {
const params = new URLSearchParams();
if (statusFilter !== "all") params.set("status", statusFilter);
- const res = await fetch(`/api/v1/approvals?${params}`);
+ const res = await fetch(`/api/v1/promotions?${params}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const fetched: ApprovalEntry[] = data.entries ?? data.approvals ?? [];
@@ -186,7 +186,7 @@ export const ApprovalsPage = () => {
if (!reviewTarget) return;
setSubmitting(true);
try {
- const res = await fetch(`/api/v1/approvals/${encodeURIComponent(reviewTarget.id)}/approve`, {
+ const res = await fetch(`/api/v1/promotions/${encodeURIComponent(reviewTarget.id)}/approve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ comment }),
@@ -210,7 +210,7 @@ export const ApprovalsPage = () => {
if (!reviewTarget) return;
setSubmitting(true);
try {
- const res = await fetch(`/api/v1/approvals/${encodeURIComponent(reviewTarget.id)}/reject`, {
+ const res = await fetch(`/api/v1/promotions/${encodeURIComponent(reviewTarget.id)}/reject`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ comment }),
diff --git a/services/studio/src/pages/AuditExplorerPage.tsx b/services/studio/src/pages/AuditExplorerPage.tsx
index 1ca9787..01dc0fb 100644
--- a/services/studio/src/pages/AuditExplorerPage.tsx
+++ b/services/studio/src/pages/AuditExplorerPage.tsx
@@ -76,7 +76,7 @@ function generateMockEntries(count: number): AuditEntry[] {
const agents = ["research-pipeline-agent", "code-assistant", "data-pipeline-agent", "support-bot", "content-writer"];
const users = ["alice@acme.com", "bob@acme.com", "charlie@acme.com", "system"];
const actions = ["tool_call", "model_invoke", "agent_deploy", "config_change", "data_access", "guardrail_trigger"];
- const resources = ["/api/v1/agents", "/api/v1/skills/search", "/api/v1/mcp/execute", "/api/v1/eval/run", "/api/v1/connectors"];
+ const resources = ["/api/v1/agents", "/api/v1/skills/search", "/api/v1/tools/execute", "/api/v1/eval/run", "/api/v1/connectors"];
const verdicts: Array<"allowed" | "blocked"> = ["allowed", "allowed", "allowed", "allowed", "blocked"];
const entries: AuditEntry[] = [];
diff --git a/services/studio/src/pages/BuildHubPage.tsx b/services/studio/src/pages/BuildHubPage.tsx
index 52b8df7..3138759 100644
--- a/services/studio/src/pages/BuildHubPage.tsx
+++ b/services/studio/src/pages/BuildHubPage.tsx
@@ -64,11 +64,11 @@ const PACKAGE_CARDS: {
color: string;
desc: string;
}[] = [
- { kind: "agent", label: "Create Agent", icon:
, color: "#3b82f6", desc: "Deploy an AI agent with custom skills and tools" },
- { kind: "skill", label: "Create Skill", icon:
, color: "#a855f7", desc: "Build a reusable capability for agents" },
- { kind: "model", label: "Create Model", icon:
, color: "#22c55e", desc: "Register an LLM provider and model" },
- { kind: "mcp", label: "Create MCP Server", icon:
, color: "#06b6d4", desc: "Connect external tools via MCP" },
- { kind: "subagent", label: "Create Subagent", icon:
, color: "#f97316", desc: "Build a specialized agent as a tool" },
+ { kind: "agent", label: "Deploy Agent", icon:
, color: "#3b82f6", desc: "Name it, pick a model, add skills, deploy" },
+ { kind: "skill", label: "Add Skill", icon:
, color: "#a855f7", desc: "Build a reusable capability for agents" },
+ { kind: "model", label: "Register Model", icon:
, color: "#22c55e", desc: "Connect an LLM provider" },
+ { kind: "mcp", label: "Connect MCP", icon:
, color: "#06b6d4", desc: "Plug in external tools via MCP" },
+ { kind: "subagent", label: "Add Subagent", icon:
, color: "#f97316", desc: "Create a specialist agent as a tool" },
];
const PROVIDERS = [
@@ -120,10 +120,9 @@ export const BuildHubPage = () => {
<>
-
Build something amazing
+ New deployment
- Create agents, skills, models, and MCP servers from a single hub.
- Choose a package type below to get started.
+ Deploy an agent, register a skill or model, or connect an MCP server.
@@ -841,7 +840,7 @@ const ModelWizard = ({ onClose }: { onClose: () => void }) => {
const GenericWizard = ({ kind, onClose }: { kind: "mcp" | "subagent"; onClose: () => void }) => {
const isMcp = kind === "mcp";
const title = isMcp ? "Register MCP Server" : "Create Subagent";
- const endpoint = isMcp ? "/api/v1/mcp" : "/api/v1/agents/register";
+ const endpoint = isMcp ? "/api/v1/tools" : "/api/v1/agents/register";
const [name, setName] = useState("");
const [description, setDescription] = useState("");
diff --git a/services/studio/src/pages/ChatDrawer.tsx b/services/studio/src/pages/ChatDrawer.tsx
index 572aa9e..81cbf88 100644
--- a/services/studio/src/pages/ChatDrawer.tsx
+++ b/services/studio/src/pages/ChatDrawer.tsx
@@ -1,55 +1,20 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
-import Chatbot, {
- ChatbotDisplayMode,
-} from "@patternfly/chatbot/dist/dynamic/Chatbot";
-import ChatbotContent from "@patternfly/chatbot/dist/dynamic/ChatbotContent";
-import ChatbotWelcomePrompt from "@patternfly/chatbot/dist/dynamic/ChatbotWelcomePrompt";
-import ChatbotFooter, {
- ChatbotFootnote,
-} from "@patternfly/chatbot/dist/dynamic/ChatbotFooter";
-import MessageBar from "@patternfly/chatbot/dist/dynamic/MessageBar";
-import MessageBox from "@patternfly/chatbot/dist/dynamic/MessageBox";
-import Message from "@patternfly/chatbot/dist/dynamic/Message";
-import ChatbotHeader, {
- ChatbotHeaderMain,
- ChatbotHeaderTitle,
- ChatbotHeaderActions,
-} from "@patternfly/chatbot/dist/dynamic/ChatbotHeader";
-import { Button } from "@patternfly/react-core";
-import { TimesIcon } from "@patternfly/react-icons";
import { useAuth } from "../auth/AuthContext";
-import {
- detectCommand,
- executePlatformCommand,
-} from "./platformCommands";
+import { detectCommand, executePlatformCommand } from "./platformCommands";
interface ConversationMessage {
id: string;
role: "user" | "bot";
content: string;
- /** Agent name produced by a deploy/suspend/resume command, for the nav button. */
agentName?: string;
timestamp: string;
}
-const WELCOME_PROMPTS = [
- {
- title: "Deploy an agent",
- message: "Deploy an agent called my-assistant",
- },
- {
- title: "Show costs",
- message: "Show me the current platform costs",
- },
- {
- title: "Check system status",
- message: "What is the system health status?",
- },
- {
- title: "List skills",
- message: "List all available skills",
- },
+const QUICK = [
+ { label: "Deploy agent", msg: "Deploy an agent called my-assistant" },
+ { label: "System status", msg: "What is the system health status?" },
+ { label: "List skills", msg: "List all available skills" },
];
interface ChatDrawerProps {
@@ -61,298 +26,241 @@ export const ChatDrawer = ({ isOpen, onClose }: ChatDrawerProps) => {
const navigate = useNavigate();
const { authHeaders } = useAuth();
const [messages, setMessages] = useState
([]);
+ const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [sessionId, setSessionId] = useState(null);
- const scrollToBottomRef = useRef(null);
+ const scrollRef = useRef(null);
+ const inputRef = useRef(null);
const abortRef = useRef(null);
- useEffect(() => {
- if (scrollToBottomRef.current) {
- scrollToBottomRef.current.scrollIntoView({ behavior: "smooth" });
- }
- }, [messages, isLoading]);
+ useEffect(() => { scrollRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, isLoading]);
- // Abort any in-flight SSE stream when the drawer closes.
useEffect(() => {
- if (!isOpen && abortRef.current) {
- abortRef.current.abort();
- abortRef.current = null;
- }
+ if (!isOpen && abortRef.current) { abortRef.current.abort(); abortRef.current = null; }
+ if (isOpen) setTimeout(() => inputRef.current?.focus(), 100);
}, [isOpen]);
- const streamAgentResponse = useCallback(
- async (text: string) => {
- const controller = new AbortController();
- abortRef.current = controller;
-
- const botId = `bot-${Date.now()}`;
- setMessages((prev) => [
- ...prev,
- { id: botId, role: "bot", content: "", timestamp: new Date().toLocaleTimeString() },
- ]);
-
- try {
- const payload: Record = { message: text };
- if (sessionId) payload.session_id = sessionId;
-
- const res = await fetch("/api/v1/chat/stream", {
- method: "POST",
- headers: { "Content-Type": "application/json", ...authHeaders() },
- body: JSON.stringify(payload),
- signal: controller.signal,
- });
+ const streamResponse = useCallback(async (text: string) => {
+ const controller = new AbortController();
+ abortRef.current = controller;
+ const botId = `bot-${Date.now()}`;
+ setMessages((prev) => [...prev, { id: botId, role: "bot", content: "", timestamp: new Date().toLocaleTimeString() }]);
- if (!res.ok) {
- throw new Error(`HTTP ${res.status}`);
- }
-
- const contentType = res.headers.get("content-type") ?? "";
- if (contentType.includes("text/event-stream") && res.body) {
- const reader = res.body.getReader();
- const decoder = new TextDecoder();
- let accumulated = "";
+ try {
+ const payload: Record = { message: text };
+ if (sessionId) payload.session_id = sessionId;
+ const res = await fetch("/api/v1/chat/stream", {
+ method: "POST",
+ headers: { "Content-Type": "application/json", ...authHeaders() },
+ body: JSON.stringify(payload),
+ signal: controller.signal,
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- const chunk = decoder.decode(value, { stream: true });
-
- // Parse SSE lines
- const lines = chunk.split("\n");
- for (const line of lines) {
- if (line.startsWith("data: ")) {
- const payload = line.slice(6);
- if (payload === "[DONE]") break;
- try {
- const parsed = JSON.parse(payload);
- if (parsed.session_id) setSessionId(parsed.session_id);
- if (parsed.token) {
- accumulated += parsed.token;
- setMessages((prev) =>
- prev.map((m) => (m.id === botId ? { ...m, content: accumulated } : m)),
- );
- }
- if (parsed.content) {
- accumulated += parsed.content;
- setMessages((prev) =>
- prev.map((m) => (m.id === botId ? { ...m, content: accumulated } : m)),
- );
- }
- } catch {
- // Non-JSON SSE line, treat as plain text token
- if (payload.trim()) {
- accumulated += payload;
- setMessages((prev) =>
- prev.map((m) => (m.id === botId ? { ...m, content: accumulated } : m)),
- );
- }
- }
- }
+ const ct = res.headers.get("content-type") ?? "";
+ if (ct.includes("text/event-stream") && res.body) {
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ let acc = "";
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ for (const line of decoder.decode(value, { stream: true }).split("\n")) {
+ if (!line.startsWith("data: ")) continue;
+ const d = line.slice(6);
+ if (d === "[DONE]") break;
+ try {
+ const p = JSON.parse(d);
+ if (p.session_id) setSessionId(p.session_id);
+ const tok = p.token ?? p.content ?? "";
+ if (tok) { acc += tok; setMessages((prev) => prev.map((m) => m.id === botId ? { ...m, content: acc } : m)); }
+ } catch {
+ if (d.trim()) { acc += d; setMessages((prev) => prev.map((m) => m.id === botId ? { ...m, content: acc } : m)); }
}
}
-
- // If nothing was streamed, show a fallback
- if (!accumulated) {
- setMessages((prev) =>
- prev.map((m) => (m.id === botId ? { ...m, content: "Done." } : m)),
- );
- }
- } else {
- // Non-streaming JSON fallback
- const data = await res.json();
- if (data.session_id) setSessionId(data.session_id);
- setMessages((prev) =>
- prev.map((m) =>
- m.id === botId ? { ...m, content: data.reply ?? data.content ?? "Done." } : m,
- ),
- );
}
- } catch (err) {
- if (err instanceof DOMException && err.name === "AbortError") return;
- setMessages((prev) =>
- prev.map((m) =>
- m.id === botId
- ? {
- ...m,
- content:
- "Sorry, I couldn't reach the Arcana API. Is the platform running? Try `make dev` to start.",
- }
- : m,
- ),
- );
+ if (!acc) setMessages((prev) => prev.map((m) => m.id === botId ? { ...m, content: "Done." } : m));
+ } else {
+ const data = await res.json();
+ if (data.session_id) setSessionId(data.session_id);
+ setMessages((prev) => prev.map((m) => m.id === botId ? { ...m, content: data.reply ?? data.content ?? "Done." } : m));
}
- },
- [sessionId, authHeaders],
- );
+ } catch (err) {
+ if (err instanceof DOMException && err.name === "AbortError") return;
+ setMessages((prev) => prev.map((m) => m.id === botId ? { ...m, content: "Could not reach the API." } : m));
+ }
+ }, [sessionId, authHeaders]);
- const handleSend = useCallback(
- async (message: string | number) => {
- const text = String(message);
- if (!text.trim() || isLoading) return;
+ const send = useCallback(async (text: string) => {
+ if (!text.trim() || isLoading) return;
+ setMessages((prev) => [...prev, { id: `user-${Date.now()}`, role: "user", content: text, timestamp: new Date().toLocaleTimeString() }]);
+ setInput("");
+ setIsLoading(true);
- const userMsg: ConversationMessage = {
- id: `user-${Date.now()}`,
- role: "user",
- content: text,
- timestamp: new Date().toLocaleTimeString(),
- };
- setMessages((prev) => [...prev, userMsg]);
- setIsLoading(true);
+ const action = detectCommand(text);
+ if (action) {
+ try {
+ const result = await executePlatformCommand(action, text, authHeaders());
+ setMessages((prev) => [...prev, { id: `bot-${Date.now()}`, role: "bot", content: result.markdown, agentName: result.agentName, timestamp: new Date().toLocaleTimeString() }]);
+ } catch {
+ setMessages((prev) => [...prev, { id: `err-${Date.now()}`, role: "bot", content: "Command failed.", timestamp: new Date().toLocaleTimeString() }]);
+ } finally { setIsLoading(false); }
+ } else {
+ try { await streamResponse(text); } finally { setIsLoading(false); }
+ }
+ }, [isLoading, authHeaders, streamResponse]);
- const action = detectCommand(text);
- if (action) {
- try {
- const result = await executePlatformCommand(action, text, authHeaders());
- const botMsg: ConversationMessage = {
- id: `bot-${Date.now()}`,
- role: "bot",
- content: result.markdown,
- agentName: result.agentName,
- timestamp: new Date().toLocaleTimeString(),
- };
- setMessages((prev) => [...prev, botMsg]);
- } catch {
- setMessages((prev) => [
- ...prev,
- {
- id: `err-${Date.now()}`,
- role: "bot",
- content: "❌ **Command failed.** Could not reach the Arcana API.",
- timestamp: new Date().toLocaleTimeString(),
- },
- ]);
- } finally {
- setIsLoading(false);
- }
- } else {
- // Not a platform command — stream via AG-UI SSE endpoint
- try {
- await streamAgentResponse(text);
- } finally {
- setIsLoading(false);
- }
- }
- },
- [isLoading, authHeaders, streamAgentResponse],
- );
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(input); }
+ };
if (!isOpen) return null;
return (
-
-
-
-
-
-
- A
-
- Arcana Chat
-
-
-
-
- }
- />
-
-
+
+ {/* Header */}
+
-
-
- {messages.length === 0 && (
- ({
- title: p.title,
- message: p.message,
- onClick: () => handleSend(p.message),
- }))}
- />
- )}
+ {/* Messages */}
+
+ {messages.length === 0 && (
+
+
A
+
+ How can I help?
+
+
+ Ask anything about the platform.
+
+
+ {QUICK.map((s) => (
+ send(s.msg)} style={{
+ padding: "7px 14px", borderRadius: 16, fontSize: 12, fontWeight: 500,
+ border: "1px solid var(--arcana-card-border)", background: "var(--arcana-card-bg)",
+ color: "var(--arcana-text-secondary)", cursor: "pointer", transition: "all 0.15s",
+ }}>
+ {s.label}
+
+ ))}
+
+
+ )}
- {messages.map((msg) => (
-
-
- {msg.role === "bot" && msg.agentName && (
-
-
{
- onClose();
- navigate(`/agents/${msg.agentName}`);
- }}
- style={{
- background:
- "var(--arcana-gradient, linear-gradient(135deg, #667eea, #764ba2))",
- color: "#fff",
- border: "none",
- borderRadius: 6,
- padding: "6px 16px",
- fontSize: 13,
- fontWeight: 600,
- }}
- >
- Open {msg.agentName} →
-
+ {messages.map((msg) => (
+
+ {msg.role === "bot" && (
+
A
+ )}
+
+ {msg.content}
+ {msg.agentName && (
+
+ { onClose(); navigate(`/agents/${msg.agentName}`); }} style={{
+ padding: "4px 10px", borderRadius: 4, border: "none", fontSize: 11, fontWeight: 600,
+ background: "linear-gradient(135deg, #5b8def, #a855f7)", color: "#fff", cursor: "pointer",
+ }}>
+ Open {msg.agentName}
+
)}
- ))}
+
+ ))}
- {isLoading && (
-
- )}
+ {isLoading && (
+
+ )}
-
-
-
+
+
-
-
-
-
-
+ {/* Input */}
+
+
+
setInput(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder="Ask something..."
+ rows={1}
+ style={{
+ flex: 1, padding: "10px 14px", borderRadius: 10, resize: "none",
+ border: "1px solid var(--arcana-card-border)", background: "var(--arcana-input-bg)",
+ color: "var(--arcana-text)", fontSize: 13, lineHeight: 1.4,
+ outline: "none", fontFamily: "inherit", minHeight: 38, maxHeight: 80,
+ }}
+ onFocus={(e) => { e.currentTarget.style.borderColor = "rgba(91,141,239,0.5)"; }}
+ onBlur={(e) => { e.currentTarget.style.borderColor = "var(--arcana-card-border)"; }}
+ />
+ send(input)} disabled={isLoading || !input.trim()} style={{
+ width: 38, height: 38, borderRadius: 10, border: "none",
+ background: input.trim() ? "linear-gradient(135deg, #5b8def, #a855f7)" : "var(--arcana-card-bg)",
+ color: input.trim() ? "#fff" : "var(--arcana-text-muted)",
+ cursor: input.trim() && !isLoading ? "pointer" : "default",
+ display: "flex", alignItems: "center", justifyContent: "center",
+ transition: "all 0.15s", flexShrink: 0,
+ }}>
+
+
+
+
+
+
+
);
};
diff --git a/services/studio/src/pages/DashboardPage.tsx b/services/studio/src/pages/DashboardPage.tsx
index 4a74575..3df28b5 100644
--- a/services/studio/src/pages/DashboardPage.tsx
+++ b/services/studio/src/pages/DashboardPage.tsx
@@ -1,472 +1,414 @@
-import { useState, useEffect } from "react";
+import { useState, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import {
PageSection,
Spinner,
Alert,
+ Button,
+ Label,
} from "@patternfly/react-core";
+import {
+ PlusCircleIcon,
+ ExternalLinkAltIcon,
+} from "@patternfly/react-icons";
import { useHealth } from "../hooks/useHealth";
import { useAuth } from "../auth/AuthContext";
+const TECH_ROLES = new Set(["developer", "data-engineer", "sre", "auditor", "admin"]);
+
+interface Agent {
+ name: string;
+ agent_type: string;
+ capabilities: string[];
+ protocols: string[];
+ status: string;
+ registered_at?: string;
+}
+
+const STATUS_DOT: Record = {
+ active: { color: "#22c55e", label: "Running" },
+ busy: { color: "#f59e0b", label: "Busy" },
+ idle: { color: "#8b95a5", label: "Sleeping" },
+ offline: { color: "#ef4444", label: "Crashed" },
+};
+
export const DashboardPage = () => {
const navigate = useNavigate();
- const { health, error, loading } = useHealth(5000);
- const { user, isAtLeast, authHeaders } = useAuth();
- const [agentCount, setAgentCount] = useState<{ total: number } | null>(null);
+ const { health, error } = useHealth(5000);
+ const { user, isAtLeast } = useAuth();
+ const [agents, setAgents] = useState([]);
+ const [loading, setLoading] = useState(true);
const [skillCount, setSkillCount] = useState(0);
const [mcpCount, setMcpCount] = useState(0);
+ const fetchData = useCallback(async () => {
+ try {
+ const [agentsRes, skillsRes, mcpRes] = await Promise.allSettled([
+ fetch("/api/v1/agents"),
+ fetch("/api/v1/skills"),
+ fetch("/api/v1/tools"),
+ ]);
+ if (agentsRes.status === "fulfilled" && agentsRes.value.ok) {
+ const data = await agentsRes.value.json();
+ setAgents(data.agents ?? []);
+ }
+ if (skillsRes.status === "fulfilled" && skillsRes.value.ok) {
+ const data = await skillsRes.value.json();
+ setSkillCount(data.skills?.length ?? 0);
+ }
+ if (mcpRes.status === "fulfilled" && mcpRes.value.ok) {
+ const data = await mcpRes.value.json();
+ setMcpCount(data.servers?.length ?? 0);
+ }
+ } catch {
+ /* best effort */
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
useEffect(() => {
- const headers = authHeaders();
- fetch("/api/v1/agents", { headers })
- .then((r) => r.json())
- .then((d) => setAgentCount({ total: d.total ?? 0 }))
- .catch(() => {});
- fetch("/api/v1/skills", { headers })
- .then((r) => r.json())
- .then((d) => setSkillCount(d.skills?.length ?? 0))
- .catch(() => {});
- fetch("/api/v1/tools", { headers })
- .then((r) => r.json())
- .then((d) => setMcpCount(d.servers?.length ?? 0))
- .catch(() => {});
- }, [authHeaders]);
+ fetchData();
+ }, [fetchData]);
const totalServices = health?.services.length ?? 0;
const healthyServices = health?.services.filter((s) => s.status === "healthy").length ?? 0;
+ const allHealthy = totalServices > 0 && healthyServices === totalServices;
+
+ const runningCount = agents.filter((a) => a.status === "active").length;
+ const sleepingCount = agents.filter((a) => a.status === "idle").length;
+ const crashedCount = agents.filter((a) => a.status === "offline").length;
+ const isTechUser = user?.roles?.some((r) => TECH_ROLES.has(r)) ?? false;
return (
<>
{error && (
- Cannot reach arcana-api. Run make dev to start.
+ Cannot reach the Arcana API. Run make dev to start.
)}
- {/* Hero Section */}
-
-
-
-
-
-
-
+ {/* Header */}
+
+
+ {isTechUser ? "Overview" : "Home"}
+
+ {isAtLeast("developer") && (
+
}
+ onClick={() => navigate("/build")}
>
- Build agents your teams can trust
-
-
+ )}
+
+
+ {/* Summary cards */}
+
+ {[
+ {
+ label: isTechUser ? "Agents" : "My Agents",
+ value: agents.length,
+ sub: `${runningCount} running`,
+ color: "#5b8def",
+ },
+ {
+ label: "Running",
+ value: runningCount,
+ sub: "active now",
+ color: "#22c55e",
+ },
+ {
+ label: isTechUser ? "Skills" : "Capabilities",
+ value: skillCount,
+ sub: isTechUser ? "registered" : "available",
+ color: "#a855f7",
+ },
+ ...(isTechUser ? [{
+ label: "MCP Servers",
+ value: mcpCount,
+ sub: "connected",
+ color: "#06b6d4",
+ }] : []),
+ {
+ label: isTechUser ? "Platform" : "Status",
+ value: allHealthy ? "Healthy" : `${healthyServices}/${totalServices}`,
+ sub: isTechUser ? (totalServices > 0 ? `${totalServices} services` : "checking...") : (allHealthy ? "All systems go" : "Some issues"),
+ color: allHealthy ? "#22c55e" : "#f59e0b",
+ },
+ ].map((card) => (
+
- Arcana Studio helps {user?.roles?.[0] === "user" ? "you use" : "you compose, publish, and run"} AI
- agents using skills, sub-agents, MCPs, and shared artifacts—so teams
- collaborate instead of duplicating work.
+
+ {card.label}
+
+
+ {loading ? : card.value}
+
+
+ {card.sub}
+
+
+ ))}
+
+
+ {/* Status breakdown — only if agents exist and some aren't running */}
+ {agents.length > 0 && (sleepingCount > 0 || crashedCount > 0) && (
+
+
+
+ {runningCount} running
+
+ {sleepingCount > 0 && (
+
+
+ {sleepingCount} sleeping
+
+ )}
+ {crashedCount > 0 && (
+
+
+ {crashedCount} crashed
+
+ )}
+ {agents.filter((a) => a.status === "busy").length > 0 && (
+
+
+ {agents.filter((a) => a.status === "busy").length} busy
+
+ )}
+
+ )}
+
+ {/* Agent list */}
+
+
+ Agents
+
+ {agents.length > 0 && (
+
navigate("/agents")}>
+ View all
+
+ )}
+
+
+ {loading ? (
+
+
+
+ ) : agents.length === 0 ? (
+
+
/
+
+ {isTechUser ? "No agents deployed" : "No agents available yet"}
+
+
+ {isTechUser
+ ? "Deploy your first agent in under a minute. Pick a template or start from scratch."
+ : "Your team hasn't set up any agents yet. Check back soon or ask your admin."}
-
- {isAtLeast("developer") && (
- navigate("/agents")}
+ {isAtLeast("developer") && (
+ navigate("/build")}>
+ Deploy your first agent
+
+ )}
+
+ ) : (
+
+ {agents.slice(0, 10).map((agent) => {
+ const dot = STATUS_DOT[agent.status] ?? STATUS_DOT.offline;
+ const skills = (agent.capabilities ?? []).filter((c) => !c.startsWith("model:"));
+ const model = (agent.capabilities ?? []).find((c) => c.startsWith("model:"))?.replace("model:", "");
+ return (
+
navigate(`/agents/${agent.name}`)}
style={{
- padding: "12px 24px",
- borderRadius: 8,
- border: "none",
- background: "linear-gradient(135deg, #5b8def 0%, #4a6cf7 100%)",
- color: "#fff",
- fontSize: 14,
- fontWeight: 600,
+ display: "flex",
+ alignItems: "center",
+ gap: 16,
+ padding: "14px 18px",
+ background: "var(--arcana-card-bg)",
+ borderRadius: 10,
+ border: "1px solid var(--arcana-card-border)",
cursor: "pointer",
- transition: "transform 0.15s, box-shadow 0.15s",
+ transition: "background 0.15s, border-color 0.15s",
+ marginBottom: 6,
+ }}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLDivElement).style.background = "var(--arcana-hover-bg)";
+ (e.currentTarget as HTMLDivElement).style.borderColor = "rgba(91,141,239,0.3)";
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLDivElement).style.background = "var(--arcana-card-bg)";
+ (e.currentTarget as HTMLDivElement).style.borderColor = "var(--arcana-card-border)";
}}
>
- Start building
-
- )}
-
navigate("/agents")}
- style={{
- padding: "12px 24px",
- borderRadius: 8,
- border: "1px solid rgba(255,255,255,0.15)",
- background: "rgba(255,255,255,0.05)",
- color: "#c5cdd8",
- fontSize: 14,
- fontWeight: 600,
- cursor: "pointer",
- transition: "background 0.15s",
- }}
- >
- Go to Agents
-
- {isAtLeast("admin") && (
+
+
+
+ {agent.name}
+
+
+ {model ?? "—"} · {dot.label}
+
+
+
+ {skills.slice(0, 3).map((s) => (
+ {s}
+ ))}
+ {skills.length > 3 && +{skills.length - 3} }
+
+
+
+ );
+ })}
+ {agents.length > 10 && (
+
+ navigate("/agents")}>
+ View all {agents.length} agents
+
+
+ )}
+
+ )}
+
+ {/* Quick actions */}
+ {isAtLeast("developer") && (
+
+
+ Quick actions
+
+
+ {(isTechUser ? [
+ { label: "Deploy agent", path: "/build" },
+ { label: "Marketplace", path: "/marketplace" },
+ { label: "Guardrails", path: "/guardrails" },
+ { label: "Usage & costs", path: "/finops" },
+ ] : [
+ { label: "Browse agents", path: "/marketplace" },
+ { label: "Chat with agent", path: "/chat" },
+ ]).map((action) => (
navigate("/settings")}
+ onClick={() => navigate(action.path)}
style={{
- padding: "12px 24px",
+ padding: "9px 18px",
borderRadius: 8,
- border: "1px solid rgba(255,255,255,0.15)",
- background: "rgba(255,255,255,0.05)",
- color: "#c5cdd8",
- fontSize: 14,
- fontWeight: 600,
+ border: "1px solid var(--arcana-card-border)",
+ background: "var(--arcana-card-bg)",
+ color: "var(--arcana-text-secondary)",
+ fontSize: 13,
+ fontWeight: 500,
cursor: "pointer",
- transition: "background 0.15s",
+ transition: "all 0.15s",
+ }}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.borderColor = "rgba(91,141,239,0.3)";
+ (e.currentTarget as HTMLButtonElement).style.color = "var(--arcana-text)";
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.borderColor = "var(--arcana-card-border)";
+ (e.currentTarget as HTMLButtonElement).style.color = "var(--arcana-text-secondary)";
}}
>
- View operations
+ {action.label}
- )}
-
-
-
-
-
- {/* Statistics Section */}
-
-
- Arcana Statistics
-
-
- Platform-wide usage across all teams and organizations.
-
-
-
- {/* Agents Deployed */}
-
-
- Agents Deployed
-
-
-
-
Total
-
- {loading ? : (agentCount?.total ?? "–")}
-
-
-
-
-
- {/* Skills Created */}
-
-
- Skills Created
-
-
-
Registered
-
- {skillCount || "–"}
-
-
-
-
- {/* MCP Integrations */}
-
-
- MCP Integrations
-
-
- {mcpCount || "–"}
+ ))}
-
- {/* Services */}
-
-
- Services
-
-
- {loading ? : `${healthyServices}/${totalServices}`}
-
-
-
0 ? `${(healthyServices / totalServices) * 100}%` : "0%",
- background: healthyServices === totalServices ? "#22c55e" : "#f59e0b",
- borderRadius: 2,
- transition: "width 0.3s",
- }}
- />
-
-
-
- {/* Planes */}
-
-
- Active Planes
-
-
8
-
-
-
-
- {/* What you can do here */}
-
-
- What you can do here
-
-
- Three ways to get value from Arcana today.
-
-
-
- {/* BUILD card */}
-
-
- BUILD
-
-
- Compose agents
-
-
- Pick skills from the ecosystem, attach sub-agents, and wire MCPs with
- guardrails—then package a version your stakeholders can review.
-
-
navigate("/agents")}
- style={{
- marginTop: 16,
- padding: 0,
- border: "none",
- background: "none",
- color: "#5b8def",
- fontSize: 14,
- fontWeight: 600,
- cursor: "pointer",
- textAlign: "left",
- }}
- >
- Open builder →
-
-
-
- {/* DISCOVER card */}
-
-
- DISCOVER
-
-
- Find what already exists
-
-
- Search the registry for skills, agents, MCPs, and connectors. See who owns them
- and how many teams reuse them before you build from scratch.
-
-
navigate("/skills")}
- style={{
- marginTop: 16,
- padding: 0,
- border: "none",
- background: "none",
- color: "#a855f7",
- fontSize: 14,
- fontWeight: 600,
- cursor: "pointer",
- textAlign: "left",
- }}
- >
- Explore registry →
-
-
-
- {/* OPERATE card */}
-
-
- OPERATE
-
-
- Run with confidence
-
-
- Monitor deployments, health, and recent runs from one operations view—prototype
- for day-two ownership inside the enterprise.
-
-
navigate("/settings")}
- style={{
- marginTop: 16,
- padding: 0,
- border: "none",
- background: "none",
- color: "#22c55e",
- fontSize: 14,
- fontWeight: 600,
- cursor: "pointer",
- textAlign: "left",
- }}
- >
- Go to operations →
-
-
-
+ )}
>
);
diff --git a/services/studio/src/pages/FinOpsDashboardPage.tsx b/services/studio/src/pages/FinOpsDashboardPage.tsx
index 45919dd..dcfc7d3 100644
--- a/services/studio/src/pages/FinOpsDashboardPage.tsx
+++ b/services/studio/src/pages/FinOpsDashboardPage.tsx
@@ -217,11 +217,11 @@ export const FinOpsDashboardPage = () => {
setError(null);
try {
const [summaryRes, cotRes, cbmRes, taRes, buRes] = await Promise.allSettled([
- fetch(`/api/v1/finops/summary?period=${period}`),
- fetch(`/api/v1/finops/cost-over-time?period=${period}&team=${teamFilter}`),
- fetch(`/api/v1/finops/cost-by-model?period=${period}`),
- fetch(`/api/v1/finops/top-agents?period=${period}&limit=10`),
- fetch("/api/v1/finops/budget-utilization"),
+ fetch(`/api/v1/costs?period=${period}`),
+ fetch(`/api/v1/costs/over-time?period=${period}&team=${teamFilter}`),
+ fetch(`/api/v1/costs/by-model?period=${period}`),
+ fetch(`/api/v1/costs/top-agents?period=${period}&limit=10`),
+ fetch("/api/v1/costs/budget-utilization"),
]);
if (summaryRes.status === "fulfilled" && summaryRes.value.ok) {
@@ -289,7 +289,7 @@ export const FinOpsDashboardPage = () => {
-
FinOps Dashboard
+
Usage & Costs
Cost analytics, budget utilization, and model spend tracking.
diff --git a/services/studio/src/pages/GuardrailBuilderPage.tsx b/services/studio/src/pages/GuardrailBuilderPage.tsx
index 5a077af..82b5490 100644
--- a/services/studio/src/pages/GuardrailBuilderPage.tsx
+++ b/services/studio/src/pages/GuardrailBuilderPage.tsx
@@ -190,6 +190,7 @@ const ruleTypeLabel = (type: string): string => {
const configSummary = (rule: GuardrailRule): string => {
const c = rule.config;
+ if (!c) return "—";
switch (rule.type) {
case "pii": {
const p = c as PiiConfig;
@@ -291,7 +292,7 @@ export const GuardrailBuilderPage = () => {
setLoading(true);
setSaveMessage(null);
try {
- const res = await fetch(`/api/v1/ward/agents/${encodeURIComponent(selectedAgent)}/rules`);
+ const res = await fetch(`/api/v1/rules/agent/${encodeURIComponent(selectedAgent)}`);
if (res.ok) {
const data = await res.json();
setRules(data.rules ?? []);
@@ -315,7 +316,7 @@ export const GuardrailBuilderPage = () => {
setSaving(true);
setSaveMessage(null);
try {
- const res = await fetch(`/api/v1/ward/agents/${encodeURIComponent(selectedAgent)}/rules`, {
+ const res = await fetch(`/api/v1/rules/agent/${encodeURIComponent(selectedAgent)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rules }),
@@ -338,7 +339,7 @@ export const GuardrailBuilderPage = () => {
setTestRunning(true);
setTestResults(null);
try {
- const res = await fetch("/api/v1/ward/evaluate", {
+ const res = await fetch("/api/v1/check", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: testInput, rules }),
diff --git a/services/studio/src/pages/LoginPage.tsx b/services/studio/src/pages/LoginPage.tsx
index 3375639..8154247 100644
--- a/services/studio/src/pages/LoginPage.tsx
+++ b/services/studio/src/pages/LoginPage.tsx
@@ -5,107 +5,62 @@ import { useAuth } from "../auth/AuthContext";
interface RoleOption {
role: string;
title: string;
- persona: string;
description: string;
color: string;
icon: string;
- capabilities: string[];
}
const ROLE_OPTIONS: RoleOption[] = [
{
role: "admin",
- title: "Administrator",
- persona: "Admin",
+ title: "Admin",
description: "Full platform access",
color: "#a855f7",
icon: "A",
- capabilities: ["Agents", "Security", "Tenants", "Billing", "All Settings"],
},
{
role: "developer",
title: "Developer",
- persona: "Alex",
description: "Build and deploy agents",
color: "#5b8def",
icon: "D",
- capabilities: ["Agents", "Skills", "Models", "Blueprints", "Evaluations"],
},
{
role: "data-engineer",
title: "Data Engineer",
- persona: "Priya",
- description: "Manage data pipelines",
+ description: "Manage pipelines & connectors",
color: "#06b6d4",
icon: "DE",
- capabilities: ["Connectors", "Knowledge Base", "Models", "MCP Servers"],
},
{
role: "sre",
- title: "SRE / Platform",
- persona: "Jordan",
+ title: "SRE",
description: "Operate and monitor",
color: "#f59e0b",
icon: "S",
- capabilities: ["Health", "Deployments", "FinOps", "Audit Trails"],
},
{
role: "auditor",
title: "Auditor",
- persona: "Sam",
description: "Compliance and audit",
color: "#ef4444",
icon: "Au",
- capabilities: ["Audit Logs", "Compliance Reports", "Tenant Data"],
},
{
role: "user",
- title: "Business User",
- persona: "Maya",
+ title: "User",
description: "Use agents day-to-day",
color: "#22c55e",
icon: "U",
- capabilities: ["Chat", "Agents", "Dashboards", "Marketplace"],
},
];
-const VALUE_PROPS = [
- {
- icon: "🚀",
- title: "Deploy in minutes",
- desc: "From conversation to production agent — no custom glue code.",
- },
- {
- icon: "🛡️",
- title: "Enterprise governance",
- desc: "Per-agent guardrails, RBAC, OPA policies, immutable audit trail.",
- },
- {
- icon: "💰",
- title: "Built-in cost control",
- desc: "Token budgets, model fallback chains, team-level spending caps.",
- },
- {
- icon: "🔄",
- title: "Self-improving agents",
- desc: "Skills evolve, memory compacts, corrections become capabilities.",
- },
-];
-
-const LOGOS = [
- { name: "Kubernetes", abbr: "K8s" },
- { name: "Temporal", abbr: "⏱" },
- { name: "PostgreSQL", abbr: "PG" },
- { name: "Redis", abbr: "R" },
- { name: "Prometheus", abbr: "P" },
- { name: "OpenTelemetry", abbr: "OT" },
-];
-
export const LoginPage = () => {
const { login, loginAs, loading, error } = useAuth();
const [apiKey, setApiKey] = useState("");
const [localError, setLocalError] = useState
(null);
- const [mode, setMode] = useState<"roles" | "key">("roles");
+ const [showApiKey, setShowApiKey] = useState(false);
+ const [ssoAvailable, setSsoAvailable] = useState(null);
const [selectedRole, setSelectedRole] = useState(null);
const [hoveredRole, setHoveredRole] = useState(null);
const [mounted, setMounted] = useState(false);
@@ -115,6 +70,16 @@ export const LoginPage = () => {
return () => clearTimeout(t);
}, []);
+ useEffect(() => {
+ fetch("/api/v1/enterprise/config")
+ .then((r) => r.ok ? r.json() : null)
+ .then((data) => {
+ const ssoEnabled = data?.auth_mode === "oidc" || data?.auth_mode === "saml" || data?.sso_enabled === true;
+ setSsoAvailable(ssoEnabled);
+ })
+ .catch(() => setSsoAvailable(false));
+ }, []);
+
const handleApiKeySubmit = async (e: React.SyntheticEvent) => {
e.preventDefault();
setLocalError(null);
@@ -127,7 +92,7 @@ export const LoginPage = () => {
setLocalError(null);
setSelectedRole(role);
const ok = await loginAs(role);
- if (!ok) setLocalError("Could not connect to platform");
+ if (!ok) setLocalError("Could not connect");
setSelectedRole(null);
};
@@ -137,26 +102,17 @@ export const LoginPage = () => {
- {/* Grid bg */}
-
+ {/* Subtle orbs */}
+
+
- {/* Orbs */}
-
-
-
- {/* ===== NAV BAR ===== */}
+ {/* Nav */}
{
background:"linear-gradient(135deg,#5b8def,#a855f7)",
display:"flex",alignItems:"center",justifyContent:"center",
fontSize:16,fontWeight:800,color:"#fff",
- boxShadow:"0 2px 12px rgba(91,141,239,0.3)",
}}>A
Arcana
- Platform
Docs
GitHub
-
Contribute
document.getElementById("login-panel")?.scrollIntoView({ behavior:"smooth" })} style={{
padding:"8px 20px",borderRadius:8,border:"1px solid rgba(91,141,239,0.3)",
background:"rgba(91,141,239,0.08)",color:"#7ba4f0",fontSize:13,fontWeight:600,
@@ -189,45 +138,45 @@ export const LoginPage = () => {
- {/* ===== HERO SECTION ===== */}
+ {/* Hero */}
- Open Source · Apache 2.0 · v0.1.0
+ Open Source · Apache 2.0
- The operating system
+ Deploy AI agents
for enterprise AI agents
+ }}>like pushing code
-
- Build, deploy, govern, and continuously improve AI agents.
- One platform for every team — from marketing to engineering to compliance.
+
+ Name it. Pick a model. Add skills. Hit deploy.
+ Your agent is running in seconds, not weeks.
-
+
document.getElementById("login-panel")?.scrollIntoView({ behavior:"smooth" })} style={{
padding:"14px 32px",borderRadius:12,border:"none",
background:"linear-gradient(135deg,#5b8def,#a855f7)",
color:"#fff",fontSize:15,fontWeight:600,cursor:"pointer",
boxShadow:"0 4px 20px rgba(91,141,239,0.3)",transition:"all 0.25s ease",
- }}>Get Started
+ }}>Get started free
{
View on GitHub
-
- {/* Tech stack bar */}
-
- BUILT ON
- {LOGOS.map(l => (
-
- {l.abbr} {l.name}
-
- ))}
-
- {/* ===== VALUE PROPS ===== */}
+ {/* How it works */}
- {VALUE_PROPS.map((v,i) => (
- (
+
-
{v.icon}
-
{v.title}
-
{v.desc}
-
- ))}
-
-
- {/* ===== NUMBERS BAR ===== */}
-
- {[
- { n:"30+",l:"Microservices" },{ n:"16",l:"Custom CRDs" },{ n:"8",l:"Architecture Planes" },
- { n:"5",l:"Agent Protocols" },{ n:"28",l:"Helm Charts" },{ n:"99.95%",l:"Availability Target" },
- ].map(s => (
-
{s.n}
-
{s.l}
+ width:32,height:32,borderRadius:10,
+ background:"rgba(91,141,239,0.1)",
+ display:"flex",alignItems:"center",justifyContent:"center",
+ fontSize:14,fontWeight:700,color:"#5b8def",marginBottom:12,
+ }}>{v.step}
+ {v.title}
+ {v.desc}
))}
- {/* ===== LOGIN PANEL ===== */}
+ {/* Login panel */}
- {/* Section header */}
-
-
- Get into the platform
+
+
+ Sign in
-
- Sign in with SSO, use an API key, or explore with a demo persona
+
+ SSO, API key, or try a demo persona
- {displayError && }
+ {displayError && }
- {/* SSO — one-click, no key needed */}
+ {/* SSO + API key */}
-
{
- window.location.href = "/auth/login";
- }} style={{
- width:"100%",padding:"15px 0",borderRadius:12,border:"none",
- background:"linear-gradient(135deg,#5b8def,#a855f7)",
- color:"#fff",fontSize:15,fontWeight:600,cursor:loading?"wait":"pointer",
- display:"flex",alignItems:"center",justifyContent:"center",gap:10,
- boxShadow:"0 4px 20px rgba(91,141,239,0.3)",transition:"all 0.25s ease",
- opacity:loading?0.7:1,
- }}>
-
- Continue with SSO
-
-
- Redirects to your organization's identity provider (OIDC/SAML)
-
-
- {/* API key expandable */}
-
-
setMode(mode === "key" ? "roles" : "key")} style={{
- width:"100%",padding:"12px 0",borderRadius:10,
- border:"1px solid rgba(255,255,255,0.08)",
+ {ssoAvailable ? (
+ {
+ window.location.href = "/auth/login";
+ }} style={{
+ width:"100%",padding:"14px 0",borderRadius:10,border:"none",
+ background:"linear-gradient(135deg,#5b8def,#a855f7)",
+ color:"#fff",fontSize:15,fontWeight:600,cursor:loading?"wait":"pointer",
+ display:"flex",alignItems:"center",justifyContent:"center",gap:10,
+ boxShadow:"0 4px 20px rgba(91,141,239,0.3)",transition:"all 0.25s ease",
+ opacity:loading?0.7:1,
+ }}>
+ Continue with SSO
+
+ ) : (
+
-
- {mode === "key" ? "Hide API key" : "Sign in with API key instead"}
-
-
-
-
+
+ SSO not configured — use API key or a demo persona below
+
+ )}
- {mode === "key" && (
-
-
-
setApiKey(e.target.value)} placeholder="ak-xxxx-xxxxxxxxxxxxxxxx" autoFocus
+ {/* API key toggle */}
+
+
setShowApiKey(!showApiKey)} style={{
+ width:"100%",padding:"10px 0",borderRadius:8,
+ border:"1px solid rgba(255,255,255,0.06)",
+ background:"transparent",
+ color:"#5a6a7d",fontSize:13,fontWeight:500,cursor:"pointer",
+ display:"flex",alignItems:"center",justifyContent:"center",gap:6,
+ }}>
+ {showApiKey ? "Hide API key" : "Use API key"}
+
+ {showApiKey && (
+
+
+ setApiKey(e.target.value)} placeholder="ak-xxxx-xxxxxxxx" autoFocus
style={{
- flex:1,padding:"12px 16px",borderRadius:10,
+ flex:1,padding:"10px 14px",borderRadius:8,
border:"1px solid rgba(255,255,255,0.08)",background:"rgba(0,0,0,0.25)",
- color:"#fff",fontSize:13,fontFamily:"var(--pf-t--global--font--family--mono)",
- outline:"none",boxSizing:"border-box",transition:"all 0.25s ease",
+ color:"#fff",fontSize:13,fontFamily:"monospace",
+ outline:"none",boxSizing:"border-box",transition:"all 0.2s",
}}/>
{loading? :"Sign In"}
@@ -378,14 +302,14 @@ export const LoginPage = () => {
{/* Divider */}
-
- or explore as
+
+ or try as
- {/* Role cards */}
-
+ {/* Role cards — compact grid */}
+
{ROLE_OPTIONS.map((opt,idx) => {
const isActive = selectedRole===opt.role;
const isHovered = hoveredRole===opt.role;
@@ -395,76 +319,48 @@ export const LoginPage = () => {
onClick={() => handleRoleLogin(opt.role)}
onMouseEnter={() => setHoveredRole(opt.role)}
onMouseLeave={() => setHoveredRole(null)}
- aria-label={`Sign in as ${opt.title}`}
style={{
display:"flex",flexDirection:"column",alignItems:"center",
- padding:"24px 16px 20px",borderRadius:16,
+ padding:"20px 12px 16px",borderRadius:12,
border:`1px solid ${isActive||isHovered?opt.color+"40":"rgba(255,255,255,0.05)"}`,
- background:isActive?`${opt.color}10`:isHovered?"rgba(255,255,255,0.035)":"rgba(255,255,255,0.02)",
+ background:isActive?`${opt.color}10`:"rgba(255,255,255,0.02)",
cursor:loading?"wait":"pointer",textAlign:"center",
- transition:"all 0.25s cubic-bezier(0.16,1,0.3,1)",
+ transition:"all 0.2s",
opacity:mounted?(loading&&!isActive?0.35:1):0,
- transform:mounted?"translateY(0)":"translateY(15px)",
- transitionDelay:`${0.1+idx*0.05}s`,
- backdropFilter:"blur(8px)",
+ transform:mounted?"translateY(0)":"translateY(10px)",
+ transitionDelay:`${0.1+idx*0.04}s`,
}}
>
- {/* Avatar */}
- {isActive&&loading? :opt.icon}
+ {isActive&&loading? :opt.icon}
-
- {/* Name + desc */}
-
+
{opt.title}
-
+
{opt.description}
-
- {/* Capability tags */}
-
- {opt.capabilities.slice(0,2).map(cap => (
- {cap}
- ))}
- {opt.capabilities.length>2 && (
- +{opt.capabilities.length-2}
- )}
-
);
})}
- {/* ===== FOOTER ===== */}
+ {/* Footer */}
-
- {["Documentation","GitHub","Changelog","Community","Contributing"].map(l => (
-
{l}
- ))}
-
- Arcana Platform v0.1.0 · Open Source (Apache 2.0) · Kubernetes-native AI Agent Operating System
+ Arcana · Open Source (Apache 2.0) · Deploy AI agents like pushing code
diff --git a/services/studio/src/pages/MarketplacePage.tsx b/services/studio/src/pages/MarketplacePage.tsx
index ff720a4..4090c7b 100644
--- a/services/studio/src/pages/MarketplacePage.tsx
+++ b/services/studio/src/pages/MarketplacePage.tsx
@@ -28,10 +28,14 @@ import {
RocketIcon,
DownloadIcon,
CodeBranchIcon,
+ RobotIcon,
+ CubesIcon,
} from "@patternfly/react-icons";
+import { useNavigate } from "react-router-dom";
/* ---------- types ---------- */
+type Tab = "yours" | "community";
type ItemType = "agent" | "skill";
type QualityBadge = "gold" | "silver" | "bronze" | "untested";
type Category = "all" | "productivity" | "marketing" | "engineering" | "support" | "data";
@@ -48,6 +52,18 @@ interface MarketplaceItem {
tier?: string;
}
+interface OwnedAgent {
+ name: string;
+ status: string;
+ capabilities: string[];
+}
+
+interface OwnedSkill {
+ name: string;
+ tier?: string;
+ description?: string;
+}
+
/* ---------- constants ---------- */
const CATEGORIES: { value: Category; label: string }[] = [
@@ -59,11 +75,11 @@ const CATEGORIES: { value: Category; label: string }[] = [
{ value: "data", label: "Data" },
];
-const BADGE_COLORS: Record
= {
- gold: { bg: "#92400e", text: "#fbbf24", label: "Gold" },
- silver: { bg: "#374151", text: "#d1d5db", label: "Silver" },
- bronze: { bg: "#7c2d12", text: "#fb923c", label: "Bronze" },
- untested: { bg: "#1f2937", text: "#6b7280", label: "Untested" },
+const BADGE_COLORS: Record = {
+ gold: { label: "Gold" },
+ silver: { label: "Silver" },
+ bronze: { label: "Bronze" },
+ untested: { label: "Untested" },
};
const BADGE_PF_COLORS: Record = {
@@ -73,6 +89,13 @@ const BADGE_PF_COLORS: Record = {
+ active: { color: "#22c55e", label: "Running" },
+ busy: { color: "#f59e0b", label: "Busy" },
+ idle: { color: "#8b95a5", label: "Sleeping" },
+ offline: { color: "#ef4444", label: "Crashed" },
+};
+
/* ---------- star rating ---------- */
function StarRating({
@@ -109,11 +132,21 @@ function StarRating({
/* ---------- main component ---------- */
export const MarketplacePage = () => {
+ const navigate = useNavigate();
+ const [tab, setTab] = useState("yours");
+
+ /* ---- Yours tab state ---- */
+ const [ownedAgents, setOwnedAgents] = useState([]);
+ const [ownedSkills, setOwnedSkills] = useState([]);
+ const [yoursLoading, setYoursLoading] = useState(true);
+ const [yoursError, setYoursError] = useState(null);
+ const [yoursSearch, setYoursSearch] = useState("");
+ const [yoursFilter, setYoursFilter] = useState<"all" | "agents" | "skills">("all");
+
+ /* ---- Community tab state ---- */
const [items, setItems] = useState([]);
- const [loading, setLoading] = useState(true);
+ const [communityLoading, setCommunityLoading] = useState(false);
const [fetchError, setFetchError] = useState(null);
-
- /* filters */
const [search, setSearch] = useState("");
const [category, setCategory] = useState("all");
const [typeFilter, setTypeFilter] = useState<"all" | ItemType>("all");
@@ -130,8 +163,45 @@ export const MarketplacePage = () => {
const [rateValue, setRateValue] = useState(0);
const [ratingSubmitting, setRatingSubmitting] = useState(false);
- const fetchItems = useCallback(async () => {
- setLoading(true);
+ /* ---- fetch yours ---- */
+ const fetchOwned = useCallback(async () => {
+ setYoursLoading(true);
+ setYoursError(null);
+ try {
+ const [agentsRes, skillsRes] = await Promise.allSettled([
+ fetch("/api/v1/agents"),
+ fetch("/api/v1/skills"),
+ ]);
+ if (agentsRes.status === "fulfilled" && agentsRes.value.ok) {
+ const data = await agentsRes.value.json();
+ setOwnedAgents((data.agents ?? []).map((a: Record) => ({
+ name: a.name as string,
+ status: a.status as string,
+ capabilities: (a.capabilities ?? []) as string[],
+ })));
+ }
+ if (skillsRes.status === "fulfilled" && skillsRes.value.ok) {
+ const data = await skillsRes.value.json();
+ setOwnedSkills((data.skills ?? []).map((s: Record) => ({
+ name: s.name as string,
+ tier: s.tier as string | undefined,
+ description: s.description as string | undefined,
+ })));
+ }
+ } catch (e) {
+ setYoursError(e instanceof Error ? e.message : "Failed to load");
+ } finally {
+ setYoursLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchOwned();
+ }, [fetchOwned]);
+
+ /* ---- fetch community ---- */
+ const fetchCommunity = useCallback(async () => {
+ setCommunityLoading(true);
setFetchError(null);
try {
const params = new URLSearchParams();
@@ -139,28 +209,27 @@ export const MarketplacePage = () => {
if (typeFilter !== "all") params.set("type", typeFilter);
if (search.trim()) params.set("q", search.trim());
const qs = params.toString();
- const url = `/api/v1/marketplace${qs ? `?${qs}` : ""}`;
- const res = await fetch(url);
+ const res = await fetch(`/api/v1/catalog${qs ? `?${qs}` : ""}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setItems(data.items ?? []);
} catch (e) {
- setFetchError(e instanceof Error ? e.message : "Failed to load marketplace");
+ setFetchError(e instanceof Error ? e.message : "Failed to load");
} finally {
- setLoading(false);
+ setCommunityLoading(false);
}
}, [category, typeFilter, search]);
useEffect(() => {
- fetchItems();
- }, [fetchItems]);
+ if (tab === "community") fetchCommunity();
+ }, [tab, fetchCommunity]);
/* ---- actions ---- */
const handleDeploy = async (name: string) => {
setActionLoading(name);
setActionResult(null);
try {
- const res = await fetch(`/api/v1/marketplace/${encodeURIComponent(name)}/deploy`, { method: "POST" });
+ const res = await fetch(`/api/v1/catalog/${encodeURIComponent(name)}/deploy`, { method: "POST" });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error((data as Record).error ?? `HTTP ${res.status}`);
@@ -177,7 +246,7 @@ export const MarketplacePage = () => {
setActionLoading(name);
setActionResult(null);
try {
- const res = await fetch(`/api/v1/marketplace/${encodeURIComponent(name)}/install`, { method: "POST" });
+ const res = await fetch(`/api/v1/catalog/${encodeURIComponent(name)}/install`, { method: "POST" });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error((data as Record).error ?? `HTTP ${res.status}`);
@@ -193,7 +262,6 @@ export const MarketplacePage = () => {
const handleFork = async (name: string) => {
setActionLoading(name);
try {
- /* fork is a conceptual clone — post to marketplace */
setActionResult({ name, message: `${name} forked to your workspace` });
} finally {
setActionLoading(null);
@@ -210,7 +278,7 @@ export const MarketplacePage = () => {
if (!rateTarget || rateValue < 1) return;
setRatingSubmitting(true);
try {
- const res = await fetch(`/api/v1/marketplace/${encodeURIComponent(rateTarget.name)}/rate`, {
+ const res = await fetch(`/api/v1/catalog/${encodeURIComponent(rateTarget.name)}/rate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rating: rateValue }),
@@ -218,7 +286,7 @@ export const MarketplacePage = () => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setRateModalOpen(false);
setActionResult({ name: rateTarget.name, message: `Rated ${rateTarget.name} ${rateValue} stars` });
- await fetchItems();
+ await fetchCommunity();
} catch {
/* ignore */
} finally {
@@ -226,27 +294,30 @@ export const MarketplacePage = () => {
}
};
- /* ---- sorting ---- */
+ /* ---- community sorting + filtering ---- */
const sortedItems = [...items].sort((a, b) => {
switch (sortBy) {
- case "popular":
- return b.usage_count - a.usage_count;
- case "rating":
- return b.rating - a.rating;
- case "recent":
- return 0; /* server order */
- default:
- return 0;
+ case "popular": return b.usage_count - a.usage_count;
+ case "rating": return b.rating - a.rating;
+ default: return 0;
}
});
- /* ---- badge filter ---- */
const BADGE_RANK: Record = { gold: 3, silver: 2, bronze: 1, untested: 0 };
const filteredItems = sortedItems.filter((item) => {
if (badgeFilter === "all") return true;
return BADGE_RANK[item.badge] >= BADGE_RANK[badgeFilter];
});
+ /* ---- yours filtering ---- */
+ const yoursItems: Array<{ kind: "agent"; data: OwnedAgent } | { kind: "skill"; data: OwnedSkill }> = [
+ ...(yoursFilter === "skills" ? [] : ownedAgents.map((a) => ({ kind: "agent" as const, data: a }))),
+ ...(yoursFilter === "agents" ? [] : ownedSkills.map((s) => ({ kind: "skill" as const, data: s }))),
+ ];
+ const filteredYours = yoursSearch.trim()
+ ? yoursItems.filter((item) => item.data.name.toLowerCase().includes(yoursSearch.toLowerCase()))
+ : yoursItems;
+
return (
<>
@@ -254,247 +325,359 @@ export const MarketplacePage = () => {
Marketplace
- Browse, deploy, and share agents and skills
+ Your registry and the community catalog in one place
-
- {filteredItems.length} items
+
+
+ {/* Yours / Community tabs */}
+
+
+ {([
+ { key: "yours", label: `Yours (${ownedAgents.length + ownedSkills.length})` },
+ { key: "community", label: "Community" },
+ ] as const).map((t) => (
+ setTab(t.key)}
+ style={{
+ padding: "10px 24px",
+ fontSize: 14,
+ fontWeight: 600,
+ color: tab === t.key ? "var(--arcana-text)" : "var(--arcana-text-muted)",
+ background: "none",
+ border: "none",
+ borderBottom: tab === t.key ? "2px solid #5b8def" : "2px solid transparent",
+ cursor: "pointer",
+ transition: "all 0.15s",
+ marginBottom: -1,
+ }}
+ >
+ {t.label}
+
+ ))}
-
-
- {fetchError && (
-
- {fetchError}
-
- )}
- {actionResult && (
-
- )}
-
-
- {/* Sidebar filters */}
-
-
-
- Type
-
-
- {(["all", "agent", "skill"] as const).map((t) => (
- setTypeFilter(t)}
- style={{ textAlign: "left", justifyContent: "flex-start" }}
- >
- {t === "all" ? "All Types" : t.charAt(0).toUpperCase() + t.slice(1) + "s"}
-
- ))}
-
-
+ {/* ===== Yours tab ===== */}
+ {tab === "yours" && (
+
+ {yoursError && (
+
+ {yoursError}
+
+ )}
+
+
+ setYoursSearch(val)}
+ onClear={() => setYoursSearch("")}
+ style={{ maxWidth: 280 }}
+ />
+
+ {([
+ { value: "all", label: `All (${ownedAgents.length + ownedSkills.length})` },
+ { value: "agents", label: `Agents (${ownedAgents.length})` },
+ { value: "skills", label: `Skills (${ownedSkills.length})` },
+ ] as const).map((opt) => (
+ setYoursFilter(opt.value)}
+ />
+ ))}
+
+
-
-
- Badge
-
-
- {(["all", "gold", "silver", "bronze"] as const).map((b) => (
-
setBadgeFilter(b)}
- style={{ textAlign: "left", justifyContent: "flex-start" }}
+ {yoursLoading ? (
+
+
+
+ ) : filteredYours.length === 0 ? (
+
+ {yoursSearch.trim()
+ ? `No results for "${yoursSearch}"`
+ : "Nothing registered yet. Deploy an agent or add a skill to get started."}
+
+ ) : (
+
+ {filteredYours.map((item) => {
+ if (item.kind === "agent") {
+ const agent = item.data;
+ const dot = STATUS_DOT[agent.status] ?? STATUS_DOT.offline;
+ const skills = agent.capabilities.filter((c) => !c.startsWith("model:"));
+ const model = agent.capabilities.find((c) => c.startsWith("model:"))?.replace("model:", "");
+ return (
+
navigate(`/agents/${agent.name}`)}
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 14,
+ padding: "14px 18px",
+ background: "var(--arcana-card-bg)",
+ borderRadius: 10,
+ border: "1px solid var(--arcana-card-border)",
+ cursor: "pointer",
+ transition: "background 0.15s",
+ }}
+ onMouseEnter={(e) => { (e.currentTarget as HTMLDivElement).style.background = "var(--arcana-hover-bg)"; }}
+ onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.background = "var(--arcana-card-bg)"; }}
+ >
+
+
+
+
+ {agent.name}
+
+
+ Agent · {model ?? "—"} · {dot.label}
+
+
+
+ {skills.slice(0, 3).map((s) => (
+ {s}
+ ))}
+ {skills.length > 3 && +{skills.length - 3} }
+
+
+ );
+ }
+
+ const skill = item.data;
+ return (
+
{ (e.currentTarget as HTMLDivElement).style.background = "var(--arcana-hover-bg)"; }}
+ onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.background = "var(--arcana-card-bg)"; }}
>
- {b === "all" ? "Any Badge" : `${b.charAt(0).toUpperCase() + b.slice(1)}+`}
-
- ))}
-
+
+
+
+ {skill.name}
+
+
+ Skill{skill.tier ? ` · ${skill.tier}` : ""}
+ {skill.description ? ` · ${skill.description}` : ""}
+
+
+ {skill.tier &&
{skill.tier} }
+
+ );
+ })}
-
-
-
- Sort By
+ )}
+
+ )}
+
+ {/* ===== Community tab ===== */}
+ {tab === "community" && (
+
+ {fetchError && (
+
+ {fetchError}
+
+ )}
+ {actionResult && (
+
+ )}
+
+
+ {/* Sidebar filters */}
+
+
+
+ Type
+
+
+ {(["all", "agent", "skill"] as const).map((t) => (
+ setTypeFilter(t)}
+ style={{ textAlign: "left", justifyContent: "flex-start" }}
+ >
+ {t === "all" ? "All Types" : t.charAt(0).toUpperCase() + t.slice(1) + "s"}
+
+ ))}
+
-
- {(["popular", "recent", "rating"] as const).map((s) => (
-
setSortBy(s)}
- style={{ textAlign: "left", justifyContent: "flex-start" }}
- >
- {s.charAt(0).toUpperCase() + s.slice(1)}
-
- ))}
+
+
+
+ Badge
+
+
+ {(["all", "gold", "silver", "bronze"] as const).map((b) => (
+ setBadgeFilter(b)}
+ style={{ textAlign: "left", justifyContent: "flex-start" }}
+ >
+ {b === "all" ? "Any Badge" : `${b.charAt(0).toUpperCase() + b.slice(1)}+`}
+
+ ))}
+
-
-
- {/* Main content */}
-
- {/* Search + category bar */}
-
-
setSearch(val)}
- onClear={() => setSearch("")}
- style={{ maxWidth: 320 }}
- />
-
- {CATEGORIES.map((cat) => (
- setCategory(cat.value)}
- />
- ))}
-
+
+
+ Sort By
+
+
+ {(["popular", "recent", "rating"] as const).map((s) => (
+ setSortBy(s)}
+ style={{ textAlign: "left", justifyContent: "flex-start" }}
+ >
+ {s.charAt(0).toUpperCase() + s.slice(1)}
+
+ ))}
+
+
- {loading ? (
-
-
+ {/* Main content */}
+
+
+ setSearch(val)}
+ onClear={() => setSearch("")}
+ style={{ maxWidth: 300 }}
+ />
+
+ {CATEGORIES.map((cat) => (
+ setCategory(cat.value)}
+ />
+ ))}
+
- ) : filteredItems.length === 0 ? (
-
-
-
+
+ {communityLoading ? (
+
+
-
- No items found
-
-
- Try adjusting your filters or search terms.
-
-
- ) : (
-
- {filteredItems.map((item) => (
-
-
-
-
-
- {item.name}
-
-
-
- {item.type === "agent" ? "Agent" : "Skill"}
-
-
- {BADGE_COLORS[item.badge].label}
-
+ ) : filteredItems.length === 0 ? (
+
+
+
No items found
+
+ Try adjusting your filters or search terms.
+
+
+ ) : (
+
+ {filteredItems.map((item) => (
+
+
+
+
+
+ {item.name}
+
+
+
+ {item.type === "agent" ? "Agent" : "Skill"}
+
+
+ {BADGE_COLORS[item.badge].label}
+
+
-
-
- {item.category}
-
- {item.tier && (
-
- {item.tier}
-
- )}
-
-
- {item.description}
-
-
-
-
-
- {item.usage_count.toLocaleString()} uses
-
-
+
{item.category}
+ {item.tier &&
{item.tier} }
+
+
+ {item.description}
+
+
+
+
+
+ {item.usage_count.toLocaleString()} uses
+
+
-
- {item.type === "agent" ? (
- }
- isLoading={actionLoading === item.name}
- isDisabled={actionLoading === item.name}
- onClick={() => handleDeploy(item.name)}
- style={{ flex: 1 }}
- >
- Deploy
-
- ) : (
- }
- isLoading={actionLoading === item.name}
- isDisabled={actionLoading === item.name}
- onClick={() => handleInstall(item.name)}
- style={{ flex: 1 }}
- >
- Install
-
- )}
- }
- onClick={() => handleFork(item.name)}
- aria-label={`Fork ${item.name}`}
- />
- }
- onClick={() => openRateModal(item)}
- aria-label={`Rate ${item.name}`}
- />
-
-
-
-
- ))}
-
- )}
+
+ {item.type === "agent" ? (
+ }
+ isLoading={actionLoading === item.name} isDisabled={actionLoading === item.name}
+ onClick={() => handleDeploy(item.name)} style={{ flex: 1 }}>
+ Deploy
+
+ ) : (
+ }
+ isLoading={actionLoading === item.name} isDisabled={actionLoading === item.name}
+ onClick={() => handleInstall(item.name)} style={{ flex: 1 }}>
+ Install
+
+ )}
+ }
+ onClick={() => handleFork(item.name)} aria-label={`Fork ${item.name}`} />
+ }
+ onClick={() => openRateModal(item)} aria-label={`Rate ${item.name}`} />
+
+
+
+
+ ))}
+
+ )}
+
-
-
+
+ )}
{/* Rate modal */}
-
setRateModalOpen(false)}
- aria-labelledby="rate-modal-title"
- >
+ setRateModalOpen(false)} aria-labelledby="rate-modal-title">
-
+
How would you rate this {rateTarget?.type}?
@@ -503,17 +686,10 @@ export const MarketplacePage = () => {
-
+
Submit Rating
- setRateModalOpen(false)}>
- Cancel
-
+ setRateModalOpen(false)}>Cancel
>
diff --git a/services/studio/src/pages/ModelsPage.tsx b/services/studio/src/pages/ModelsPage.tsx
index 004431f..f483308 100644
--- a/services/studio/src/pages/ModelsPage.tsx
+++ b/services/studio/src/pages/ModelsPage.tsx
@@ -74,14 +74,18 @@ export const ModelsPage = () => {
const fetchData = useCallback(async () => {
try {
- const [modelsRes, budgetRes] = await Promise.all([
+ const [modelsRes, budgetRes] = await Promise.allSettled([
fetch("/api/v1/models"),
fetch("/api/v1/budget"),
]);
- const modelsData = await modelsRes.json();
- const budgetData = await budgetRes.json();
- setModels(modelsData.models || []);
- setBudget(budgetData);
+ if (modelsRes.status === "fulfilled" && modelsRes.value.ok) {
+ const data = await modelsRes.value.json();
+ setModels(data.models || []);
+ }
+ if (budgetRes.status === "fulfilled" && budgetRes.value.ok) {
+ const data = await budgetRes.value.json();
+ setBudget(data);
+ }
} catch {
setError("Failed to load models data");
} finally {
diff --git a/services/studio/src/pages/OrgChartPage.tsx b/services/studio/src/pages/OrgChartPage.tsx
index d00d460..8f7b0f9 100644
--- a/services/studio/src/pages/OrgChartPage.tsx
+++ b/services/studio/src/pages/OrgChartPage.tsx
@@ -161,7 +161,7 @@ export const OrgChartPage = () => {
try {
const [agentsRes, teamsRes] = await Promise.allSettled([
fetch("/api/v1/agents"),
- fetch("/api/v1/finops/teams"),
+ fetch("/api/v1/costs/teams"),
]);
if (agentsRes.status === "fulfilled" && agentsRes.value.ok) {
diff --git a/services/studio/src/pages/PlatformChatPage.tsx b/services/studio/src/pages/PlatformChatPage.tsx
index 602a776..965a730 100644
--- a/services/studio/src/pages/PlatformChatPage.tsx
+++ b/services/studio/src/pages/PlatformChatPage.tsx
@@ -1,33 +1,8 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
-import Chatbot, {
- ChatbotDisplayMode,
-} from "@patternfly/chatbot/dist/dynamic/Chatbot";
-import ChatbotContent from "@patternfly/chatbot/dist/dynamic/ChatbotContent";
-import ChatbotWelcomePrompt from "@patternfly/chatbot/dist/dynamic/ChatbotWelcomePrompt";
-import ChatbotFooter, {
- ChatbotFootnote,
-} from "@patternfly/chatbot/dist/dynamic/ChatbotFooter";
-import MessageBar from "@patternfly/chatbot/dist/dynamic/MessageBar";
-import MessageBox from "@patternfly/chatbot/dist/dynamic/MessageBox";
-import Message from "@patternfly/chatbot/dist/dynamic/Message";
-import ChatbotHeader, {
- ChatbotHeaderMain,
- ChatbotHeaderTitle,
- ChatbotHeaderActions,
-} from "@patternfly/chatbot/dist/dynamic/ChatbotHeader";
-import ChatbotConversationHistoryNav, {
- type Conversation,
-} from "@patternfly/chatbot/dist/dynamic/ChatbotConversationHistoryNav";
-import { Button, Spinner } from "@patternfly/react-core";
-import { PlusCircleIcon } from "@patternfly/react-icons";
+import { Spinner } from "@patternfly/react-core";
import { useAuth } from "../auth/AuthContext";
-import {
- detectCommand,
- executePlatformCommand,
-} from "./platformCommands";
-
-/* ---------- types ---------- */
+import { detectCommand, executePlatformCommand } from "./platformCommands";
interface ConversationMessage {
id: string;
@@ -43,399 +18,358 @@ interface ChatSession {
updated_at: string;
}
-/* ---------- welcome prompts ---------- */
-
-const WELCOME_PROMPTS = [
- { title: "Deploy an agent", message: "Deploy an agent called my-assistant" },
- { title: "Show costs", message: "Show me the current platform costs" },
- { title: "Check system status", message: "What is the system health status?" },
- { title: "List skills", message: "List all available skills" },
- { title: "List models", message: "Show me all registered models" },
- { title: "View audit logs", message: "Show recent audit events" },
+const SUGGESTIONS = [
+ { label: "Deploy an agent", msg: "Deploy an agent called my-assistant" },
+ { label: "System status", msg: "What is the system health status?" },
+ { label: "List skills", msg: "List all available skills" },
+ { label: "Show costs", msg: "Show me the current platform costs" },
];
-/* ---------- component ---------- */
-
export const PlatformChatPage = () => {
const navigate = useNavigate();
const { authHeaders } = useAuth();
const [messages, setMessages] = useState
([]);
+ const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [sessionId, setSessionId] = useState(null);
const [sessions, setSessions] = useState([]);
const [sessionsLoading, setSessionsLoading] = useState(true);
- const [isHistoryOpen, setIsHistoryOpen] = useState(true);
+ const [sidebarOpen, setSidebarOpen] = useState(true);
const scrollRef = useRef(null);
+ const inputRef = useRef(null);
const abortRef = useRef(null);
useEffect(() => {
- if (scrollRef.current) {
- scrollRef.current.scrollIntoView({ behavior: "smooth" });
- }
+ scrollRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, isLoading]);
- /* ----- load session list ----- */
+ useEffect(() => {
+ inputRef.current?.focus();
+ }, []);
+
const loadSessions = useCallback(async () => {
setSessionsLoading(true);
try {
- const res = await fetch("/api/v1/chat/sessions", {
- headers: { ...authHeaders() },
- });
+ const res = await fetch("/api/v1/chat/sessions", { headers: { ...authHeaders() } });
if (res.ok) {
const data = await res.json();
- const list: ChatSession[] = Array.isArray(data) ? data : data.sessions ?? [];
- setSessions(list);
+ setSessions(Array.isArray(data) ? data : data.sessions ?? []);
}
- } catch {
- /* best-effort */
- } finally {
- setSessionsLoading(false);
- }
+ } catch { /* best-effort */ }
+ finally { setSessionsLoading(false); }
}, [authHeaders]);
- useEffect(() => {
- loadSessions();
- }, [loadSessions]);
+ useEffect(() => { loadSessions(); }, [loadSessions]);
- /* ----- load messages for a session ----- */
- const loadSessionMessages = useCallback(
- async (id: string) => {
- try {
- const res = await fetch(`/api/v1/chat/sessions/${id}/messages`, {
- headers: { ...authHeaders() },
- });
- if (!res.ok) return;
- const data = await res.json();
- const msgs: Array<{ role: string; content: string; timestamp?: string }> =
- Array.isArray(data) ? data : data.messages ?? [];
- setMessages(
- msgs.map((m, i) => ({
- id: `${id}-${i}`,
- role: m.role === "user" ? "user" : "bot",
- content: m.content,
- timestamp: m.timestamp ?? "",
- })),
- );
- setSessionId(id);
- } catch {
- /* best-effort */
- }
- },
- [authHeaders],
- );
+ const loadSessionMessages = useCallback(async (id: string) => {
+ try {
+ const res = await fetch(`/api/v1/chat/sessions/${id}/messages`, { headers: { ...authHeaders() } });
+ if (!res.ok) return;
+ const data = await res.json();
+ const msgs: Array<{ role: string; content: string; timestamp?: string }> =
+ Array.isArray(data) ? data : data.messages ?? [];
+ setMessages(msgs.map((m, i) => ({
+ id: `${id}-${i}`,
+ role: m.role === "user" ? "user" : "bot",
+ content: m.content,
+ timestamp: m.timestamp ?? "",
+ })));
+ setSessionId(id);
+ } catch { /* best-effort */ }
+ }, [authHeaders]);
- /* ----- new conversation ----- */
- const startNewConversation = useCallback(() => {
- if (abortRef.current) {
- abortRef.current.abort();
- abortRef.current = null;
- }
+ const startNew = useCallback(() => {
+ abortRef.current?.abort();
+ abortRef.current = null;
setMessages([]);
setSessionId(null);
setIsLoading(false);
+ inputRef.current?.focus();
}, []);
- /* ----- SSE streaming ----- */
- const streamAgentResponse = useCallback(
- async (text: string) => {
- const controller = new AbortController();
- abortRef.current = controller;
-
- const botId = `bot-${Date.now()}`;
- setMessages((prev) => [
- ...prev,
- { id: botId, role: "bot", content: "", timestamp: new Date().toLocaleTimeString() },
- ]);
-
- try {
- const payload: Record = { message: text };
- if (sessionId) payload.session_id = sessionId;
-
- const res = await fetch("/api/v1/chat/stream", {
- method: "POST",
- headers: { "Content-Type": "application/json", ...authHeaders() },
- body: JSON.stringify(payload),
- signal: controller.signal,
- });
-
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const streamResponse = useCallback(async (text: string) => {
+ const controller = new AbortController();
+ abortRef.current = controller;
+ const botId = `bot-${Date.now()}`;
+ setMessages((prev) => [...prev, { id: botId, role: "bot", content: "", timestamp: new Date().toLocaleTimeString() }]);
- const contentType = res.headers.get("content-type") ?? "";
- if (contentType.includes("text/event-stream") && res.body) {
- const reader = res.body.getReader();
- const decoder = new TextDecoder();
- let accumulated = "";
-
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- const chunk = decoder.decode(value, { stream: true });
- const lines = chunk.split("\n");
- for (const line of lines) {
- if (line.startsWith("data: ")) {
- const dataPayload = line.slice(6);
- if (dataPayload === "[DONE]") break;
- try {
- const parsed = JSON.parse(dataPayload);
- if (parsed.session_id) setSessionId(parsed.session_id);
- const token = parsed.token ?? parsed.content ?? "";
- if (token) {
- accumulated += token;
- setMessages((prev) =>
- prev.map((m) => (m.id === botId ? { ...m, content: accumulated } : m)),
- );
- }
- } catch {
- if (dataPayload.trim()) {
- accumulated += dataPayload;
- setMessages((prev) =>
- prev.map((m) => (m.id === botId ? { ...m, content: accumulated } : m)),
- );
- }
- }
- }
+ try {
+ const payload: Record = { message: text };
+ if (sessionId) payload.session_id = sessionId;
+ const res = await fetch("/api/v1/chat/stream", {
+ method: "POST",
+ headers: { "Content-Type": "application/json", ...authHeaders() },
+ body: JSON.stringify(payload),
+ signal: controller.signal,
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+
+ const ct = res.headers.get("content-type") ?? "";
+ if (ct.includes("text/event-stream") && res.body) {
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ let acc = "";
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ for (const line of decoder.decode(value, { stream: true }).split("\n")) {
+ if (!line.startsWith("data: ")) continue;
+ const d = line.slice(6);
+ if (d === "[DONE]") break;
+ try {
+ const p = JSON.parse(d);
+ if (p.session_id) setSessionId(p.session_id);
+ const tok = p.token ?? p.content ?? "";
+ if (tok) { acc += tok; setMessages((prev) => prev.map((m) => m.id === botId ? { ...m, content: acc } : m)); }
+ } catch {
+ if (d.trim()) { acc += d; setMessages((prev) => prev.map((m) => m.id === botId ? { ...m, content: acc } : m)); }
}
}
-
- if (!accumulated) {
- setMessages((prev) =>
- prev.map((m) => (m.id === botId ? { ...m, content: "Done." } : m)),
- );
- }
- } else {
- const data = await res.json();
- if (data.session_id) setSessionId(data.session_id);
- setMessages((prev) =>
- prev.map((m) =>
- m.id === botId ? { ...m, content: data.reply ?? data.content ?? "Done." } : m,
- ),
- );
}
-
- // Refresh sidebar after a conversation
- loadSessions();
- } catch (err) {
- if (err instanceof DOMException && err.name === "AbortError") return;
- setMessages((prev) =>
- prev.map((m) =>
- m.id === botId
- ? {
- ...m,
- content:
- "Sorry, I couldn't reach the Arcana API. Is the platform running? Try `make dev` to start.",
- }
- : m,
- ),
- );
+ if (!acc) setMessages((prev) => prev.map((m) => m.id === botId ? { ...m, content: "Done." } : m));
+ } else {
+ const data = await res.json();
+ if (data.session_id) setSessionId(data.session_id);
+ setMessages((prev) => prev.map((m) => m.id === botId ? { ...m, content: data.reply ?? data.content ?? "Done." } : m));
}
- },
- [sessionId, authHeaders, loadSessions],
- );
-
- /* ----- send handler ----- */
- const handleSend = useCallback(
- async (message: string | number) => {
- const text = String(message);
- if (!text.trim() || isLoading) return;
+ loadSessions();
+ } catch (err) {
+ if (err instanceof DOMException && err.name === "AbortError") return;
+ setMessages((prev) => prev.map((m) => m.id === botId ? { ...m, content: "Could not reach the Arcana API. Is the platform running?" } : m));
+ }
+ }, [sessionId, authHeaders, loadSessions]);
- const userMsg: ConversationMessage = {
- id: `user-${Date.now()}`,
- role: "user",
- content: text,
- timestamp: new Date().toLocaleTimeString(),
- };
- setMessages((prev) => [...prev, userMsg]);
- setIsLoading(true);
+ const send = useCallback(async (text: string) => {
+ if (!text.trim() || isLoading) return;
+ setMessages((prev) => [...prev, { id: `user-${Date.now()}`, role: "user", content: text, timestamp: new Date().toLocaleTimeString() }]);
+ setInput("");
+ setIsLoading(true);
- const action = detectCommand(text);
- if (action) {
- try {
- const result = await executePlatformCommand(action, text, authHeaders());
- setMessages((prev) => [
- ...prev,
- {
- id: `bot-${Date.now()}`,
- role: "bot",
- content: result.markdown,
- agentName: result.agentName,
- timestamp: new Date().toLocaleTimeString(),
- },
- ]);
- } catch {
- setMessages((prev) => [
- ...prev,
- {
- id: `err-${Date.now()}`,
- role: "bot",
- content: "❌ **Command failed.** Could not reach the Arcana API.",
- timestamp: new Date().toLocaleTimeString(),
- },
- ]);
- } finally {
- setIsLoading(false);
- }
- } else {
- try {
- await streamAgentResponse(text);
- } finally {
- setIsLoading(false);
- }
- }
- },
- [isLoading, authHeaders, streamAgentResponse],
- );
+ const action = detectCommand(text);
+ if (action) {
+ try {
+ const result = await executePlatformCommand(action, text, authHeaders());
+ setMessages((prev) => [...prev, { id: `bot-${Date.now()}`, role: "bot", content: result.markdown, agentName: result.agentName, timestamp: new Date().toLocaleTimeString() }]);
+ } catch {
+ setMessages((prev) => [...prev, { id: `err-${Date.now()}`, role: "bot", content: "Command failed. Could not reach the API.", timestamp: new Date().toLocaleTimeString() }]);
+ } finally { setIsLoading(false); }
+ } else {
+ try { await streamResponse(text); } finally { setIsLoading(false); }
+ }
+ }, [isLoading, authHeaders, streamResponse]);
- /* ----- build conversation list for sidebar ----- */
- const conversations: Conversation[] = sessions.map((s) => ({
- id: s.id,
- text: s.title || `Session ${s.id.slice(0, 8)}`,
- onSelect: () => loadSessionMessages(s.id),
- }));
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ send(input);
+ }
+ };
- /* ----- render ----- */
return (
-
-
setIsHistoryOpen((prev) => !prev)}
- isDrawerOpen={isHistoryOpen}
- setIsDrawerOpen={setIsHistoryOpen}
- activeItemId={sessionId ?? undefined}
- onSelectActiveItem={(_e, itemId) => {
- if (typeof itemId === "string") {
- loadSessionMessages(itemId);
- }
- }}
- conversations={conversations}
- onNewChat={startNewConversation}
- newChatButtonText="New Conversation"
- searchInputPlaceholder="Search conversations..."
- searchInputAriaLabel="Search conversations"
- isLoading={sessionsLoading}
- drawerContent={
-
-
-
-
-
+ {/* Sidebar */}
+ {sidebarOpen && (
+
+
+ Conversations
+ + New
+
+
+ {sessionsLoading ? (
+
+ ) : sessions.length === 0 ? (
+
+ No conversations yet
+
+ ) : sessions.map((s) => (
+
loadSessionMessages(s.id)} style={{
+ display: "block", width: "100%", textAlign: "left", padding: "10px 12px",
+ borderRadius: 8, border: "none", cursor: "pointer", marginBottom: 2,
+ background: sessionId === s.id ? "rgba(91,141,239,0.1)" : "transparent",
+ color: sessionId === s.id ? "var(--arcana-text)" : "var(--arcana-text-secondary)",
+ fontSize: 13, fontWeight: sessionId === s.id ? 600 : 400,
+ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
+ transition: "background 0.15s",
+ }}>
+ {s.title || `Session ${s.id.slice(0, 8)}`}
+
+ ))}
+
+
+ )}
+
+ {/* Main chat area */}
+
+ {/* Header */}
+
+
setSidebarOpen(!sidebarOpen)} style={{
+ background: "none", border: "none", cursor: "pointer", color: "var(--arcana-text-muted)",
+ fontSize: 16, padding: 4,
+ }}>
+ {sidebarOpen ? "☰" : "☰"}
+
+
+
+
+ {/* Messages */}
+
+
+ {messages.length === 0 && !sessionsLoading && (
+
+
A
+
+ What can I help you with?
+
+
+ Deploy agents, check costs, manage skills — in plain English.
+
+
+ {SUGGESTIONS.map((s) => (
+
send(s.msg)} style={{
+ padding: "8px 16px", borderRadius: 20, fontSize: 13, fontWeight: 500,
+ border: "1px solid var(--arcana-card-border)", background: "var(--arcana-card-bg)",
+ color: "var(--arcana-text-secondary)", cursor: "pointer", transition: "all 0.15s",
}}
- >
- { (e.currentTarget as HTMLButtonElement).style.borderColor = "rgba(91,141,239,0.4)"; (e.currentTarget as HTMLButtonElement).style.color = "var(--arcana-text)"; }}
+ onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.borderColor = "var(--arcana-card-border)"; (e.currentTarget as HTMLButtonElement).style.color = "var(--arcana-text-secondary)"; }}
>
- A
-
- Arcana Chat
-
-
-
-
- }
- />
-
-
-
-
-
- {messages.length === 0 && !sessionsLoading && (
- ({
- title: p.title,
- message: p.message,
- onClick: () => handleSend(p.message),
- }))}
- />
- )}
-
- {messages.length === 0 && sessionsLoading && (
-
-
-
+ {s.label}
+
+ ))}
+
+
+ )}
+
+ {messages.map((msg) => (
+
+ {msg.role === "bot" && (
+
A
)}
-
- {messages.map((msg) => (
-
-
- {msg.role === "bot" && msg.agentName && (
-
- navigate(`/agents/${msg.agentName}`)}
- style={{
- background:
- "var(--arcana-gradient, linear-gradient(135deg, #667eea, #764ba2))",
- color: "#fff",
- border: "none",
- borderRadius: 6,
- padding: "6px 16px",
- fontSize: 13,
- fontWeight: 600,
- }}
- >
- Open {msg.agentName} →
-
-
- )}
-
- ))}
-
- {isLoading && (
-
- )}
-
-
-
-
-
-
-
-
-
-
- }
- />
+
+ {msg.content}
+ {msg.agentName && (
+
+ navigate(`/agents/${msg.agentName}`)} style={{
+ padding: "6px 14px", borderRadius: 6, border: "none", fontSize: 12, fontWeight: 600,
+ background: "linear-gradient(135deg, #5b8def, #a855f7)", color: "#fff", cursor: "pointer",
+ }}>
+ Open {msg.agentName}
+
+
+ )}
+
+
+ ))}
+
+ {isLoading && (
+
+ )}
+
+
+
+
+
+ {/* Input */}
+
+
+
setInput(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder="Ask Arcana anything..."
+ rows={1}
+ style={{
+ flex: 1, padding: "12px 16px", borderRadius: 12, resize: "none",
+ border: "1px solid var(--arcana-card-border)", background: "var(--arcana-input-bg)",
+ color: "var(--arcana-text)", fontSize: 14, lineHeight: 1.5,
+ outline: "none", fontFamily: "inherit",
+ transition: "border-color 0.15s",
+ minHeight: 44, maxHeight: 120,
+ }}
+ onFocus={(e) => { e.currentTarget.style.borderColor = "rgba(91,141,239,0.5)"; }}
+ onBlur={(e) => { e.currentTarget.style.borderColor = "var(--arcana-card-border)"; }}
+ />
+ send(input)} disabled={isLoading || !input.trim()} style={{
+ width: 44, height: 44, borderRadius: 12, border: "none",
+ background: input.trim() ? "linear-gradient(135deg, #5b8def, #a855f7)" : "var(--arcana-card-bg)",
+ color: input.trim() ? "#fff" : "var(--arcana-text-muted)",
+ cursor: input.trim() && !isLoading ? "pointer" : "default",
+ display: "flex", alignItems: "center", justifyContent: "center",
+ transition: "all 0.15s", flexShrink: 0,
+ }}>
+
+
+
+
+
+
+ All data stays in your cluster
+
+
+
);
};
diff --git a/services/studio/src/pages/RegistryPage.tsx b/services/studio/src/pages/RegistryPage.tsx
new file mode 100644
index 0000000..88858a8
--- /dev/null
+++ b/services/studio/src/pages/RegistryPage.tsx
@@ -0,0 +1,268 @@
+import { useState, useEffect, useCallback } from "react";
+import {
+ PageSection,
+ Title,
+ Content,
+ Label,
+ SearchInput,
+ Spinner,
+ Alert,
+ Divider,
+ ToggleGroup,
+ ToggleGroupItem,
+} from "@patternfly/react-core";
+import {
+ RobotIcon,
+ CubesIcon,
+} from "@patternfly/react-icons";
+import { useNavigate } from "react-router-dom";
+
+type RegistryFilter = "all" | "agents" | "skills";
+
+interface RegistryAgent {
+ kind: "agent";
+ name: string;
+ status: string;
+ capabilities: string[];
+ registered_at?: string;
+}
+
+interface RegistrySkill {
+ kind: "skill";
+ name: string;
+ tier?: string;
+ description?: string;
+}
+
+type RegistryItem = RegistryAgent | RegistrySkill;
+
+const STATUS_DOT: Record = {
+ active: { color: "#22c55e", label: "Running" },
+ busy: { color: "#f59e0b", label: "Busy" },
+ idle: { color: "#8b95a5", label: "Sleeping" },
+ offline: { color: "#ef4444", label: "Crashed" },
+};
+
+export const RegistryPage = () => {
+ const navigate = useNavigate();
+ const [agents, setAgents] = useState([]);
+ const [skills, setSkills] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [filter, setFilter] = useState("all");
+ const [search, setSearch] = useState("");
+
+ const fetchAll = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const [agentsRes, skillsRes] = await Promise.allSettled([
+ fetch("/api/v1/agents"),
+ fetch("/api/v1/skills"),
+ ]);
+
+ const agentList: RegistryAgent[] = [];
+ if (agentsRes.status === "fulfilled" && agentsRes.value.ok) {
+ const data = await agentsRes.value.json();
+ for (const a of data.agents ?? []) {
+ agentList.push({
+ kind: "agent",
+ name: a.name,
+ status: a.status,
+ capabilities: a.capabilities ?? [],
+ registered_at: a.registered_at,
+ });
+ }
+ }
+
+ const skillList: RegistrySkill[] = [];
+ if (skillsRes.status === "fulfilled" && skillsRes.value.ok) {
+ const data = await skillsRes.value.json();
+ for (const s of data.skills ?? []) {
+ skillList.push({
+ kind: "skill",
+ name: s.name,
+ tier: s.tier,
+ description: s.description,
+ });
+ }
+ }
+
+ setAgents(agentList);
+ setSkills(skillList);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to load registry");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchAll();
+ }, [fetchAll]);
+
+ const allItems: RegistryItem[] = [
+ ...(filter === "skills" ? [] : agents),
+ ...(filter === "agents" ? [] : skills),
+ ];
+
+ const filtered = search.trim()
+ ? allItems.filter((item) =>
+ item.name.toLowerCase().includes(search.toLowerCase()),
+ )
+ : allItems;
+
+ const agentCount = agents.length;
+ const skillCount = skills.length;
+
+ return (
+ <>
+
+
+
+
Registry
+
+ All agents and skills registered on the platform
+
+
+
+ {agentCount} agent{agentCount !== 1 ? "s" : ""}
+ {skillCount} skill{skillCount !== 1 ? "s" : ""}
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Filters */}
+
+ setSearch(val)}
+ onClear={() => setSearch("")}
+ style={{ maxWidth: 280 }}
+ />
+
+ {([
+ { value: "all", label: `All (${agentCount + skillCount})` },
+ { value: "agents", label: `Agents (${agentCount})` },
+ { value: "skills", label: `Skills (${skillCount})` },
+ ] as const).map((opt) => (
+ setFilter(opt.value)}
+ />
+ ))}
+
+
+
+ {loading ? (
+
+
+
+ ) : filtered.length === 0 ? (
+
+ {search.trim()
+ ? `No results for "${search}"`
+ : "Nothing registered yet. Deploy an agent or add a skill to get started."}
+
+ ) : (
+
+ {filtered.map((item) => {
+ if (item.kind === "agent") {
+ const dot = STATUS_DOT[item.status] ?? STATUS_DOT.offline;
+ const skills = item.capabilities.filter((c) => !c.startsWith("model:"));
+ const model = item.capabilities.find((c) => c.startsWith("model:"))?.replace("model:", "");
+ return (
+
navigate(`/agents/${item.name}`)}
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 14,
+ padding: "14px 18px",
+ background: "var(--arcana-card-bg)",
+ borderRadius: 10,
+ border: "1px solid var(--arcana-card-border)",
+ cursor: "pointer",
+ transition: "background 0.15s",
+ }}
+ onMouseEnter={(e) => { (e.currentTarget as HTMLDivElement).style.background = "var(--arcana-hover-bg)"; }}
+ onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.background = "var(--arcana-card-bg)"; }}
+ >
+
+
+
+
+ {item.name}
+
+
+ Agent · {model ?? "—"} · {dot.label}
+
+
+
+ {skills.slice(0, 3).map((s) => (
+ {s}
+ ))}
+ {skills.length > 3 && +{skills.length - 3} }
+
+
+ );
+ }
+
+ return (
+
{ (e.currentTarget as HTMLDivElement).style.background = "var(--arcana-hover-bg)"; }}
+ onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.background = "var(--arcana-card-bg)"; }}
+ >
+
+
+
+ {item.name}
+
+
+ Skill{item.tier ? ` · ${item.tier}` : ""}
+ {item.description ? ` · ${item.description}` : ""}
+
+
+ {item.tier &&
{item.tier} }
+
+ );
+ })}
+
+ )}
+
+ >
+ );
+};
diff --git a/services/studio/src/pages/SettingsPage.tsx b/services/studio/src/pages/SettingsPage.tsx
index 919cdd1..de4c907 100644
--- a/services/studio/src/pages/SettingsPage.tsx
+++ b/services/studio/src/pages/SettingsPage.tsx
@@ -107,6 +107,15 @@ interface EnterpriseConfig {
api_key_count: number;
tenant_count: number;
roles: string[];
+ sso_enabled?: boolean;
+ oidc_issuer?: string;
+ oidc_client_id?: string;
+ oidc_client_secret?: string;
+ oidc_redirect_uri?: string;
+ oidc_scopes?: string;
+ saml_entity_id?: string;
+ saml_sso_url?: string;
+ saml_certificate?: string;
}
const PlatformTab = () => (
@@ -172,6 +181,186 @@ const PlatformTab = () => (
);
+const SSOConfigTab = () => {
+ const [authMode, setAuthMode] = useState("open");
+ const [oidcIssuer, setOidcIssuer] = useState("");
+ const [oidcClientId, setOidcClientId] = useState("");
+ const [oidcClientSecret, setOidcClientSecret] = useState("");
+ const [oidcRedirectUri, setOidcRedirectUri] = useState("");
+ const [oidcScopes, setOidcScopes] = useState("openid profile email");
+ const [samlEntityId, setSamlEntityId] = useState("");
+ const [samlSsoUrl, setSamlSsoUrl] = useState("");
+ const [samlCertificate, setSamlCertificate] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [saveResult, setSaveResult] = useState<{ type: "success" | "danger"; text: string } | null>(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ fetch("/api/v1/enterprise/config")
+ .then((r) => r.ok ? r.json() : null)
+ .then((data) => {
+ if (data) {
+ setAuthMode(data.auth_mode ?? "open");
+ setOidcIssuer(data.oidc_issuer ?? "");
+ setOidcClientId(data.oidc_client_id ?? "");
+ setOidcClientSecret(data.oidc_client_secret ?? "");
+ setOidcRedirectUri(data.oidc_redirect_uri ?? "");
+ setOidcScopes(data.oidc_scopes ?? "openid profile email");
+ setSamlEntityId(data.saml_entity_id ?? "");
+ setSamlSsoUrl(data.saml_sso_url ?? "");
+ setSamlCertificate(data.saml_certificate ?? "");
+ }
+ })
+ .finally(() => setLoading(false));
+ }, []);
+
+ const handleSave = async () => {
+ setSaving(true);
+ setSaveResult(null);
+ try {
+ const body: Record = { auth_mode: authMode };
+ if (authMode === "oidc") {
+ body.oidc_issuer = oidcIssuer;
+ body.oidc_client_id = oidcClientId;
+ body.oidc_client_secret = oidcClientSecret;
+ body.oidc_redirect_uri = oidcRedirectUri;
+ body.oidc_scopes = oidcScopes;
+ } else if (authMode === "saml") {
+ body.saml_entity_id = samlEntityId;
+ body.saml_sso_url = samlSsoUrl;
+ body.saml_certificate = samlCertificate;
+ }
+ const res = await fetch("/api/v1/enterprise/config", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error((data as Record).error ?? `HTTP ${res.status}`);
+ }
+ setSaveResult({ type: "success", text: "SSO configuration saved. Changes take effect on next restart." });
+ } catch (e) {
+ setSaveResult({ type: "danger", text: e instanceof Error ? e.message : "Save failed" });
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ if (loading) return ;
+
+ return (
+
+ Single Sign-On (SSO)
+
+ {saveResult && (
+
+ )}
+
+
+ setAuthMode(v)}
+ style={{ maxWidth: 300 }}
+ >
+
+
+
+
+
+
+
+ {authMode === "oidc" && (
+ <>
+
+ OIDC Configuration
+
+ setOidcIssuer(v)}
+ placeholder="https://accounts.google.com"
+ />
+
+
+ setOidcClientId(v)}
+ placeholder="your-client-id.apps.googleusercontent.com"
+ />
+
+
+ setOidcClientSecret(v)}
+ placeholder="your-client-secret"
+ />
+
+
+ setOidcRedirectUri(v)}
+ placeholder="https://arcana.example.com/auth/callback"
+ />
+
+
+ setOidcScopes(v)}
+ placeholder="openid profile email"
+ />
+
+ >
+ )}
+
+ {authMode === "saml" && (
+ <>
+
+ SAML Configuration
+
+ setSamlEntityId(v)}
+ placeholder="https://arcana.example.com"
+ />
+
+
+ setSamlSsoUrl(v)}
+ placeholder="https://idp.example.com/saml/sso"
+ />
+
+
+ setSamlCertificate(v)}
+ placeholder="Paste PEM certificate or upload"
+ />
+
+ >
+ )}
+
+
+
+ Save Configuration
+
+
+
+
+
+ );
+};
+
const SecurityTab = () => {
const [config, setConfig] = useState(null);
const [keys, setKeys] = useState([]);
@@ -179,13 +368,13 @@ const SecurityTab = () => {
const fetchData = useCallback(async () => {
try {
- const [configRes, keysRes] = await Promise.all([
+ const [configRes, keysRes] = await Promise.allSettled([
fetch("/api/v1/enterprise/config"),
fetch("/api/v1/auth/keys"),
]);
- if (configRes.ok) setConfig(await configRes.json());
- if (keysRes.ok) {
- const kd = await keysRes.json();
+ if (configRes.status === "fulfilled" && configRes.value.ok) setConfig(await configRes.value.json());
+ if (keysRes.status === "fulfilled" && keysRes.value.ok) {
+ const kd = await keysRes.value.json();
setKeys(kd.keys ?? []);
}
} finally {
@@ -319,12 +508,12 @@ const AuditTab = () => {
const [loading, setLoading] = useState(true);
useEffect(() => {
- Promise.all([
- fetch("/api/v1/enterprise/audit").then(r => r.json()),
- fetch("/api/v1/enterprise/audit/stats").then(r => r.json()),
- ]).then(([auditData, statsData]) => {
- setEntries(auditData.entries ?? []);
- setStats(statsData);
+ Promise.allSettled([
+ fetch("/api/v1/enterprise/audit").then(r => r.ok ? r.json() : null),
+ fetch("/api/v1/enterprise/audit/stats").then(r => r.ok ? r.json() : null),
+ ]).then(([auditRes, statsRes]) => {
+ if (auditRes.status === "fulfilled" && auditRes.value) setEntries(auditRes.value.entries ?? []);
+ if (statsRes.status === "fulfilled" && statsRes.value) setStats(statsRes.value);
}).finally(() => setLoading(false));
}, []);
@@ -758,19 +947,22 @@ export const SettingsPage = () => {
Platform}>
- RBAC}>
+ SSO}>
+
+
+ RBAC}>
- Security & Auth}>
+ API Keys}>
- Tenants}>
+ Tenants}>
- Audit Log}>
+ Audit Log}>
- Compliance}>
+ Compliance}>
diff --git a/services/studio/src/pages/platformCommands.ts b/services/studio/src/pages/platformCommands.ts
index 515d940..fe6405e 100644
--- a/services/studio/src/pages/platformCommands.ts
+++ b/services/studio/src/pages/platformCommands.ts
@@ -155,7 +155,7 @@ export async function executePlatformCommand(
case "show_costs": {
try {
- const res = await fetch("/api/v1/finops/summary", { headers });
+ const res = await fetch("/api/v1/costs", { headers });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const total = data.total_cost ?? data.totalCost ?? "N/A";
diff --git a/services/studio/src/styles.css b/services/studio/src/styles.css
index 7d19318..a41a538 100644
--- a/services/studio/src/styles.css
+++ b/services/studio/src/styles.css
@@ -60,16 +60,7 @@
letter-spacing: -0.3px;
}
-.arcana-logo-badge {
- font-size: 10px;
- font-weight: 600;
- color: rgba(255,255,255,0.85);
- background: rgba(255,255,255,0.15);
- padding: 2px 8px;
- border-radius: 10px;
- letter-spacing: 0.5px;
- text-transform: uppercase;
-}
+/* Logo badge removed — clean wordmark only */
.arcana-env-badge {
background: rgba(255,255,255,0.15) !important;
@@ -289,6 +280,49 @@
margin: 2px 8px !important;
}
+.arcana-sidebar-footer {
+ border-top: 1px solid rgba(255,255,255,0.06);
+ padding: 8px 8px;
+ margin-top: auto;
+}
+
+.arcana-settings-btn {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ padding: 10px 16px;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--arcana-text-secondary, #8b95a5);
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s;
+ text-align: left;
+}
+
+.arcana-settings-btn:hover {
+ background: rgba(255,255,255,0.06);
+ color: var(--arcana-text, #e2e8f0);
+}
+
+.arcana-settings-btn.active {
+ background: rgba(91,141,239,0.1);
+ color: #5b8def;
+}
+
+.arcana-nav-group-label {
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 1.2px;
+ color: #4a5568;
+ padding: 16px 20px 6px;
+ user-select: none;
+}
+
/* Section titles */
.section-title {
font-size: 14px;
@@ -408,6 +442,31 @@
border-radius: 16px;
}
+/* Typing dots */
+.arcana-typing-dots {
+ display: inline-flex;
+ gap: 4px;
+ align-items: center;
+ height: 20px;
+}
+
+.arcana-typing-dots span {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--arcana-text-muted);
+ animation: arcana-dot-bounce 1.4s infinite ease-in-out both;
+}
+
+.arcana-typing-dots span:nth-child(1) { animation-delay: -0.32s; }
+.arcana-typing-dots span:nth-child(2) { animation-delay: -0.16s; }
+.arcana-typing-dots span:nth-child(3) { animation-delay: 0s; }
+
+@keyframes arcana-dot-bounce {
+ 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
+ 40% { transform: scale(1); opacity: 1; }
+}
+
@keyframes chat-slide-up {
from {
opacity: 0;