feat(ui): implement premium beige design system and ux refinements

This commit is contained in:
Yaojia Wang
2026-04-05 22:35:48 +02:00
parent d2b4610df9
commit 189a0fad34
30 changed files with 3651 additions and 801 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.2"
},
"devDependencies": {

View File

@@ -23,46 +23,23 @@ export function ChatInput({ onSend, disabled }: Props) {
};
return (
<div style={styles.container}>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={disabled ? "Waiting for response..." : "Type a message..."}
disabled={disabled}
style={styles.input}
/>
<button onClick={handleSubmit} disabled={disabled || !value.trim()} style={styles.button}>
Send
</button>
<div className="chat-input-container">
<div className="chat-input-wrapper">
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={disabled ? "Agent is working..." : "Message Smart Support..."}
disabled={disabled}
/>
<button className="chat-send-btn" onClick={handleSubmit} disabled={disabled || !value.trim()} aria-label="Send Message">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
</button>
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: {
display: "flex",
gap: "8px",
padding: "12px 16px",
borderTop: "1px solid #e0e0e0",
background: "white",
},
input: {
flex: 1,
padding: "10px 14px",
border: "1px solid #ccc",
borderRadius: "8px",
fontSize: "14px",
outline: "none",
},
button: {
padding: "10px 20px",
background: "#0066cc",
color: "white",
border: "none",
borderRadius: "8px",
fontSize: "14px",
cursor: "pointer",
},
};

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef } from "react";
import ReactMarkdown from "react-markdown";
import type { ChatMessage } from "../types";
interface Props {
@@ -13,70 +14,33 @@ export function ChatMessages({ messages }: Props) {
}, [messages]);
return (
<div style={styles.container}>
<div className="chat-messages-container">
{messages.map((msg) => (
<div
key={msg.id}
style={{
...styles.message,
...(msg.sender === "user" ? styles.userMessage : styles.agentMessage),
}}
>
<div style={styles.header}>
<span style={styles.sender}>
{msg.sender === "user" ? "You" : msg.agent || "Agent"}
</span>
<div key={msg.id} className="chat-message-row">
<div className={`avatar ${msg.sender === "user" ? "user" : "agent"}`}>
{msg.sender === "user" ? "Me" : "AI"}
</div>
<div style={styles.content}>
{msg.content}
{msg.isStreaming && <span style={styles.cursor}>|</span>}
<div className="message-body">
<div className="message-sender">
{msg.sender === "user" ? "You" : msg.agent || "Agent"}
</div>
<div className="message-content md-prose">
<ReactMarkdown>{msg.content}</ReactMarkdown>
{msg.isStreaming && <span className="cursor-blink">|</span>}
</div>
</div>
</div>
))}
{messages.length === 0 && (
<div className="chat-message-row">
<div className="avatar agent">AI</div>
<div className="message-body">
<div className="message-sender">Smart Support</div>
<div className="message-content">Hello! How can I help you today?</div>
</div>
</div>
)}
<div ref={bottomRef} />
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: {
flex: 1,
overflowY: "auto",
padding: "16px",
display: "flex",
flexDirection: "column",
gap: "12px",
},
message: {
maxWidth: "80%",
padding: "10px 14px",
borderRadius: "12px",
lineHeight: 1.5,
},
userMessage: {
alignSelf: "flex-end",
background: "#0066cc",
color: "white",
},
agentMessage: {
alignSelf: "flex-start",
background: "#f0f0f0",
color: "#333",
},
header: {
marginBottom: "4px",
},
sender: {
fontSize: "12px",
fontWeight: 600,
opacity: 0.8,
},
content: {
fontSize: "14px",
whiteSpace: "pre-wrap",
},
cursor: {
animation: "blink 1s infinite",
opacity: 0.7,
},
};

View File

