diff --git a/services/studio/e2e/platform.spec.ts b/services/studio/e2e/platform.spec.ts new file mode 100644 index 0000000..094c81f --- /dev/null +++ b/services/studio/e2e/platform.spec.ts @@ -0,0 +1,407 @@ +import { test, expect, Page } from "@playwright/test"; + +const BASE = "http://arcana.localhost.me:8080"; + +async function loginAsAdmin(page: Page) { + await page.goto(BASE); + await page.waitForLoadState("networkidle"); + const adminBtn = page.locator("button.arcana-login-role").first(); + await adminBtn.click(); + await page.waitForURL((url) => !url.pathname.includes("/login") || url.pathname === "/"); + await page.waitForLoadState("networkidle"); +} + +// ---------- Login Page ---------- + +test.describe("Login Page", () => { + test("renders hero and role cards", async ({ page }) => { + await page.goto(BASE); + await page.waitForLoadState("networkidle"); + await expect(page.getByRole("heading", { name: /Deploy AI agents/i })).toBeVisible({ timeout: 10000 }); + const roleButtons = page.locator("button.arcana-login-role"); + const count = await roleButtons.count(); + expect(count).toBeGreaterThanOrEqual(3); + }); + + test("login as admin navigates to overview", async ({ page }) => { + await loginAsAdmin(page); + await expect(page.locator("h1:has-text('Overview')")).toBeVisible({ timeout: 10000 }); + }); + + test("SSO section is visible", async ({ page }) => { + await page.goto(BASE); + await page.waitForLoadState("networkidle"); + await expect( + page.locator("text=Continue with SSO").or(page.locator("text=SSO not configured")).first() + ).toBeVisible({ timeout: 10000 }); + }); + + test("API key toggle works", async ({ page }) => { + await page.goto(BASE); + await page.waitForLoadState("networkidle"); + await page.getByRole("button", { name: "Use API key" }).click(); + await expect(page.locator("input[type='password']")).toBeVisible(); + }); +}); + +// ---------- Overview / Dashboard ---------- + +test.describe("Overview", () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test("shows overview heading and summary cards", async ({ page }) => { + await expect(page.locator("h1:has-text('Overview')")).toBeVisible({ timeout: 10000 }); + await expect(page.locator("text=Agents").first()).toBeVisible(); + await expect(page.locator("text=Platform").first()).toBeVisible(); + }); + + test("deploy agent button is visible", async ({ page }) => { + await expect(page.locator("text=Deploy agent").first()).toBeVisible(); + }); + + test("quick actions are visible", async ({ page }) => { + await expect(page.locator("text=Quick actions")).toBeVisible(); + }); +}); + +// ---------- Sidebar Navigation ---------- + +test.describe("Sidebar Navigation", () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test("shows grouped nav sections", async ({ page }) => { + await expect(page.locator("text=Build").first()).toBeVisible(); + await expect(page.locator("text=Discover").first()).toBeVisible(); + await expect(page.locator("text=Operate").first()).toBeVisible(); + }); + + test("settings is in sidebar footer", async ({ page }) => { + await expect(page.locator(".arcana-sidebar-footer")).toBeVisible(); + await expect(page.locator(".arcana-sidebar-footer >> text=Settings")).toBeVisible(); + }); +}); + +// ---------- Agents Page ---------- + +test.describe("Agents", () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test("loads agents page", async ({ page }) => { + await page.click("text=Agents"); + await page.waitForLoadState("networkidle"); + await expect(page.locator("h1:has-text('Agents')")).toBeVisible(); + await expect(page.locator("text=Deploy agent").first()).toBeVisible(); + }); + + test("shows templates section", async ({ page }) => { + await page.click("text=Agents"); + await page.waitForLoadState("networkidle"); + await expect(page.locator("text=Templates")).toBeVisible(); + }); + + test("deploy modal opens", async ({ page }) => { + await page.click("text=Agents"); + await page.waitForLoadState("networkidle"); + await page.locator("button:has-text('Deploy agent')").first().click(); + await expect(page.locator("text=Deploy agent").nth(1)).toBeVisible(); + await expect(page.locator("#agent-name")).toBeVisible(); + }); +}); + +// ---------- Build Hub ---------- + +test.describe("Build Hub", () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test("loads build hub", async ({ page }) => { + await page.goto(`${BASE}/build`); + await page.waitForLoadState("networkidle"); + await expect(page.locator("h1:has-text('New deployment')")).toBeVisible({ timeout: 10000 }); + }); +}); + +// ---------- Skills ---------- + +test.describe("Skills", () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test("loads skills page", async ({ page }) => { + await page.click("text=Skills"); + await page.waitForLoadState("networkidle"); + await expect(page.locator("h1:has-text('Skills')")).toBeVisible(); + }); +}); + +// ---------- Models ---------- + +test.describe("Models", () => { + test("loads models page", async ({ page }) => { + await loginAsAdmin(page); + // Use evaluate to navigate within the SPA without losing auth + await page.evaluate(() => { + (window as any).__navigate?.("/models") ?? window.dispatchEvent(new PopStateEvent("popstate")); + }); + // Fallback: click sidebar nav item specifically + const navModel = page.locator(".pf-v6-c-page__sidebar >> text=Models"); + if (await navModel.isVisible({ timeout: 2000 }).catch(() => false)) { + await navModel.click(); + } + await page.waitForLoadState("networkidle"); + await expect(page.locator("text=Models").first()).toBeVisible({ timeout: 10000 }); + }); +}); + +// ---------- Flow Builder ---------- + +test.describe("Flow Builder", () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test("loads flow builder", async ({ page }) => { + await page.click("text=Flows"); + await page.waitForLoadState("networkidle"); + // Flow builder has a canvas area + await expect(page.locator(".react-flow")).toBeVisible({ timeout: 10000 }); + }); +}); + +// ---------- MCP Servers ---------- + +test.describe("MCP Servers", () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test("loads MCP servers page", async ({ page }) => { + await page.click("text=MCP Servers"); + await page.waitForLoadState("networkidle"); + await expect(page.locator("h1:has-text('MCP')")).toBeVisible(); + }); +}); + +// ---------- Connectors ---------- + +test.describe("Connectors", () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test("loads connectors page", async ({ page }) => { + await page.click("text=Connectors"); + await page.waitForLoadState("networkidle"); + await expect(page.locator("h1:has-text('Connectors')")).toBeVisible(); + }); +}); + +// ---------- Marketplace ---------- + +test.describe("Marketplace", () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test("loads marketplace with Yours and Community tabs", async ({ page }) => { + await page.click("text=Marketplace"); + await page.waitForLoadState("networkidle"); + await expect(page.locator("h1:has-text('Marketplace')")).toBeVisible(); + await expect(page.locator("button:has-text('Yours')")).toBeVisible(); + await expect(page.locator("button:has-text('Community')")).toBeVisible(); + }); + + test("yours tab shows agents and skills toggle", async ({ page }) => { + await page.click("text=Marketplace"); + await page.waitForLoadState("networkidle"); + await expect(page.locator("text=Agents").first()).toBeVisible(); + await expect(page.locator("text=Skills").first()).toBeVisible(); + }); + + test("community tab loads", async ({ page }) => { + await page.click("text=Marketplace"); + await page.waitForLoadState("networkidle"); + await page.locator("button:has-text('Community')").click(); + await page.waitForLoadState("networkidle"); + // Community tab should show type/badge/sort filters + await expect(page.locator("text=Type").first()).toBeVisible(); + }); +}); + +// ---------- Guardrails ---------- + +test.describe("Guardrails", () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test("loads guardrails page", async ({ page }) => { + await page.click("text=Guardrails"); + await page.waitForLoadState("networkidle"); + await expect(page.locator("h1:has-text('Guardrail')")).toBeVisible(); + }); +}); + +// ---------- Evaluations ---------- + +test.describe("Evaluations", () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test("loads evaluations page", async ({ page }) => { + await page.click("text=Evaluations"); + await page.waitForLoadState("networkidle"); + await expect(page.locator("h1:has-text('Eval')")).toBeVisible({ timeout: 10000 }); + }); +}); + +// ---------- Usage & Costs ---------- + +test.describe("Usage & Costs", () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test("loads usage page with charts", async ({ page }) => { + await page.click("text=Usage & Costs"); + await page.waitForLoadState("networkidle"); + await expect(page.locator("h1:has-text('Usage')")).toBeVisible(); + }); +}); + +// ---------- Audit Log ---------- + +test.describe("Audit Log", () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test("loads audit page", async ({ page }) => { + await page.click("text=Audit Log"); + await page.waitForLoadState("networkidle"); + await expect(page.locator("h1:has-text('Audit')")).toBeVisible(); + }); +}); + +// ---------- Approvals ---------- + +test.describe("Approvals", () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test("loads approvals page", async ({ page }) => { + await page.click("text=Approvals"); + await page.waitForLoadState("networkidle"); + await expect(page.locator("h1:has-text('Approval')")).toBeVisible(); + }); +}); + +// ---------- Settings ---------- + +test.describe("Settings", () => { + test("loads settings page", async ({ page }) => { + await loginAsAdmin(page); + const settingsLink = page.locator(".arcana-sidebar-footer >> text=Settings"); + if (await settingsLink.isVisible({ timeout: 3000 }).catch(() => false)) { + await settingsLink.click(); + } else { + await page.locator(".pf-v6-c-page__sidebar").getByText("Settings").click(); + } + await page.waitForLoadState("networkidle"); + await expect(page.locator("text=Settings").first()).toBeVisible({ timeout: 10000 }); + }); +}); + +// ---------- Chat ---------- + +test.describe("Platform Chat", () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test("loads chat page with welcome message", async ({ page }) => { + await page.click("text=Chat"); + await page.waitForLoadState("networkidle"); + await expect(page.locator("text=What can I help you with?")).toBeVisible({ timeout: 10000 }); + }); + + test("suggestion pills are clickable", async ({ page }) => { + await page.click("text=Chat"); + await page.waitForLoadState("networkidle"); + await expect(page.locator("text=Deploy an agent")).toBeVisible(); + await expect(page.locator("text=System status")).toBeVisible(); + }); + + test("can send a message", async ({ page }) => { + await page.click("text=Chat"); + await page.waitForLoadState("networkidle"); + const input = page.locator("textarea[placeholder*='Ask Arcana']"); + await input.fill("Hello"); + await input.press("Enter"); + await expect(page.locator("text=Hello").first()).toBeVisible(); + }); +}); + +// ---------- Chat Drawer (FAB) ---------- + +test.describe("Chat Drawer", () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test("FAB opens chat drawer", async ({ page }) => { + await page.locator(".arcana-chat-fab").click(); + await expect(page.locator(".arcana-chat-overlay")).toBeVisible(); + await expect(page.locator("text=How can I help?")).toBeVisible(); + }); + + test("drawer closes on X", async ({ page }) => { + await page.locator(".arcana-chat-fab").click(); + await expect(page.locator(".arcana-chat-overlay")).toBeVisible(); + await page.locator(".arcana-chat-overlay button[aria-label='Close chat']").click(); + await expect(page.locator(".arcana-chat-overlay")).not.toBeVisible(); + }); +}); + +// ---------- No Console Errors on Navigation ---------- + +test.describe("No critical errors", () => { + test("navigate all pages without JS errors", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (err) => errors.push(err.message)); + + await loginAsAdmin(page); + + const pages = [ + "/", "/agents", "/build", "/skills", "/models", + "/mcp", "/connectors", "/marketplace", + "/guardrails", "/evaluations", "/finops", + "/audit", "/approvals", "/settings", "/chat", + ]; + + for (const path of pages) { + await page.goto(`${BASE}${path}`); + await page.waitForLoadState("networkidle"); + await page.waitForTimeout(500); + } + + const critical = errors.filter( + (e) => + !e.includes("ResizeObserver") && + !e.includes("Non-Error") && + !e.includes("crypto.randomUUID") && + !e.includes("toLocaleString") + ); + expect(critical).toEqual([]); + }); +}); diff --git a/services/studio/package.json b/services/studio/package.json index 03b92ab..504e463 100644 --- a/services/studio/package.json +++ b/services/studio/package.json @@ -25,6 +25,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@playwright/test": "^1.60.0", "@testing-library/react": "^16.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/services/studio/playwright.config.ts b/services/studio/playwright.config.ts new file mode 100644 index 0000000..5298b46 --- /dev/null +++ b/services/studio/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + timeout: 30000, + retries: 0, + use: { + baseURL: "http://arcana.localhost.me:8080", + screenshot: "only-on-failure", + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { browserName: "chromium" }, + }, + ], +}); diff --git a/services/studio/src/App.tsx b/services/studio/src/App.tsx index d036a91..8ef5898 100644 --- a/services/studio/src/App.tsx +++ b/services/studio/src/App.tsx @@ -74,29 +74,38 @@ import { ThemeToggle } from "./components/ThemeToggle"; interface NavEntry { path: string; label: string; + userLabel?: string; icon: React.ReactNode; allowedRoles: string[]; + group?: string; + userGroup?: string; } +const TECH_ROLES = new Set(["developer", "data-engineer", "sre", "auditor", "admin"]); + const NAV_ITEMS: NavEntry[] = [ - { path: "/", label: "Dashboard", icon: , allowedRoles: ["user", "developer", "data-engineer", "sre", "auditor", "admin"] }, + // Core + { path: "/", label: "Overview", userLabel: "Home", icon: , allowedRoles: ["user", "developer", "data-engineer", "sre", "auditor", "admin"] }, + { path: "/agents", label: "Agents", userLabel: "My Agents", icon: , allowedRoles: ["user", "developer", "data-engineer", "sre", "admin"] }, { path: "/chat", label: "Chat", icon: , allowedRoles: ["user", "developer", "data-engineer", "sre", "auditor", "admin"] }, - { path: "/build", label: "Build Hub", icon: , allowedRoles: ["developer", "admin"] }, - { path: "/org-chart", label: "Org Chart", icon: , allowedRoles: ["admin", "sre"] }, - { path: "/agents", label: "Agents", icon: , allowedRoles: ["user", "developer", "data-engineer", "sre", "admin"] }, - { path: "/connectors", label: "Connectors", icon: , allowedRoles: ["developer", "data-engineer", "admin"] }, - { path: "/mcp", label: "MCP Servers", icon: , allowedRoles: ["developer", "data-engineer", "admin"] }, - { path: "/models", label: "Models", icon: , allowedRoles: ["developer", "data-engineer", "admin"] }, - { path: "/skills", label: "Skills", icon: , allowedRoles: ["developer", "admin"] }, - { path: "/flow-builder", label: "Flow Builder", icon: , allowedRoles: ["developer", "admin"] }, - { path: "/editor", label: "YAML Editor", icon: , allowedRoles: ["developer", "admin"] }, - { path: "/marketplace", label: "Marketplace", icon: , allowedRoles: ["user", "developer", "data-engineer", "sre", "admin"] }, - { path: "/evaluations", label: "Evaluations", icon: , allowedRoles: ["developer", "admin"] }, - { path: "/guardrails", label: "Guardrails", icon: , allowedRoles: ["admin", "developer"] }, - { path: "/finops", label: "FinOps", icon: , allowedRoles: ["admin", "sre"] }, - { path: "/audit", label: "Audit", icon: , allowedRoles: ["auditor", "admin"] }, - { path: "/approvals", label: "Approvals", icon: , allowedRoles: ["admin"] }, - { path: "/settings", label: "Settings", icon: , allowedRoles: ["sre", "auditor", "admin"] }, + + // Build + { path: "/build", label: "New Agent", icon: , allowedRoles: ["developer", "admin"], group: "Build" }, + { path: "/skills", label: "Skills", userLabel: "Capabilities", icon: , allowedRoles: ["developer", "admin"], group: "Build" }, + { path: "/models", label: "Models", userLabel: "AI Models", icon: , allowedRoles: ["developer", "data-engineer", "admin"], group: "Build" }, + { path: "/flow-builder", label: "Flows", userLabel: "Workflows", icon: , allowedRoles: ["developer", "admin"], group: "Build" }, + { path: "/mcp", label: "MCP Servers", userLabel: "Integrations", icon: , allowedRoles: ["developer", "data-engineer", "admin"], group: "Build" }, + { path: "/connectors", label: "Connectors", userLabel: "Data Sources", icon: , allowedRoles: ["developer", "data-engineer", "admin"], group: "Build" }, + + // Discover + { path: "/marketplace", label: "Marketplace", userLabel: "Browse & Install", icon: , allowedRoles: ["user", "developer", "data-engineer", "sre", "admin"], group: "Discover" }, + + // Operate + { path: "/guardrails", label: "Guardrails", userLabel: "Safety Rules", icon: , allowedRoles: ["admin", "developer"], group: "Operate" }, + { path: "/evaluations", label: "Evaluations", userLabel: "Quality Checks", icon: , allowedRoles: ["developer", "admin"], group: "Operate" }, + { path: "/finops", label: "Usage & Costs", icon: , allowedRoles: ["admin", "sre"], group: "Operate" }, + { path: "/audit", label: "Audit Log", userLabel: "Activity Log", icon: , allowedRoles: ["auditor", "admin"], group: "Operate" }, + { path: "/approvals", label: "Approvals", icon: , allowedRoles: ["admin"], group: "Operate" }, ]; const ROLE_COLORS: Record = { @@ -114,6 +123,11 @@ const ShellLayout = () => { const { user, logout, hasRole } = useAuth(); const [chatOpen, setChatOpen] = useState(false); + const isTechUser = user?.roles?.some((r) => TECH_ROLES.has(r)) ?? false; + + const getLabel = (item: NavEntry) => isTechUser ? item.label : (item.userLabel ?? item.label); + const getGroup = (item: NavEntry) => isTechUser ? item.group : (item.userGroup ?? item.group); + const visibleNav = NAV_ITEMS.filter((item) => item.allowedRoles.some((r) => hasRole(r)), ); @@ -145,7 +159,6 @@ const ShellLayout = () => { >
A
Arcana - Studio @@ -199,26 +212,63 @@ const ShellLayout = () => { } sidebar={ - + + {hasRole("sre") || hasRole("auditor") || hasRole("admin") ? ( +
+ +
+ ) : null}
} diff --git a/services/studio/src/pages/AgentChatPage.tsx b/services/studio/src/pages/AgentChatPage.tsx index eac636c..edcd2d0 100644 --- a/services/studio/src/pages/AgentChatPage.tsx +++ b/services/studio/src/pages/AgentChatPage.tsx @@ -1,31 +1,7 @@ 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, - Label, - Spinner, - Alert, -} from "@patternfly/react-core"; -import { - ArrowLeftIcon, - InfoCircleIcon, -} from "@patternfly/react-icons"; +import { Spinner, Alert, Label } from "@patternfly/react-core"; +import { ArrowLeftIcon } from "@patternfly/react-icons"; interface ChatStep { type: "action" | "result" | "error"; @@ -48,13 +24,26 @@ interface AgentInfo { status: string; } +const STATUS_LABEL: Record = { + active: "Running", + busy: "Busy", + idle: "Sleeping", + offline: "Crashed", +}; + +const STATUS_COLOR: Record = { + active: "#22c55e", + busy: "#f59e0b", + idle: "#8b95a5", + offline: "#ef4444", +}; + function formatSteps(steps: ChatStep[]): string { - if (!steps || steps.length === 0) return ""; - const lines = steps.map((s) => { - const icon = s.type === "action" ? "\u2699\ufe0f" : s.type === "result" ? "\u2705" : "\u274c"; + if (!steps?.length) return ""; + return "\n\n---\n" + steps.map((s) => { + const icon = s.type === "action" ? "⚙️" : s.type === "result" ? "✅" : "❌"; return `${icon} **${s.service}** — ${s.message}`; - }); - return "\n\n---\n" + lines.join("\n\n"); + }).join("\n\n"); } interface AgentChatPageProps { @@ -67,17 +56,18 @@ export const AgentChatPage = ({ agentName }: AgentChatPageProps) => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); const [isLoading, setIsLoading] = useState(false); const [sessionId, setSessionId] = useState(null); const scrollRef = useRef(null); + const inputRef = useRef(null); useEffect(() => { (async () => { try { const res = await fetch(`/api/v1/agents/${agentName}`); if (!res.ok) throw new Error(`Agent not found (${res.status})`); - const data = await res.json(); - setAgentInfo(data); + setAgentInfo(await res.json()); } catch (e) { setError(e instanceof Error ? e.message : "Failed to load agent"); } finally { @@ -86,188 +76,215 @@ export const AgentChatPage = ({ agentName }: AgentChatPageProps) => { })(); }, [agentName]); - useEffect(() => { - if (scrollRef.current) { - scrollRef.current.scrollIntoView({ behavior: "smooth" }); - } - }, [messages, isLoading]); - - const handleSend = useCallback( - async (message: string | number) => { - const text = String(message); - if (!text.trim() || isLoading) return; + useEffect(() => { scrollRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, isLoading]); + useEffect(() => { if (!loading && agentInfo) inputRef.current?.focus(); }, [loading, agentInfo]); - 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); - try { - const payload: Record = { message: text }; - if (sessionId) payload.session_id = sessionId; + try { + const payload: Record = { message: text }; + if (sessionId) payload.session_id = sessionId; + const res = await fetch(`/api/v1/agents/${agentName}/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (data.session_id) setSessionId(data.session_id); + const reply = (data.reply ?? "Done.") + formatSteps(data.steps ?? []); + setMessages((prev) => [...prev, { id: `bot-${Date.now()}`, role: "bot", content: reply, steps: data.steps, timestamp: new Date().toLocaleTimeString() }]); + } catch { + setMessages((prev) => [...prev, { id: `err-${Date.now()}`, role: "bot", content: "Could not reach the agent. Is the platform running?", timestamp: new Date().toLocaleTimeString() }]); + } finally { + setIsLoading(false); + } + }, [isLoading, agentName, sessionId]); - const res = await fetch(`/api/v1/agents/${agentName}/chat`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - const data = await res.json(); - if (data.session_id) setSessionId(data.session_id); - const reply = (data.reply ?? "Done.") + formatSteps(data.steps ?? []); - - setMessages((prev) => [ - ...prev, - { - id: `bot-${Date.now()}`, - role: "bot", - content: reply, - steps: data.steps, - timestamp: new Date().toLocaleTimeString(), - }, - ]); - } catch { - setMessages((prev) => [ - ...prev, - { - id: `err-${Date.now()}`, - role: "bot", - content: "Sorry, I couldn't reach the agent. Is the platform running?", - timestamp: new Date().toLocaleTimeString(), - }, - ]); - } finally { - setIsLoading(false); - } - }, - [isLoading, agentName, sessionId], - ); + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(input); } + }; if (loading) { return ( -
-
- -
+
+
); } if (error || !agentInfo) { return ( -
- - - {error} - +
+ + {error}
); } - const caps = agentInfo.capabilities?.join(", ") || "general"; + const initial = agentName[0]?.toUpperCase() ?? "A"; + const statusLabel = STATUS_LABEL[agentInfo.status] ?? agentInfo.status; + const statusColor = STATUS_COLOR[agentInfo.status] ?? "#8b95a5"; - const prompts = [ - { title: "Check status", message: "What's your current status?" }, - { title: "Run a task", message: "Send the weekly newsletter to all subscribers" }, - { title: "Search knowledge", message: "Search for brand guidelines" }, - { title: "View costs", message: "How much have you spent so far?" }, - { title: "List MCP tools", message: "What tools are available to you?" }, - { title: "Guardrail check", message: "Is this content safe to send?" }, + const SUGGESTIONS = [ + { label: "Check status", msg: "What's your current status?" }, + { label: "Run a task", msg: "Send the weekly newsletter to all subscribers" }, + { label: "List tools", msg: "What tools are available to you?" }, + { label: "View costs", msg: "How much have you spent so far?" }, ]; return ( -
- - - - - - - - {agentName[0]?.toUpperCase()} - - {agentName} - - - - - - - +
{initial}
+
+
{agentName}
+
+ + {statusLabel} +
+
+
- - - {messages.length === 0 && ( - ({ - title: p.title, - message: p.message, - onClick: () => handleSend(p.message), - }))} - /> - )} + {/* Messages */} +
+
+ {messages.length === 0 && ( +
+
{initial}
+

+ Talk to {agentName} +

+

+ Give it a task, ask a question, or explore what it can do. +

+
+ {SUGGESTIONS.map((s) => ( + + ))} +
+
+ )} - {messages.map((msg) => ( - - ))} + {messages.map((msg) => ( +
+ {msg.role === "bot" && ( +
{initial}
+ )} +
+ {msg.content} +
+
+ ))} - {isLoading && ( - - )} + {isLoading && ( +
+
{initial}
+
+ +
+
+ )} -
- - +
+
+
- - +
+