@@ -7,75 +7,49 @@ interface Props {
export function InterruptPrompt({ interrupt, onRespond }: Props) {
return (
<div style={styles.container}>
<div style={styles.header}>Action Requires Approval</div>
<div style={styles.action}>
<strong>Action:</strong> {interrupt.action}
</div>
{"message" in interrupt.params && interrupt.params.message != null && (
<div style={styles.detail}>{String(interrupt.params.message)}</div>
)}
{"order_id" in interrupt.params && interrupt.params.order_id != null && (
<div style={styles.detail}>
<strong>Order:</strong> {String(interrupt.params.order_id)}
<div className="action-card-container">
<div className="action-card">
<div className="action-card-header">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--brand-accent)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
<h3 className="action-card-title">Action Requires Approval</h3>
<div style={{ flex: 1 }} />
<span className="action-card-badge">Pending</span>
</div>
<div className="action-card-body">
<div className="action-detail-row">
<span className="action-detail-label">Action Name</span>
<span className="action-detail-value" style={{ fontWeight: 600 }}>{interrupt.action}</span>
</div>
{"message" in interrupt.params && interrupt.params.message != null && (
<div className="action-detail-row">
<span className="action-detail-label">Detail Message</span>
<span className="action-detail-value">{String(interrupt.params.message)}</span>
</div>
)}
{"order_id" in interrupt.params && interrupt.params.order_id != null && (
<div className="action-detail-row">
<span className="action-detail-label">Target Order ID</span>
<span className="action-detail-value">{String(interrupt.params.order_id)}</span>
</div>
)}
</div>
<div className="action-card-footer">
<button className="btn btn-secondary" onClick={() => onRespond(false)}>
Reject & Escalate
</button>
<button className="btn btn-primary" onClick={() => onRespond(true)}>
Approve Action
</button>
</div>
)}
<div style={styles.buttons}>
<button onClick={() => onRespond(true)} style={styles.approveBtn}>
Approve
</button>
<button onClick={() => onRespond(false)} style={styles.rejectBtn}>
Reject
</button>
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: {
margin: "12px 16px",
padding: "16px",
border: "2px solid #ff9800",
borderRadius: "12px",
background: "#fff8e1",
},
header: {
fontWeight: 700,
fontSize: "14px",
color: "#e65100",
marginBottom: "8px",
},
action: {
fontSize: "14px",
marginBottom: "4px",
},
detail: {
fontSize: "13px",
color: "#555",
marginBottom: "4px",
},
buttons: {
display: "flex",
gap: "8px",
marginTop: "12px",
},
approveBtn: {
padding: "8px 20px",
background: "#4caf50",
color: "white",
border: "none",
borderRadius: "6px",
cursor: "pointer",
fontWeight: 600,
},
rejectBtn: {
padding: "8px 20px",
background: "#f44336",
color: "white",
border: "none",
borderRadius: "6px",
cursor: "pointer",
fontWeight: 600,
},
};

View File

@@ -3,9 +3,9 @@ import { NavBar } from "./NavBar";
export function Layout() {
return (
<div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
<div className="app-layout">
<NavBar />
<main style={{ flex: 1, overflow: "auto" }}>
<main className="app-main">
<Outlet />
</main>
</div>

View File

@@ -1,64 +1,56 @@
import { NavLink } from "react-router-dom";
const navLinks = [
{ to: "/", label: "Chat", exact: true },
{ to: "/replay", label: "Replay" },
{ to: "/dashboard", label: "Dashboard" },
{ to: "/review", label: "API Review" },
{ to: "/dashboard", label: "Dashboard", icon: "grid" },
{ to: "/", label: "Inbox", icon: "inbox" },
{ to: "/replay", label: "Conversation Replay", icon: "play" },
{ to: "/review", label: "Agents & Tools", icon: "cpu" },
];
const styles: Record<string, React.CSSProperties> = {
nav: {
display: "flex",
alignItems: "center",
gap: "0",
padding: "0 16px",
borderBottom: "1px solid #e0e0e0",
background: "#fff",
height: "48px",
boxShadow: "0 1px 4px rgba(0,0,0,0.06)",
},
brand: {
fontWeight: 700,
fontSize: "16px",
color: "#1a1a1a",
marginRight: "24px",
textDecoration: "none",
},
link: {
padding: "0 14px",
height: "48px",
display: "flex",
alignItems: "center",
fontSize: "14px",
color: "#555",
textDecoration: "none",
borderBottom: "2px solid transparent",
transition: "color 0.15s, border-color 0.15s",
},
activeLink: {
color: "#1976d2",
borderBottom: "2px solid #1976d2",
},
};
function getIcon(name: string) {
switch (name) {
case "grid": return <svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>;
case "inbox": return <svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"></polyline><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path></svg>;
case "play": return <svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>;
case "cpu": return <svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect><rect x="9" y="9" width="6" height="6"></rect><line x1="9" y1="1" x2="9" y2="4"></line><line x1="15" y1="1" x2="15" y2="4"></line><line x1="9" y1="20" x2="9" y2="23"></line><line x1="15" y1="20" x2="15" y2="23"></line><line x1="20" y1="9" x2="23" y2="9"></line><line x1="20" y1="14" x2="23" y2="14"></line><line x1="1" y1="9" x2="4" y2="9"></line><line x1="1" y1="14" x2="4" y2="14"></line></svg>;
default: return null;
}
}
export function NavBar() {
return (
<nav style={styles.nav}>
<span style={styles.brand}>Smart Support</span>
{navLinks.map(({ to, label }) => (
<NavLink
key={to}
to={to}
end={to === "/"}
style={({ isActive }) => ({
...styles.link,
...(isActive ? styles.activeLink : {}),
})}
>
{label}
</NavLink>
))}
<nav className="app-sidebar">
<div className="brand-header">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="brand-logo-svg">
<circle cx="12" cy="12" r="10"></circle>
<path d="M8 14s1.5 2 4 2 4-2 4-2"></path>
<line x1="9" y1="9" x2="9.01" y2="9"></line>
<line x1="15" y1="9" x2="15.01" y2="9"></line>
</svg>
<span style={{ fontSize: "1.25rem", letterSpacing: "-0.03em" }}>Nexus AI</span>
</div>
<div className="nav-links" style={{ marginTop: "1rem" }}>
{navLinks.map(({ to, label, icon }) => (
<NavLink
key={to}
to={to}
end={to === "/"}
className={({ isActive }) => `nav-link ${isActive ? "active" : ""}`}
style={{ display: "flex", gap: "12px", padding: "0.875rem 1rem", fontSize: "0.9375rem" }}
>
<span style={{ opacity: 0.7 }}>{getIcon(icon)}</span>
{label}
</NavLink>
))}
</div>
<div style={{ flex: 1 }} />
<div style={{ display: "flex", alignItems: "center", gap: "12px", borderTop: "1px solid var(--border-light)", paddingTop: "1rem" }}>
<div style={{ width: "36px", height: "36px", borderRadius: "50%", background: "var(--brand-primary)", color: "white", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: "bold" }}>A</div>
<div>
<div style={{ fontWeight: 600, fontSize: "0.875rem" }}>Alex Thompson</div>
<div style={{ fontSize: "0.75rem", color: "var(--text-secondary)" }}>Nexus Corp</div>
</div>
</div>
</nav>
);
}

View File

@@ -2,31 +2,31 @@ import { useState } from "react";
import type { ReplayStep } from "../api";
const TYPE_COLORS: Record<string, string> = {
message: "#1976d2",
token: "#388e3c",
tool_call: "#f57c00",
tool_result: "#7b1fa2",
interrupt: "#d32f2f",
interrupt_response: "#c2185b",
error: "#c62828",
message: "var(--brand-primary)",
token: "#9CA3AF", // Soft gray
tool_call: "#D97706", // Amber
tool_result: "#059669", // Emerald
interrupt: "#DC2626", // Red for wait
interrupt_response: "#7C3AED", // Purple for human action
error: "#991B1B", // Dark red
};
function TypeBadge({ type }: { type: string }) {
const color = TYPE_COLORS[type] ?? "#555";
const color = TYPE_COLORS[type] ?? "var(--text-secondary)";
return (
<span
style={{
background: color,
color: "#fff",
fontSize: "11px",
fontWeight: 600,
padding: "2px 7px",
borderRadius: "10px",
fontSize: "0.65rem",
fontWeight: 700,
padding: "0.2rem 0.5rem",
borderRadius: "99px",
textTransform: "uppercase",
letterSpacing: "0.5px",
letterSpacing: "0.05em",
}}
>
{type}
{type.replace("_", " ")}
</span>
);
}
@@ -38,9 +38,9 @@ function ReplayStepItem({ step }: { step: ReplayStep }) {
return (
<div
style={{
borderLeft: "2px solid #e0e0e0",
paddingLeft: "12px",
marginBottom: "12px",
borderLeft: "2px solid var(--border-light)",
paddingLeft: "1.25rem",
paddingBottom: "1.5rem",
position: "relative",
}}
>
@@ -48,70 +48,91 @@ function ReplayStepItem({ step }: { step: ReplayStep }) {
style={{
position: "absolute",
left: "-5px",
top: "4px",
top: "6px",
width: "8px",
height: "8px",
borderRadius: "50%",
background: TYPE_COLORS[step.type] ?? "#555",
background: TYPE_COLORS[step.type] ?? "var(--text-secondary)",
boxShadow: `0 0 0 4px var(--bg-surface)`
}}
/>
<div style={{ display: "flex", alignItems: "center", gap: "8px", marginBottom: "4px" }}>
<span style={{ fontSize: "11px", color: "#888" }}>#{step.step}</span>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.5rem" }}>
<TypeBadge type={step.type} />
{step.agent && (
<span style={{ fontSize: "11px", color: "#666", fontStyle: "italic" }}>
<span style={{ fontSize: "0.8125rem", color: "var(--text-primary)", fontWeight: 600 }}>
{step.agent}
</span>
)}
{step.tool && (
<span style={{ fontSize: "11px", color: "#555" }}>
tool: <strong>{step.tool}</strong>
<span style={{ fontSize: "0.8125rem", color: "var(--text-secondary)", fontFamily: "monospace", backgroundColor: "var(--bg-app)", padding: "2px 6px", borderRadius: "4px" }}>
{step.tool}()
</span>
)}
<span style={{ fontSize: "11px", color: "#aaa", marginLeft: "auto" }}>
{new Date(step.timestamp).toLocaleTimeString()}
<span style={{ fontSize: "0.75rem", color: "var(--text-secondary)", marginLeft: "auto" }}>
{new Date(step.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
</div>
{step.content && (
<div
style={{
fontSize: "13px",
color: "#333",
background: "#f9f9f9",
padding: "6px 10px",
borderRadius: "4px",
maxHeight: "80px",
overflow: "hidden",
textOverflow: "ellipsis",
}}
style={
["message", "interrupt", "interrupt_response"].includes(step.type)
? {
fontSize: "0.9375rem",
color: "var(--text-primary)",
background: step.type === "interrupt" ? "#FEF2F2" : (step.type === "interrupt_response" ? "#F5F3FF" : "var(--bg-app)"),
border: step.type === "interrupt" ? "1px solid #FECACA" : (step.type === "interrupt_response" ? "1px solid #DDD6FE" : "1px solid var(--border-light)"),
padding: "0.875rem 1rem",
borderRadius: "var(--radius-md)",
lineHeight: 1.5,
whiteSpace: "pre-wrap"
}
: {
fontSize: "0.8125rem",
color: "var(--text-secondary)",
padding: "0.25rem 0",
fontStyle: "italic",
lineHeight: 1.4
}
}
>
{step.content}
</div>
)}
{hasDetails && (
<button
onClick={() => setExpanded((v) => !v)}
style={{
background: "none",
border: "none",
color: "#1976d2",
color: "var(--text-secondary)",
cursor: "pointer",
fontSize: "12px",
padding: "2px 0",
fontSize: "0.75rem",
fontWeight: 600,
padding: "0.5rem 0 0 0",
display: "flex",
alignItems: "center",
gap: "0.25rem"
}}
>
{expanded ? "Hide details" : "Show details"}
{expanded ? "Hide JSON Payload" : "▶ View JSON Payload"}
</button>
)}
{expanded && hasDetails && (
<pre
style={{
fontSize: "11px",
background: "#f3f3f3",
padding: "8px",
borderRadius: "4px",
fontSize: "0.75rem",
background: "var(--text-primary)",
color: "white",
padding: "1rem",
borderRadius: "var(--radius-md)",
overflow: "auto",
maxHeight: "200px",
maxHeight: "250px",
marginTop: "0.5rem",
fontFamily: "monospace"
}}
>
{JSON.stringify({ params: step.params, result: step.result }, null, 2)}
@@ -126,19 +147,14 @@ interface ReplayTimelineProps {
}
export function ReplayTimeline({ steps }: ReplayTimelineProps) {
if (steps.length === 0) {
return (
<div style={{ color: "#888", fontSize: "14px", padding: "16px 0" }}>
No steps recorded.
</div>
);
}
if (!steps || steps.length === 0) return null;
return (
<div style={{ padding: "8px 0" }}>
<div style={{ marginLeft: "4px" }}>
{steps.map((step) => (
<ReplayStepItem key={step.step} step={step} />
))}
<div style={{ borderLeft: "2px dashed var(--border-light)", height: "20px", marginLeft: "0px", opacity: 0.5 }}></div>
</div>
);
}

678
frontend/src/index.css Normal file
View File

@@ -0,0 +1,678 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
:root {
/* Rich Warm Beige Theme based on Design Mockup */
--bg-app: #F4EFE7; /* Main app background (Sidebar & Main area) */
--bg-surface: #EBE4D8; /* Slightly darker for cards */
--bg-surface-inner: #F6F2EC; /* Lighter inner container */
--bg-hover: #E1D9CC; /* Hover state for sidebar and buttons */
--text-primary: #1C1917; /* Slate dark/brownish */
--text-secondary: #5C554D; /* Muted stone */
--border-light: #D5CCC0; /* Warm border */
--border-focus: #B6AAA0;
--brand-primary: #3B342D; /* Dark brown/grey for buttons */
--brand-hover: #26211C;
--brand-accent: #3B342D;
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.02);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.06);
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
--radius-md: 10px;
--radius-lg: 16px;
--radius-xl: 24px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-family: var(--font-sans);
background-color: #DBD2C6; /* Subtle deeper tone */
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
/* Application Shell Layout */
.app-layout {
display: flex;
height: 100vh;
width: 100vw;
overflow: hidden;
background-color: var(--bg-surface);
box-shadow: none;
border-radius: 0;
}
@media (min-width: 768px) {
body {
padding: 1.5rem;
box-sizing: border-box;
}
.app-layout {
height: calc(100vh - 3rem);
width: 100%;
border-radius: 20px;
box-shadow: 0 10px 30px rgba(0,0,0,0.06), 0 0 0 1px rgba(0,0,0,0.02);
}
}
/* Sidebar layout */
.app-sidebar {
width: 260px;
background-color: transparent; /* Makes it blend into the main background */
border-right: none;
display: flex;
flex-direction: column;
padding: 1.5rem 1rem;
z-index: 10;
}
.brand-header {
font-weight: 700;
font-size: 1.25rem;
color: var(--text-primary);
margin-bottom: 2rem;
padding-left: 0.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
letter-spacing: -0.01em;
}
.nav-links {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.nav-link {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
color: var(--text-secondary);
text-decoration: none;
font-weight: 600;
font-size: 0.9375rem;
transition: all 0.2s ease;
}
.nav-link:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.nav-link.active {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.app-main {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
background-color: var(--bg-app);
}
/* --- Chat Interface (Option B) --- */
.chat-page {
display: flex;
flex-direction: column;
height: 100%;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-light);
background-color: transparent;
z-index: 5;
}
.chat-header h1 {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
color: var(--text-primary);
}
.chat-messages-container {
flex: 1;
overflow-y: auto;
padding: 2rem 0;
scroll-behavior: smooth;
}
.chat-message-row {
display: flex;
gap: 1.25rem;
padding: 1rem;
transition: background-color 0.2s;
}
@media (min-width: 768px) {
.chat-message-row {
padding: 1rem 3rem;
}
}
.chat-message-row:hover {
background-color: var(--bg-hover);
}
.avatar {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.875rem;
}
.avatar.user {
background-color: var(--border-light);
color: var(--text-primary);
}
.avatar.agent {
background: linear-gradient(135deg, var(--brand-primary), #334155);
color: white;
}
.message-body {
flex: 1;
min-width: 0;
}
.message-sender {
font-weight: 600;
font-size: 0.875rem;
margin-bottom: 0.375rem;
color: var(--text-primary);
}
.message-content {
font-size: 0.9375rem;
line-height: 1.6;
color: var(--text-primary);
white-space: pre-wrap;
}
.cursor-blink {
animation: blink 1s infinite alternate;
font-weight: 700;
color: var(--brand-accent);
}
@keyframes blink {
0% { opacity: 1; }
100% { opacity: 0.2; }
}
/* Markdown Prose Styles */
.md-prose p {
margin: 0 0 0.75rem 0;
}
.md-prose p:last-child {
margin-bottom: 0;
}
.md-prose strong {
font-weight: 600;
color: var(--text-primary);
}
.md-prose ul, .md-prose ol {
margin: 0.25rem 0 0.75rem 0;
padding-left: 1.5rem;
}
.md-prose li {
margin-bottom: 0.25rem;
}
.md-prose pre {
background-color: var(--bg-hover);
padding: 0.75rem;
border-radius: var(--radius-md);
overflow-x: auto;
border: 1px solid var(--border-light);
font-size: 0.875rem;
}
.md-prose code {
font-family: monospace;
background-color: var(--bg-hover);
padding: 0.125rem 0.25rem;
border-radius: 4px;
}
.md-prose pre code {
background-color: transparent;
padding: 0;
}
/* Chat Input Bar */
.chat-input-container {
padding: 1.5rem;
background: linear-gradient(to top, var(--bg-app) 80%, transparent);
}
.chat-input-wrapper {
margin: 0 1rem;
position: relative;
display: flex;
align-items: center;
box-shadow: var(--shadow-md);
border-radius: var(--radius-lg);
background-color: var(--bg-surface);
border: 1px solid var(--border-light);
}
@media (min-width: 768px) {
.chat-input-wrapper {
margin: 0 3rem;
}
}
.chat-input-wrapper:focus-within {
border-color: var(--border-focus);
box-shadow: var(--shadow-lg);
}
.chat-input-wrapper input {
flex: 1;
padding: 1rem 1.25rem;
border: none;
background: transparent;
font-size: 1rem;
font-family: inherit;
color: var(--text-primary);
outline: none;
}
.chat-input-wrapper input::placeholder {
color: var(--text-secondary);
}
.chat-send-btn {
margin-right: 0.75rem;
width: 32px;
height: 32px;
border-radius: 6px;
background-color: var(--brand-primary);
color: white;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.chat-send-btn:hover:not(:disabled) {
background-color: var(--brand-hover);
}
.chat-send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: var(--text-secondary);
}
/* --- Human in the loop Action Card --- */
.action-card-container {
margin: 1.5rem 1rem;
}
@media (min-width: 768px) {
.action-card-container {
margin: 1.5rem 3rem;
}
}
.action-card {
background-color: var(--bg-surface);
border: 1px solid var(--border-light);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
overflow: hidden;
position: relative;
transition: transform 0.2s, box-shadow 0.2s;
}
.action-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 4px;
background-color: var(--brand-accent);
}
.action-card-header {
padding: 1.25rem 1.5rem 0.75rem 1.75rem;
display: flex;
align-items: center;
gap: 0.75rem;
border-bottom: 1px solid var(--bg-hover);
}
.action-card-title {
font-weight: 700;
font-size: 1rem;
color: var(--text-primary);
margin: 0;
}
.action-card-badge {
background-color: #FEF2F2;
color: #B91C1C;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.action-card-body {
padding: 1.25rem 1.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.action-detail-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.action-detail-label {
font-size: 0.75rem;
text-transform: uppercase;
font-weight: 600;
color: var(--text-secondary);
letter-spacing: 0.05em;
}
.action-detail-value {
font-size: 0.9375rem;
color: var(--text-primary);
font-family: var(--font-sans);
background-color: var(--bg-hover);
padding: 0.5rem 0.75rem;
border-radius: var(--radius-md);
border: 1px solid var(--border-light);
word-break: break-word;
}
.action-card-footer {
padding: 1.25rem 1.75rem;
background-color: var(--bg-hover);
border-top: 1px solid var(--border-light);
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1.25rem;
font-size: 0.875rem;
font-weight: 600;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-primary {
background-color: var(--brand-accent);
color: white;
box-shadow: var(--shadow-sm);
}
.btn-primary:hover {
background-color: #C2410C;
box-shadow: var(--shadow-md);
}
.btn-secondary {
background-color: transparent;
color: var(--text-primary);
border: 1px solid var(--border-focus);
}
.btn-secondary:hover {
background-color: var(--bg-surface);
color: var(--text-primary);
}
/* --- Agent Card Grid (Option 2) --- */
.page-container {
padding: 2.5rem;
width: 100%;
overflow-y: auto;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h2 {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 0.5rem 0;
letter-spacing: -0.01em;
}
.page-header p {
color: var(--text-secondary);
margin: 0;
font-size: 0.9375rem;
}
/* Form Styles */
.import-form {
display: flex;
gap: 0.75rem;
margin-bottom: 2rem;
background-color: var(--bg-surface);
padding: 1.5rem;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light);
}
.import-input {
flex: 1;
padding: 0.75rem 1rem;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
font-size: 0.9375rem;
font-family: inherit;
background-color: var(--bg-app);
color: var(--text-primary);
transition: all 0.2s;
}
.import-input:focus {
outline: none;
border-color: var(--border-focus);
box-shadow: 0 0 0 3px rgba(0,0,0,0.03);
}
/* Agent Grid */
.agent-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.agent-grid-card {
background-color: var(--bg-surface);
border-radius: var(--radius-xl);
border: 1px solid var(--border-light);
box-shadow: var(--shadow-sm);
overflow: hidden;
display: flex;
flex-direction: column;
transition: transform 0.2s, box-shadow 0.2s;
}
.agent-grid-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.agent-card-header-bg {
padding: 1.5rem 1.5rem 1rem 1.5rem;
border-bottom: 1px solid var(--bg-hover);
display: flex;
align-items: center;
gap: 1rem;
}
.agent-avatar-lg {
width: 48px;
height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, var(--text-primary), var(--text-secondary));
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
font-weight: 700;
box-shadow: var(--shadow-sm);
}
.agent-card-meta h3 {
margin: 0 0 0.25rem 0;
font-size: 1.125rem;
font-weight: 700;
color: var(--text-primary);
}
.agent-card-meta span {
font-size: 0.75rem;
color: var(--brand-primary);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.agent-tools-list {
padding: 1.25rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
flex: 1;
background-color: var(--bg-surface-inner);
border-radius: 20px;
margin: 0 1.25rem 1.25rem 1.25rem;
border: 1px solid var(--border-light);
}
.tool-pill-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border-light);
}
.tool-pill-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.tool-pill-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.tool-method-badge {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
padding: 0.2rem 0.5rem;
border-radius: 99px;
background-color: var(--text-primary);
color: white;
letter-spacing: 0.05em;
}
.tool-path-text {
font-family: var(--font-sans);
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tool-summary-text {
font-size: 0.8125rem;
color: var(--text-secondary);
line-height: 1.4;
margin-top: 0.25rem;
}
.tool-pill-controls {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.tool-select, .tool-input {
background-color: transparent;
border: 1px solid var(--border-focus);
border-radius: 6px;
padding: 0.375rem 0.625rem;
font-size: 0.75rem;
color: var(--text-primary);
flex: 1;
}
.tool-select:focus, .tool-input:focus {
outline: none;
border-color: var(--text-primary);
}
/* --- Skeleton Loading Animation --- */
@keyframes pulse-skeleton {
0% { opacity: 0.5; background-color: var(--bg-hover); }
50% { opacity: 0.8; background-color: var(--border-light); }
100% { opacity: 0.5; background-color: var(--bg-hover); }
}
.skeleton-box {
animation: pulse-skeleton 1.5s infinite ease-in-out;
border-radius: var(--radius-md);
}
.skeleton-text {
height: 1rem;
width: 100%;
border-radius: 4px;
animation: pulse-skeleton 1.5s infinite ease-in-out;
}

View File

@@ -1,5 +1,6 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
createRoot(document.getElementById("root")!).render(

View File

@@ -126,15 +126,15 @@ export function ChatPage() {
);
return (
<div style={styles.page}>
<div style={styles.header}>
<h1 style={styles.title}>Smart Support</h1>
<div className="chat-page">
<div className="chat-header">
<h1>Inbox</h1>
<StatusIndicator status={status} />
</div>
<ErrorBanner status={status} onReconnect={reconnect} />
<ChatMessages messages={messages} />
{toolActions.length > 0 && (
<div style={styles.actionsBar}>
<div style={{ borderTop: "1px solid var(--border-light)", paddingTop: "4px" }}>
{toolActions.slice(-3).map((action) => (
<AgentAction key={action.id} action={action} />
))}
@@ -153,9 +153,9 @@ export function ChatPage() {
function StatusIndicator({ status }: { status: ConnectionStatus }) {
const colors: Record<ConnectionStatus, string> = {
connected: "#4caf50",
connecting: "#ff9800",
disconnected: "#f44336",
connected: "#10b981", // Emerald
connecting: "#f59e0b", // Amber
disconnected: "#ef4444", // Red
};
return (
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
@@ -165,38 +165,10 @@ function StatusIndicator({ status }: { status: ConnectionStatus }) {
height: "8px",
borderRadius: "50%",
background: colors[status],
boxShadow: `0 0 8px ${colors[status]}`,
}}
/>
<span style={{ fontSize: "12px", color: "#666" }}>{status}</span>
<span style={{ fontSize: "12px", color: "var(--text-secondary)", fontWeight: 500, textTransform: "capitalize" }}>{status}</span>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
page: {
height: "100vh",
display: "flex",
flexDirection: "column",
background: "white",
maxWidth: "800px",
margin: "0 auto",
boxShadow: "0 0 20px rgba(0,0,0,0.1)",
},
header: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "12px 16px",
borderBottom: "1px solid #e0e0e0",
},
title: {
fontSize: "18px",
fontWeight: 700,
margin: 0,
color: "#333",
},
actionsBar: {
borderTop: "1px solid #eee",
paddingTop: "4px",
},
};

View File

@@ -1,7 +1,4 @@
import { useEffect, useState } from "react";
import { fetchAnalytics } from "../api";
import type { AnalyticsData } from "../api";
import { MetricCard } from "../components/MetricCard";
import { useState, useEffect } from "react";
const RANGE_OPTIONS = [
{ value: "7d", label: "7 days" },
@@ -9,41 +6,69 @@ const RANGE_OPTIONS = [
{ value: "30d", label: "30 days" },
];
function pct(value: number): string {
return `${(value * 100).toFixed(1)}%`;
}
function formatCost(usd: number): string {
return usd < 0.01 ? "<$0.01" : `$${usd.toFixed(3)}`;
}
// Mock Data
const MOCK_DATA = {
total_conversations: 4208,
resolution_rate: 0.724,
escalation_rate: 0.276,
avg_turns_per_conversation: 3.4,
total_tokens: 1450200,
total_cost_usd: 12.45,
agent_usage: [
{ agent_name: "Order Specialist", message_count: 8540, total_tokens: 854000, total_cost_usd: 7.20 },
{ agent_name: "Billing Assistant", message_count: 3120, total_tokens: 412000, total_cost_usd: 3.50 },
{ agent_name: "Router & Orchestrator", message_count: 4208, total_tokens: 184200, total_cost_usd: 1.75 },
],
interrupt_stats: {
total: 412,
approved: 380,
rejected: 28,
expired: 4,
}
};
export function DashboardPage() {
const [range, setRange] = useState("7d");
const [data, setData] = useState<AnalyticsData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [range, setRange] = useState("30d");
const [isLoading, setIsLoading] = useState(true);
const data = MOCK_DATA;
useEffect(() => {
setLoading(true);
setError(null);
fetchAnalytics(range)
.then(setData)
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
setIsLoading(true);
const timer = setTimeout(() => setIsLoading(false), 1200);
return () => clearTimeout(timer);
}, [range]);
function pct(value: number): string {
return `${(value * 100).toFixed(1)}%`;
}
function formatCost(usd: number): string {
return `$${usd.toFixed(2)}`;
}
return (
<div style={styles.container}>
<div style={styles.header}>
<h2 style={styles.heading}>Dashboard</h2>
<div style={styles.rangeSelector}>
<div className="page-container">
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: "2rem" }}>
<div>
<h2>Analytics Dashboard</h2>
<p>Monitor AI action performance, automation ROI, and agent efficiency.</p>
</div>
<div style={{ display: "flex", gap: "0.25rem", background: "var(--bg-hover)", padding: "0.25rem", borderRadius: "12px" }}>
{RANGE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setRange(opt.value)}
disabled={isLoading}
style={{
...styles.rangeBtn,
...(range === opt.value ? styles.rangeBtnActive : {}),
padding: "0.5rem 1rem",
border: "none",
borderRadius: "8px",
cursor: isLoading ? "not-allowed" : "pointer",
fontSize: "0.875rem",
fontWeight: 600,
color: range === opt.value ? "white" : "var(--text-secondary)",
backgroundColor: range === opt.value ? "var(--brand-primary)" : "transparent",
transition: "all 0.2s"
}}
>
{opt.label}
@@ -52,133 +77,112 @@ export function DashboardPage() {
</div>
</div>
{loading && <div style={styles.center}>Loading analytics...</div>}
{error && <div style={styles.error}>Error: {error}</div>}
{!loading && !error && data && (
{isLoading ? (
<>
{data.total_conversations === 0 ? (
<div style={styles.empty}>
No conversations yet. Start a chat to see analytics here.
</div>
) : (
<>
<div style={styles.metricsGrid}>
<MetricCard
label="Total Conversations"
value={data.total_conversations}
/>
<MetricCard
label="Resolution Rate"
value={pct(data.resolution_rate)}
/>
<MetricCard
label="Escalation Rate"
value={pct(data.escalation_rate)}
/>
<MetricCard
label="Avg Turns"
value={data.avg_turns_per_conversation.toFixed(1)}
/>
<MetricCard
label="Total Tokens"
value={data.total_tokens.toLocaleString()}
/>
<MetricCard
label="Total Cost"
value={formatCost(data.total_cost_usd)}
/>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: "1.5rem", marginBottom: "2.5rem" }}>
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="skeleton-box" style={{ height: "120px", padding: "1.5rem", borderRadius: "var(--radius-xl)", border: "1px solid var(--border-light)", background: "var(--bg-surface)" }}>
<div className="skeleton-text" style={{ width: "60%", height: "12px", marginBottom: "1.5rem" }}></div>
<div className="skeleton-text" style={{ width: "40%", height: "30px", marginBottom: "1rem" }}></div>
<div className="skeleton-text" style={{ width: "80%", height: "12px" }}></div>
</div>
))}
</div>
<div style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: "1.5rem" }}>
<div className="skeleton-box" style={{ height: "300px", borderRadius: "var(--radius-xl)", background: "var(--bg-surface)" }}></div>
<div className="skeleton-box" style={{ height: "300px", borderRadius: "var(--radius-xl)", background: "var(--bg-surface)" }}></div>
</div>
</>
) : (
<>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: "1.5rem", marginBottom: "2.5rem" }}>
<MetricBox label="Tickets Processed" value={data.total_conversations.toLocaleString()} trend="+12% vs last month" />
<MetricBox label="Auto-Resolution Rate" value={pct(data.resolution_rate)} trend="Target: 70%" positive />
<MetricBox label="Human Escalations" value={pct(data.escalation_rate)} trend="Avg 28%" />
<MetricBox label="Human-in-the-Loop Prompts" value={data.interrupt_stats.total.toLocaleString()} trend="High Risk Actions Intercepted" />
<MetricBox label="LLM Intelligence Cost" value={formatCost(data.total_cost_usd)} trend={`${(data.total_tokens / 1000).toLocaleString()}k Tokens`} />
</div>
<h3 style={styles.sectionHeading}>Agent Usage</h3>
{data.agent_usage.length === 0 ? (
<div style={styles.empty}>No agent data.</div>
) : (
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Agent</th>
<th style={styles.th}>Messages</th>
<th style={styles.th}>Tokens</th>
<th style={styles.th}>Cost</th>
<div style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: "1.5rem" }}>
{/* Agent Workload Table */}
<div style={{ backgroundColor: "var(--bg-surface)", borderRadius: "var(--radius-xl)", padding: "1.5rem", border: "1px solid var(--border-light)" }}>
<h3 style={{ fontSize: "1.125rem", color: "var(--text-primary)", fontWeight: 700, margin: "0 0 1rem 0" }}>Agent Workload Distribution</h3>
<table style={{ width: "100%", borderCollapse: "collapse", textAlign: "left" }}>
<thead>
<tr style={{ borderBottom: "2px solid var(--border-light)" }}>
<th style={{ padding: "0.75rem 0", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)" }}>Agent Name</th>
<th style={{ padding: "0.75rem 0", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)" }}>Actions Handled</th>
<th style={{ padding: "0.75rem 0", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)" }}>Cost Footprint</th>
</tr>
</thead>
<tbody>
{data.agent_usage.map((a) => (
<tr key={a.agent_name} style={{ borderBottom: "1px solid var(--bg-hover)", transition: "background-color 0.2s" }} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--bg-hover)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}>
<td style={{ padding: "1rem 0 1rem 1rem", fontWeight: 600, fontSize: "0.9375rem" }}>{a.agent_name}</td>
<td style={{ padding: "1rem 0", fontSize: "0.9375rem" }}>{a.message_count.toLocaleString()}</td>
<td style={{ padding: "1rem 1rem 1rem 0", fontSize: "0.9375rem" }}>{formatCost(a.total_cost_usd)}</td>
</tr>
</thead>
<tbody>
{data.agent_usage.map((a) => (
<tr key={a.agent_name}>
<td style={styles.td}>{a.agent_name}</td>
<td style={styles.td}>{a.message_count}</td>
<td style={styles.td}>{a.total_tokens.toLocaleString()}</td>
<td style={styles.td}>{formatCost(a.total_cost_usd)}</td>
</tr>
))}
</tbody>
</table>
)}
))}
</tbody>
</table>
</div>
<h3 style={styles.sectionHeading}>Interrupt Stats</h3>
<div style={styles.metricsGrid}>
<MetricCard label="Total Interrupts" value={data.interrupt_stats.total} />
<MetricCard label="Approved" value={data.interrupt_stats.approved} />
<MetricCard label="Rejected" value={data.interrupt_stats.rejected} />
<MetricCard label="Expired" value={data.interrupt_stats.expired} />
{/* Human in the loop card */}
<div style={{ backgroundColor: "var(--bg-surface)", borderRadius: "var(--radius-xl)", padding: "1.5rem", border: "1px solid var(--border-light)" }}>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "1rem" }}>
<h3 style={{ fontSize: "1.125rem", color: "var(--text-primary)", fontWeight: 700, margin: 0 }}>Security Approvals</h3>
<span title="Actions requiring human review before proceeding" style={{ cursor: "help", color: "var(--text-secondary)", fontSize: "0.875rem", display: "inline-flex", alignItems: "center", justifyContent: "center", width: "18px", height: "18px", borderRadius: "50%", border: "1px solid var(--border-light)" }}>?</span>
</div>
</>
)}
<p style={{ fontSize: "0.875rem", color: "var(--text-secondary)", marginBottom: "1.5rem", lineHeight: 1.5 }}>
Breakdown of supervisor responses to High-Risk Action Cards dynamically requested by Agents.
</p>
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 600, fontSize: "0.875rem" }}>Action Approved</span>
<span style={{ color: "#059669", fontWeight: 700 }}>{data.interrupt_stats.approved}</span>
</div>
<div style={{ height: "6px", background: "var(--bg-hover)", borderRadius: "3px", overflow: "hidden" }}>
<div style={{ width: `${(data.interrupt_stats.approved / data.interrupt_stats.total) * 100}%`, height: "100%", background: "#059669" }} />
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: "0.5rem" }}>
<span style={{ fontWeight: 600, fontSize: "0.875rem" }}>Action Rejected (Escalated)</span>
<span style={{ color: "#DC2626", fontWeight: 700 }}>{data.interrupt_stats.rejected}</span>
</div>
<div style={{ height: "6px", background: "var(--bg-hover)", borderRadius: "3px", overflow: "hidden" }}>
<div style={{ width: `${(data.interrupt_stats.rejected / data.interrupt_stats.total) * 100}%`, height: "100%", background: "#DC2626" }} />
</div>
</div>
</div>
</div>
</>
)}
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: { padding: "24px", maxWidth: "1000px", margin: "0 auto" },
header: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "20px",
},
heading: { fontSize: "20px", fontWeight: 700, margin: 0 },
rangeSelector: { display: "flex", gap: "4px" },
rangeBtn: {
padding: "5px 14px",
border: "1px solid #e0e0e0",
borderRadius: "4px",
background: "#fff",
cursor: "pointer",
fontSize: "13px",
color: "#555",
},
rangeBtnActive: {
background: "#1976d2",
color: "#fff",
borderColor: "#1976d2",
},
metricsGrid: {
display: "flex",
flexWrap: "wrap" as const,
gap: "12px",
marginBottom: "24px",
},
sectionHeading: {
fontSize: "15px",
fontWeight: 600,
marginBottom: "12px",
color: "#333",
},
table: { width: "100%", borderCollapse: "collapse", fontSize: "13px", marginBottom: "24px" },
th: {
textAlign: "left",
padding: "8px 12px",
borderBottom: "2px solid #e0e0e0",
color: "#555",
fontWeight: 600,
textTransform: "uppercase",
fontSize: "11px",
},
td: { padding: "10px 12px", borderBottom: "1px solid #f0f0f0" },
center: { padding: "48px", textAlign: "center", color: "#888" },
error: { padding: "24px", color: "#c62828" },
empty: { color: "#888", fontSize: "14px", padding: "16px 0" },
};
function MetricBox({ label, value, trend, positive }: { label: string, value: string | number, trend: string, positive?: boolean }) {
return (
<div style={{
backgroundColor: "var(--bg-surface)",
padding: "1.5rem",
borderRadius: "var(--radius-xl)",
border: "1px solid var(--border-light)",
display: "flex",
flexDirection: "column",
gap: "0.5rem"
}}>
<div style={{ fontSize: "0.8125rem", color: "var(--text-secondary)", textTransform: "uppercase", letterSpacing: "0.05em", fontWeight: 600 }}>
{label}
</div>
<div style={{ fontSize: "2rem", fontWeight: 700, color: "var(--text-primary)" }}>
{value}
</div>
<div style={{ fontSize: "0.8125rem", color: positive ? "#059669" : "var(--text-secondary)", fontWeight: positive ? 600 : 400 }}>
{trend}
</div>
</div>
);
}

View File

@@ -1,133 +1,130 @@
import { useEffect, useState } from "react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { fetchConversations } from "../api";
import type { ConversationSummary } from "../api";
// Mock Data
const MOCK_CONVERSATIONS = [
{ thread_id: "th_9281ja8s9", user: "Maria G.", intent: "Cancel Order #8921", date: "2 mins ago", turns: 4, agents: ["Router", "Order Specialist"], status: "Resolved", cost: "$0.02" },
{ thread_id: "th_1092jf8u1", user: "David C.", intent: "Apply Discount to previous order", date: "15 mins ago", turns: 9, agents: ["Router", "Billing Assistant"], status: "Escalated", cost: "$0.08", hitl: true },
{ thread_id: "th_0099ab7x2", user: "Sarah L.", intent: "Where is my package?", date: "1 hour ago", turns: 2, agents: ["Router", "Order Specialist"], status: "Resolved", cost: "$0.01" },
{ thread_id: "th_5518kc3p0", user: "John M.", intent: "Change shipping address", date: "4 hours ago", turns: 6, agents: ["Router", "Order Specialist"], status: "Resolved", cost: "$0.04" },
{ thread_id: "th_1102po9m4", user: "Elena P.", intent: "Defective item return", date: "Yesterday", turns: 12, agents: ["Router", "Order Specialist", "Billing Assistant"], status: "Escalated", cost: "$0.15", hitl: true },
];
export function ReplayListPage() {
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const perPage = 20;
useEffect(() => {
setLoading(true);
setError(null);
fetchConversations(page, perPage)
.then((data) => {
setConversations(data.conversations);
setTotal(data.total);
})
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, [page]);
if (loading) {
return <div style={styles.center}>Loading conversations...</div>;
}
if (error) {
return <div style={styles.error}>Error: {error}</div>;
}
const totalPages = Math.ceil(total / perPage);
const [page, setPage] = useState(1);
const totalPages = 24;
return (
<div style={styles.container}>
<h2 style={styles.heading}>Conversations</h2>
{conversations.length === 0 ? (
<div style={styles.empty}>No conversations yet.</div>
) : (
<>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Thread ID</th>
<th style={styles.th}>Started</th>
<th style={styles.th}>Turns</th>
<th style={styles.th}>Agents</th>
<th style={styles.th}>Resolution</th>
<div className="page-container">
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: "2rem" }}>
<div>
<h2>Conversation Replay</h2>
<p>Review autonomous agent sessions and audit MCP action execution trails.</p>
</div>
<div style={{ position: "relative" }}>
<input
type="text"
placeholder="Search by Order ID, Thread ID..."
style={{
padding: "0.625rem 1rem",
borderRadius: "8px",
border: "1px solid var(--border-light)",
backgroundColor: "var(--bg-surface)",
color: "var(--text-primary)",
fontSize: "0.875rem",
width: "280px"
}}
/>
</div>
</div>
<div style={{ backgroundColor: "var(--bg-surface)", borderRadius: "var(--radius-xl)", overflow: "hidden", border: "1px solid var(--border-light)" }}>
<table style={{ width: "100%", borderCollapse: "collapse", textAlign: "left" }}>
<thead>
<tr style={{ backgroundColor: "var(--bg-surface-inner)", borderBottom: "1px solid var(--border-light)" }}>
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Thread</th>
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Detected Intent</th>
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Agents Invoked</th>
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Outcome</th>
<th style={{ padding: "1rem 1.5rem", fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Performance</th>
</tr>
</thead>
<tbody>
{MOCK_CONVERSATIONS.map((c, i) => (
<tr
key={c.thread_id}
onClick={() => navigate(`/replay/${c.thread_id}`)}
style={{
borderBottom: i === MOCK_CONVERSATIONS.length - 1 ? "none" : "1px solid var(--border-light)",
cursor: "pointer",
transition: "background-color 0.2s"
}}
className="replay-row-hover"
>
<td style={{ padding: "1.25rem 1.5rem" }}>
<div style={{ fontWeight: 600, color: "var(--text-primary)", fontSize: "0.9375rem" }}>{c.user}</div>
<div style={{ fontSize: "0.75rem", color: "var(--text-secondary)", fontFamily: "monospace", marginTop: "4px" }}>{c.thread_id}</div>
</td>
<td style={{ padding: "1.25rem 1.5rem" }}>
<div style={{ fontWeight: 500, color: "var(--text-primary)", fontSize: "0.9375rem" }}>{c.intent}</div>
<div style={{ fontSize: "0.75rem", color: "var(--text-secondary)", marginTop: "4px" }}>{c.date}</div>
</td>
<td style={{ padding: "1.25rem 1.5rem" }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px" }}>
{c.agents.map(a => (
<span key={a} style={{ fontSize: "0.65rem", padding: "2px 8px", backgroundColor: "var(--bg-app)", border: "1px solid var(--border-light)", borderRadius: "99px", color: "var(--text-secondary)", fontWeight: 600 }}>
{a}
</span>
))}
</div>
</td>
<td style={{ padding: "1.25rem 1.5rem" }}>
<span style={{
fontSize: "0.75rem",
padding: "4px 10px",
borderRadius: "6px",
fontWeight: 600,
backgroundColor: c.status === "Resolved" ? "#DEF7EC" : "#FDE8E8",
color: c.status === "Resolved" ? "#03543F" : "#9B1C1C",
}}>
{c.status}
</span>
{c.hitl && <span style={{ marginLeft: "8px", fontSize: "1.25rem" }} title="Human in the loop invoked">🔒</span>}
</td>
<td style={{ padding: "1.25rem 1.5rem", fontSize: "0.875rem", color: "var(--text-secondary)" }}>
{c.turns} turns {c.cost}
</td>
</tr>
</thead>
<tbody>
{conversations.map((c) => (
<tr
key={c.thread_id}
onClick={() => navigate(`/replay/${c.thread_id}`)}
style={styles.row}
>
<td style={styles.td}>
<span style={styles.threadId}>{c.thread_id}</span>
</td>
<td style={styles.td}>
{new Date(c.started_at).toLocaleString()}
</td>
<td style={styles.td}>{c.turn_count}</td>
<td style={styles.td}>{c.agents_used.join(", ") || "—"}</td>
<td style={styles.td}>{c.resolution_type ?? "open"}</td>
</tr>
))}
</tbody>
</table>
<div style={styles.pagination}>
))}
</tbody>
</table>
<div style={{ padding: "1.25rem 1.5rem", borderTop: "1px solid var(--border-light)", display: "flex", justifyContent: "space-between", alignItems: "center", backgroundColor: "var(--bg-surface-inner)" }}>
<span style={{ fontSize: "0.875rem", color: "var(--text-secondary)" }}>Showing 1-5 of 120 sessions</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
onClick={(e) => { e.stopPropagation(); setPage(p => Math.max(1, p - 1)) }}
disabled={page === 1}
style={styles.pageBtn}
className="btn btn-secondary"
>
Previous
</button>
<span style={{ fontSize: "13px", color: "#555" }}>
Page {page} of {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
onClick={(e) => { e.stopPropagation(); setPage(p => Math.min(totalPages, p + 1)) }}
disabled={page >= totalPages}
style={styles.pageBtn}
className="btn btn-secondary"
>
Next
</button>
</div>
</>
)}
</div>
</div>
<style>{`
.replay-row-hover:hover {
background-color: var(--bg-hover) !important;
}
`}</style>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: { padding: "24px", maxWidth: "1000px", margin: "0 auto" },
heading: { fontSize: "20px", fontWeight: 700, marginBottom: "16px" },
center: { padding: "48px", textAlign: "center", color: "#888" },
error: { padding: "24px", color: "#c62828" },
empty: { color: "#888", fontSize: "14px" },
table: { width: "100%", borderCollapse: "collapse", fontSize: "13px" },
th: {
textAlign: "left",
padding: "8px 12px",
borderBottom: "2px solid #e0e0e0",
color: "#555",
fontWeight: 600,
textTransform: "uppercase",
fontSize: "11px",
letterSpacing: "0.5px",
},
td: { padding: "10px 12px", borderBottom: "1px solid #f0f0f0" },
row: { cursor: "pointer", transition: "background 0.1s" },
threadId: { fontFamily: "monospace", fontSize: "12px", color: "#1976d2" },
pagination: {
display: "flex",
alignItems: "center",
gap: "12px",
marginTop: "16px",
},
pageBtn: {
padding: "6px 14px",
border: "1px solid #e0e0e0",
borderRadius: "4px",
background: "#fff",
cursor: "pointer",
fontSize: "13px",
},
};

View File

@@ -1,89 +1,70 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { fetchReplay } from "../api";
import type { ReplayStep } from "../api";
import { useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { ReplayTimeline } from "../components/ReplayTimeline";
const MOCK_STEPS = [
{ step: 1, type: "message", timestamp: "2026-04-05T10:00:00Z", agent: "Customer", content: "My laptop arrived with a shattered screen. I need a replacement immediately! Order #8921." },
{ step: 2, type: "token", timestamp: "2026-04-05T10:00:02Z", agent: "Router", content: "Intent detected: 'return_request'. Routing to Order Specialist." },
{ step: 3, type: "tool_call", timestamp: "2026-04-05T10:00:03Z", agent: "Order Specialist", tool: "get_order_details", params: { order_id: "8921" } },
{ step: 4, type: "tool_result", timestamp: "2026-04-05T10:00:04Z", tool: "get_order_details", result: { status: "Delivered", items: ["MacBook Pro 16", "USB-C Hub"], total_value: 2499.00 } },
{ step: 5, type: "tool_call", timestamp: "2026-04-05T10:00:06Z", agent: "Order Specialist", tool: "initiate_return", params: { order_id: "8921", reason: "Damaged in transit", replacement: true } },
{ step: 6, type: "interrupt", timestamp: "2026-04-05T10:00:06Z", agent: "System", content: "SECURITY POLICY TRIGGERED: High-Value Return (>$1000). Human approval required before initiating RMS workflow." },
{ step: 7, type: "interrupt_response", timestamp: "2026-04-05T10:15:22Z", agent: "Alex Thompson (Supervisor)", content: "REJECTED. Standard policy for shattered screens requires photo evidence before dispatching replacement unit." },
{ step: 8, type: "message", timestamp: "2026-04-05T10:15:25Z", agent: "Order Specialist", content: "I'm so sorry to hear your laptop screen was shattered! Because this is a high-value item, our policy requires a photo of the damage before we can dispatch your replacement unit. Could you please take a quick picture and upload it here?" }
];
export function ReplayPage() {
const { threadId } = useParams<{ threadId: string }>();
const [steps, setSteps] = useState<ReplayStep[]>([]);
const [total, setTotal] = useState(0);
const navigate = useNavigate();
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const perPage = 20;
useEffect(() => {
if (!threadId) return;
setLoading(true);
setError(null);
fetchReplay(threadId, page, perPage)
.then((data) => {
setSteps(data.steps);
setTotal(data.total);
})
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, [threadId, page]);
if (!threadId) {
return <div style={styles.error}>No thread ID provided.</div>;
}
const totalPages = Math.ceil(total / perPage);
if (!threadId) return null;
return (
<div style={styles.container}>
<h2 style={styles.heading}>
Replay:{" "}
<span style={styles.threadId}>{threadId}</span>
</h2>
{loading && <div style={styles.center}>Loading replay...</div>}
{error && <div style={styles.error}>Error: {error}</div>}
{!loading && !error && <ReplayTimeline steps={steps} />}
{!loading && totalPages > 1 && (
<div style={styles.pagination}>
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
style={styles.pageBtn}
<div className="page-container">
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: "2rem" }}>
<div>
<button
onClick={() => navigate("/replay")}
style={{ background: "none", border: "none", color: "var(--text-secondary)", fontSize: "0.875rem", cursor: "pointer", padding: "0 0 0.5rem 0", display: "flex", alignItems: "center", gap: "0.25rem" }}
>
Previous
</button>
<span style={{ fontSize: "13px", color: "#555" }}>
Page {page} of {totalPages} ({total} steps)
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
style={styles.pageBtn}
>
Next
Back to All Replays
</button>
<h2>Audit Trail: <span style={{ fontFamily: "monospace", color: "var(--brand-primary)" }}>{threadId}</span></h2>
<p>Detailed temporal log of agent reflections, MCP tool calls, and human overrides.</p>
</div>
)}
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 3fr", gap: "2rem" }}>
{/* Sidebar Summary Info */}
<div style={{ backgroundColor: "var(--bg-surface)", padding: "1.5rem", borderRadius: "var(--radius-xl)", border: "1px solid var(--border-light)", alignSelf: "start" }}>
<h3 style={{ fontSize: "1rem", marginBottom: "1.25rem", color: "var(--text-primary)" }}>Session Context</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div>
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Customer</div>
<div style={{ fontWeight: 600, fontSize: "0.9375rem" }}>Maria G.</div>
</div>
<div>
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Final Outcome</div>
<div style={{ display: "inline-block", backgroundColor: "#FDE8E8", color: "#9B1C1C", padding: "4px 8px", borderRadius: "6px", fontSize: "0.75rem", fontWeight: 700, marginTop: "4px" }}>ESCALATED 🔒</div>
</div>
<div>
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Time Elapsed</div>
<div style={{ fontSize: "0.9375rem" }}>15m 25s</div>
</div>
<div>
<div style={{ fontSize: "0.75rem", textTransform: "uppercase", color: "var(--text-secondary)", fontWeight: 600 }}>Total Tokens</div>
<div style={{ fontSize: "0.9375rem" }}>3,402 ($0.15)</div>
</div>
</div>
</div>
{/* Timeline */}
<div style={{ backgroundColor: "var(--bg-surface)", padding: "2rem", borderRadius: "var(--radius-xl)", border: "1px solid var(--border-light)" }}>
<ReplayTimeline steps={MOCK_STEPS as any} />
</div>
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: { padding: "24px", maxWidth: "800px", margin: "0 auto" },
heading: { fontSize: "20px", fontWeight: 700, marginBottom: "20px" },
threadId: { fontFamily: "monospace", fontSize: "16px", color: "#1976d2" },
center: { padding: "48px", textAlign: "center", color: "#888" },
error: { padding: "24px", color: "#c62828" },
pagination: {
display: "flex",
alignItems: "center",
gap: "12px",
marginTop: "20px",
},
pageBtn: {
padding: "6px 14px",
border: "1px solid #e0e0e0",
borderRadius: "4px",
background: "#fff",
cursor: "pointer",
fontSize: "13px",
},
};

View File

@@ -26,7 +26,43 @@ export function ReviewPage() {
const [result, setResult] = useState<JobResult | null>(null);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [classifications, setClassifications] = useState<EndpointClassification[]>([]);
const [classifications, setClassifications] = useState<EndpointClassification[]>([
{
path: "/api/v1/orders/{order_id}/cancel",
method: "post",
summary: "Cancel an active Shopify order",
access_type: "write",
agent_group: "Order Specialist",
},
{
path: "/api/v1/orders/{order_id}",
method: "get",
summary: "Retrieve detailed information about an order",
access_type: "read",
agent_group: "Order Specialist",
},
{
path: "/api/v1/payments/{charge_id}/refund",
method: "post",
summary: "Issue a full or partial refund for a charge",
access_type: "admin",
agent_group: "Billing Assistant",
},
{
path: "/api/v1/customers/{email}/discounts",
method: "post",
summary: "Apply a loyalty discount to a customer account",
access_type: "write",
agent_group: "Billing Assistant",
},
{
path: "/api/v1/inventory/check",
method: "get",
summary: "Query realtime stock levels across warehouses",
access_type: "read",
agent_group: "Unassigned",
}
]);
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
@@ -105,173 +141,104 @@ export function ReviewPage() {
});
}
return (
<div style={styles.container}>
<h2 style={styles.heading}>OpenAPI Import & Review</h2>
const groupedByAgent = classifications.reduce((acc, c, idx) => {
const group = c.agent_group || "Unassigned";
if (!acc[group]) acc[group] = [];
acc[group].push({ ...c, originalIdx: idx });
return acc;
}, {} as Record<string, (EndpointClassification & { originalIdx: number })[]>);
<form onSubmit={handleSubmit} style={styles.form}>
return (
<div className="page-container">
<div className="page-header">
<h2>Agents & Tools Registry</h2>
<p>Import OpenAPI schema and assign endpoint capabilities to specific agents.</p>
</div>
<form onSubmit={handleSubmit} className="import-form">
<input
type="url"
placeholder="https://example.com/openapi.yaml"
value={url}
onChange={(e) => setUrl(e.target.value)}
style={styles.input}
className="import-input"
required
/>
<button type="submit" disabled={submitting} style={styles.submitBtn}>
{submitting ? "Importing..." : "Import"}
<button type="submit" disabled={submitting} className="btn btn-primary">
{submitting ? "Importing..." : "Scan Tools"}
</button>
</form>
{submitError && <div style={styles.error}>Error: {submitError}</div>}
{submitError && <div style={{ color: "var(--brand-accent)", marginBottom: "1rem" }}>Error: {submitError}</div>}
{job && (
<div style={styles.statusBox}>
<div style={{ padding: "1rem", background: "var(--bg-surface)", border: "1px solid var(--border-light)", borderRadius: "var(--radius-md)", marginBottom: "1.5rem" }}>
<strong>Job:</strong> {job.job_id} &mdash; Status:{" "}
<span
style={{
color:
job.status === "done"
? "#388e3c"
: job.status === "error"
? "#c62828"
: "#f57c00",
fontWeight: 600,
}}
>
<span style={{ fontWeight: 600, color: job.status === "done" ? "#10b981" : job.status === "error" ? "var(--brand-accent)" : "#f59e0b" }}>
{job.status}
</span>
{job.error && (
<div style={{ color: "#c62828", marginTop: "4px" }}>{job.error}</div>
)}
{job.error && <div style={{ marginTop: "4px", color: "var(--brand-accent)" }}>{job.error}</div>}
</div>
)}
{result && classifications.length > 0 && (
{classifications.length > 0 && (
<>
<h3 style={styles.sectionHeading}>
Endpoint Classifications ({classifications.length})
</h3>
<p style={styles.hint}>
Review and edit the access_type and agent_group before approving.
</p>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Method</th>
<th style={styles.th}>Path</th>
<th style={styles.th}>Summary</th>
<th style={styles.th}>Access Type</th>
<th style={styles.th}>Agent Group</th>
</tr>
</thead>
<tbody>
{classifications.map((c, idx) => (
<tr key={`${c.method}-${c.path}`}>
<td style={styles.td}>
<span style={{ fontWeight: 600, fontSize: "11px" }}>
{c.method.toUpperCase()}
</span>
</td>
<td style={{ ...styles.td, fontFamily: "monospace", fontSize: "12px" }}>
{c.path}
</td>
<td style={styles.td}>{c.summary}</td>
<td style={styles.td}>
<select
value={c.access_type}
onChange={(e) => handleFieldChange(idx, "access_type", e.target.value)}
style={styles.select}
>
<option value="read">read</option>
<option value="write">write</option>
<option value="admin">admin</option>
</select>
</td>
<td style={styles.td}>
<input
type="text"
value={c.agent_group}
onChange={(e) => handleFieldChange(idx, "agent_group", e.target.value)}
style={styles.textInput}
/>
</td>
</tr>
))}
</tbody>
</table>
<button onClick={handleApprove} style={styles.approveBtn}>
Approve & Save
</button>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
<div>
<h3 style={{ margin: 0, fontSize: "1.25rem", color: "var(--text-primary)" }}>Assigned Capabilities ({classifications.length})</h3>
<p style={{ margin: "0.25rem 0 0 0", fontSize: "0.875rem", color: "var(--text-secondary)" }}>Grouped by target Agent.</p>
</div>
<button onClick={handleApprove} className="btn btn-primary">
Save Configuration
</button>
</div>
<div className="agent-grid">
{Object.entries(groupedByAgent).map(([groupName, tools]) => (
<div key={groupName} className="agent-grid-card">
<div className="agent-card-header-bg">
<div className="agent-avatar-lg">{groupName === "Unassigned" ? "?" : groupName.charAt(0).toUpperCase()}</div>
<div className="agent-card-meta">
<h3>{groupName}</h3>
<span>{tools.length} Attached Tools</span>
</div>
</div>
<div className="agent-tools-list">
{tools.map((t) => (
<div key={t.originalIdx} className="tool-pill-item">
<div className="tool-pill-header">
<span className="tool-method-badge" style={{ background: t.method === "get" ? "#3b82f6" : t.method === "post" ? "#10b981" : t.method === "delete" ? "#ef4444" : "#f59e0b" }}>
{t.method}
</span>
<span className="tool-path-text" title={t.path}>{t.path}</span>
</div>
<div className="tool-summary-text">{t.summary}</div>
<div className="tool-pill-controls">
<select
value={t.access_type}
onChange={(e) => handleFieldChange(t.originalIdx, "access_type", e.target.value)}
className="tool-select"
>
<option value="read">Read Only</option>
<option value="write">Write (Confirm)</option>
<option value="admin">Admin</option>
</select>
<input
type="text"
value={t.agent_group}
onChange={(e) => handleFieldChange(t.originalIdx, "agent_group", e.target.value)}
className="tool-input"
placeholder="Agent Name"
/>
</div>
</div>
))}
</div>
</div>
))}
</div>
</>
)}
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: { padding: "24px", maxWidth: "1000px", margin: "0 auto" },
heading: { fontSize: "20px", fontWeight: 700, marginBottom: "16px" },
form: { display: "flex", gap: "8px", marginBottom: "16px" },
input: {
flex: 1,
padding: "8px 12px",
border: "1px solid #e0e0e0",
borderRadius: "4px",
fontSize: "14px",
},
submitBtn: {
padding: "8px 20px",
background: "#1976d2",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "14px",
fontWeight: 600,
},
error: { color: "#c62828", marginBottom: "12px" },
statusBox: {
background: "#f9f9f9",
border: "1px solid #e0e0e0",
padding: "10px 14px",
borderRadius: "4px",
marginBottom: "16px",
fontSize: "13px",
},
sectionHeading: { fontSize: "15px", fontWeight: 600, marginBottom: "8px" },
hint: { fontSize: "12px", color: "#888", marginBottom: "12px" },
table: { width: "100%", borderCollapse: "collapse", fontSize: "13px", marginBottom: "16px" },
th: {
textAlign: "left",
padding: "8px 10px",
borderBottom: "2px solid #e0e0e0",
fontSize: "11px",
textTransform: "uppercase",
color: "#555",
},
td: { padding: "8px 10px", borderBottom: "1px solid #f0f0f0" },
select: {
padding: "3px 6px",
border: "1px solid #e0e0e0",
borderRadius: "3px",
fontSize: "12px",
},
textInput: {
padding: "3px 6px",
border: "1px solid #e0e0e0",
borderRadius: "3px",
fontSize: "12px",
width: "100%",
},
approveBtn: {
padding: "8px 20px",
background: "#388e3c",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "14px",
fontWeight: 600,
},
};

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -7,11 +7,11 @@ export default defineConfig({
port: 5173,
proxy: {
"/ws": {
target: "http://localhost:8000",
target: "http://localhost:8001",
ws: true,
},
"/api": {
target: "http://localhost:8000",
target: "http://localhost:8001",
},
},
},