refactor: optimize ui for dashboard

This commit is contained in:
2026-05-18 14:47:08 +08:00
parent 6f68a5314b
commit 4c31a5f80e
39 changed files with 1710 additions and 605 deletions
+8
View File
@@ -4,6 +4,14 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Workflow Dashboard</title> <title>Workflow Dashboard</title>
<script>
(function () {
var t = localStorage.getItem("theme");
if (t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches)) {
document.documentElement.classList.add("dark");
}
})();
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+12 -1
View File
@@ -13,12 +13,23 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@xyflow/react": "^12.10.2", "@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.16.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router": "^7.15.1", "react-router": "^7.15.1",
"shiki": "^4.0.2" "shiki": "^4.0.2",
"tailwind-merge": "^3.6.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.2.4", "@tailwindcss/vite": "^4.2.4",
@@ -0,0 +1,226 @@
import { createFilter, type Plugin } from "vite";
type LimitLineOverride = {
files: string;
maxReactFCLines: number | null;
maxFileLines: number | null;
};
type LimitLineOptions = {
maxReactFCLines: number;
maxFileLines: number;
include: RegExp;
exclude: RegExp | null;
overrides: Array<LimitLineOverride>;
};
const DEFAULT_OPTIONS: LimitLineOptions = {
maxReactFCLines: 300,
maxFileLines: 600,
include: /\.[tj]sx$/,
exclude: null,
overrides: [],
};
type ResolvedLimits = {
maxReactFCLines: number | null;
maxFileLines: number | null;
};
type ComponentInfo = {
name: string;
startLine: number;
lineCount: number;
};
const PASCAL_CASE = /^[A-Z][A-Za-z0-9]*$/;
type AstNode = {
type: string;
start: number;
end: number;
id: { name: string } | null;
init: AstNode | null;
declaration: AstNode | null;
declarations: Array<{ id: { name: string }; init: AstNode | null }>;
callee: { name: string } | null;
arguments: Array<AstNode>;
body: Array<AstNode>;
[key: string]: unknown;
};
function lineAt(code: string, offset: number): number {
let line = 1;
for (let i = 0; i < offset && i < code.length; i++) {
if (code[i] === "\n") line++;
}
return line;
}
function lineSpan(code: string, start: number, end: number): { startLine: number; lineCount: number } {
const startLine = lineAt(code, start);
const endLine = lineAt(code, end);
return { startLine, lineCount: endLine - startLine + 1 };
}
function extractComponents(ast: AstNode, code: string): Array<ComponentInfo> {
const results: Array<ComponentInfo> = [];
for (const node of ast.body ?? []) {
if (node.type === "FunctionDeclaration" && node.id && PASCAL_CASE.test(node.id.name)) {
const span = lineSpan(code, node.start, node.end);
results.push({ name: node.id.name, ...span });
continue;
}
if (node.type === "ExportNamedDeclaration" && node.declaration) {
const decl = node.declaration;
if (decl.type === "FunctionDeclaration" && decl.id && PASCAL_CASE.test(decl.id.name)) {
const span = lineSpan(code, node.start, node.end);
results.push({ name: decl.id.name, ...span });
continue;
}
if (decl.type === "VariableDeclaration") {
collectFromVarDeclaration(decl, code, results);
continue;
}
}
if (node.type === "VariableDeclaration") {
collectFromVarDeclaration(node, code, results);
}
}
return results;
}
function collectFromVarDeclaration(node: AstNode, code: string, results: Array<ComponentInfo>): void {
for (const declarator of node.declarations ?? []) {
if (!declarator.id || !PASCAL_CASE.test(declarator.id.name) || !declarator.init) continue;
const init = declarator.init;
if (isFunctionLike(init)) {
const span = lineSpan(code, node.start, node.end);
results.push({ name: declarator.id.name, ...span });
} else if (isWrapperCall(init) && init.arguments.length > 0 && isFunctionLike(init.arguments[0])) {
const span = lineSpan(code, node.start, node.end);
results.push({ name: declarator.id.name, ...span });
}
}
}
function isFunctionLike(node: AstNode): boolean {
return node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression";
}
const WRAPPER_NAMES = new Set(["memo", "forwardRef", "lazy"]);
function isWrapperCall(node: AstNode): boolean {
return node.type === "CallExpression" && node.callee !== null && node.callee.name !== undefined && WRAPPER_NAMES.has(node.callee.name);
}
function createLimitResolver(options: LimitLineOptions): (id: string) => ResolvedLimits {
const matchers = options.overrides.map((override) => ({
match: createFilter(override.files),
maxReactFCLines: override.maxReactFCLines,
maxFileLines: override.maxFileLines,
}));
return (id: string): ResolvedLimits => {
let maxReactFCLines: number | null = options.maxReactFCLines;
let maxFileLines: number | null = options.maxFileLines;
for (const matcher of matchers) {
if (matcher.match(id)) {
maxReactFCLines = matcher.maxReactFCLines;
maxFileLines = matcher.maxFileLines;
}
}
return { maxReactFCLines, maxFileLines };
};
}
function shouldProcess(id: string, options: LimitLineOptions): boolean {
return options.include.test(id) && !id.includes("node_modules") && (options.exclude === null || !options.exclude.test(id));
}
function viteLimitLinePlugin(
userOptions: Partial<LimitLineOptions> = {},
): Array<Plugin> {
const options: LimitLineOptions = { ...DEFAULT_OPTIONS, ...userOptions, overrides: userOptions.overrides ?? [] };
const resolve = createLimitResolver(options);
return [
{
name: "vite-plugin-file-line-limit",
enforce: "pre",
transform(code, id) {
if (!shouldProcess(id, options)) return null;
const limits = resolve(id);
if (limits.maxFileLines === null) return null;
const totalLines = code.split("\n").length;
if (totalLines > limits.maxFileLines) {
this.error(
[
`[vite-limit-line] File too long: ${totalLines} lines (limit: ${limits.maxFileLines})`,
` file: ${id}`,
"",
"How to fix:",
" Split this file into smaller modules — extract related types, helpers,",
" or sub-components into separate files and re-export from an index.ts.",
].join("\n"),
);
}
return null;
},
},
{
name: "vite-plugin-react-fc-line-limit",
transform(code, id) {
if (!shouldProcess(id, options)) return null;
const limits = resolve(id);
if (limits.maxReactFCLines === null) return null;
const ast = this.parse(code) as unknown as AstNode;
const components = extractComponents(ast, code);
const violations = components.filter((c) => c.lineCount > (limits.maxReactFCLines as number));
if (violations.length > 0) {
const details = violations
.map(
(v) =>
` ${v.name} (line ${v.startLine}): ${v.lineCount} lines (limit: ${limits.maxReactFCLines})`,
)
.join("\n");
this.error(
[
`[vite-limit-line] React component too long in ${id}:`,
details,
"",
"How to fix:",
" Break each oversized component into smaller ones. Extract reusable",
" sections into child components, move complex logic into custom hooks,",
" and keep each component focused on a single responsibility.",
].join("\n"),
);
}
return null;
},
},
];
}
export { viteLimitLinePlugin };
export type { LimitLineOptions, LimitLineOverride };
+6 -4
View File
@@ -4,23 +4,27 @@ import { clearApiKey, hasApiKey } from "./api.ts";
import { RunDialog } from "./components/run-dialog.tsx"; import { RunDialog } from "./components/run-dialog.tsx";
import { Sidebar } from "./components/sidebar.tsx"; import { Sidebar } from "./components/sidebar.tsx";
import { StatusBar } from "./components/status-bar.tsx"; import { StatusBar } from "./components/status-bar.tsx";
import { useTheme } from "./hooks/use-theme.tsx";
export function Layout() { export function Layout() {
const [authed, setAuthed] = useState(hasApiKey()); const [authed, setAuthed] = useState(hasApiKey());
const { client } = useParams(); const { client } = useParams();
const [showRun, setShowRun] = useState(false); const [showRun, setShowRun] = useState(false);
const { theme, toggleTheme } = useTheme();
if (!authed) { if (!authed) {
return <Navigate to="/login" replace />; return <Navigate to="/login" replace />;
} }
return ( return (
<div className="flex h-screen"> <div className="flex h-screen bg-background">
<Sidebar <Sidebar
onLogout={() => { onLogout={() => {
clearApiKey(); clearApiKey();
setAuthed(false); setAuthed(false);
}} }}
theme={theme}
onToggleTheme={toggleTheme}
/> />
<main className="flex-1 overflow-hidden flex flex-col"> <main className="flex-1 overflow-hidden flex flex-col">
<StatusBar client={client ?? null} onRun={() => setShowRun(true)} /> <StatusBar client={client ?? null} onRun={() => setShowRun(true)} />
@@ -28,9 +32,7 @@ export function Layout() {
<Outlet /> <Outlet />
</div> </div>
</main> </main>
{showRun && client && ( {client && <RunDialog client={client} open={showRun} onOpenChange={setShowRun} />}
<RunDialog client={client} onClose={() => setShowRun(false)} />
)}
</div> </div>
); );
} }
@@ -1,3 +1,4 @@
import { Loader2, Users } from "lucide-react";
import { Navigate } from "react-router"; import { Navigate } from "react-router";
import { listClients } from "../api.ts"; import { listClients } from "../api.ts";
import { useFetch } from "../hooks.ts"; import { useFetch } from "../hooks.ts";
@@ -7,8 +8,9 @@ export function ClientRedirect() {
if (status === "loading") { if (status === "loading") {
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex flex-col items-center justify-center h-full gap-3">
<p style={{ color: "var(--color-text-muted)" }}>Loading clients...</p> <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Loading clients...</p>
</div> </div>
); );
} }
@@ -18,8 +20,10 @@ export function ClientRedirect() {
} }
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex flex-col items-center justify-center h-full gap-3">
<p style={{ color: "var(--color-text-muted)" }}> <Users className="h-12 w-12 text-muted-foreground/50" />
<p className="text-sm font-medium">No client selected</p>
<p className="text-xs text-muted-foreground">
Select a client from the sidebar to get started. Select a client from the sidebar to get started.
</p> </p>
</div> </div>
@@ -1,12 +1,18 @@
import { AlertCircle, Loader2, Moon, Settings, Sun } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { setApiKey } from "../api.ts"; import { setApiKey } from "../api.ts";
import { useTheme } from "../hooks/use-theme.tsx";
import { Button } from "./ui/button.tsx";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card.tsx";
import { Input } from "./ui/input.tsx";
export function LoginPage() { export function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [key, setKey] = useState(""); const [key, setKey] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { theme, toggleTheme } = useTheme();
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -15,7 +21,6 @@ export function LoginPage() {
setLoading(true); setLoading(true);
setError(null); setError(null);
// Test the key by hitting the endpoints list
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || ""; const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || "";
try { try {
const res = await fetch(`${gatewayUrl}/api/gateway/endpoints`, { const res = await fetch(`${gatewayUrl}/api/gateway/endpoints`, {
@@ -42,52 +47,55 @@ export function LoginPage() {
} }
return ( return (
<div <div className="min-h-screen flex items-center justify-center bg-background relative">
className="min-h-screen flex items-center justify-center" <Button
style={{ background: "var(--color-bg)" }} variant="ghost"
> size="icon"
<div className="absolute top-4 right-4 transition-colors duration-200"
className="p-8 rounded-lg border w-full max-w-sm" onClick={toggleTheme}
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
> >
<h1 className="text-xl font-bold mb-1" style={{ color: "var(--color-accent)" }}> {theme === "dark" ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
Workflow Dashboard </Button>
</h1> <Card className="w-full max-w-sm shadow-lg transition-all duration-200 hover:shadow-xl hover:border-primary/30">
<p className="text-sm mb-6" style={{ color: "var(--color-text-muted)" }}> <CardHeader>
Enter your API key to continue <CardTitle className="flex items-center gap-2 text-xl tracking-tight">
</p> <Settings className="h-5 w-5" />
<form onSubmit={handleSubmit}> Workflow Dashboard
<input </CardTitle>
type="password" <CardDescription>Enter your API key to continue</CardDescription>
value={key} </CardHeader>
onChange={(e) => setKey(e.target.value)} <CardContent>
placeholder="API Key" <form onSubmit={handleSubmit} className="space-y-4">
className="w-full px-3 py-2 rounded border text-sm mb-3 outline-none" <Input
style={{ type="password"
background: "var(--color-bg)", value={key}
borderColor: "var(--color-border)", onChange={(e) => setKey(e.target.value)}
color: "var(--color-text)", placeholder="API Key"
}} className="transition-all duration-200"
/> />
{error && ( {error && (
<p className="text-xs mb-3" style={{ color: "var(--color-error)" }}> <p className="text-xs text-destructive flex items-center gap-1.5">
{error} <AlertCircle className="h-3.5 w-3.5 shrink-0" />
</p> {error}
)} </p>
<button )}
type="submit" <Button
disabled={loading || !key.trim()} type="submit"
className="w-full px-3 py-2 rounded text-sm font-medium" disabled={loading || !key.trim()}
style={{ className="w-full transition-all duration-200"
background: "var(--color-accent)", >
color: "var(--color-bg)", {loading ? (
opacity: loading || !key.trim() ? 0.5 : 1, <span className="flex items-center gap-2">
}} <Loader2 className="h-4 w-4 animate-spin" />
> Verifying
{loading ? "Verifying..." : "Login"} </span>
</button> ) : (
</form> "Login"
</div> )}
</Button>
</form>
</CardContent>
</Card>
</div> </div>
); );
} }
@@ -52,19 +52,23 @@ function CodeBlock({ className, children }: { className?: string; children?: Rea
if (html !== null) { if (html !== null) {
return ( return (
<div <div className="relative rounded-lg border border-border overflow-hidden my-3">
className="rounded overflow-x-auto text-xs my-2" {lang !== "text" && (
// biome-ignore lint/security/noDangerouslySetInnerHtml: shiki output is safe <span className="absolute top-2 right-2 text-[10px] uppercase tracking-wider text-muted-foreground/70 font-mono">
dangerouslySetInnerHTML={{ __html: html }} {lang}
/> </span>
)}
<div
className="overflow-x-auto text-xs"
// biome-ignore lint/security/noDangerouslySetInnerHtml: shiki output is safe
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
); );
} }
return ( return (
<pre <pre className="rounded-lg overflow-x-auto text-xs my-3 p-3 bg-muted/50 border border-border">
className="rounded overflow-x-auto text-xs my-2 p-3"
style={{ background: "var(--color-bg)" }}
>
<code>{code}</code> <code>{code}</code>
</pre> </pre>
); );
@@ -80,8 +84,7 @@ export function Markdown({ content }: { content: string }) {
if (isInline) { if (isInline) {
return ( return (
<code <code
className="text-xs px-1 py-0.5 rounded" className="bg-muted rounded px-1.5 py-0.5 text-[13px] font-mono text-foreground"
style={{ background: "var(--color-border)", color: "var(--color-accent)" }}
{...props} {...props}
> >
{children} {children}
@@ -91,7 +94,7 @@ export function Markdown({ content }: { content: string }) {
return <CodeBlock className={className}>{children}</CodeBlock>; return <CodeBlock className={className}>{children}</CodeBlock>;
}, },
p({ children }) { p({ children }) {
return <p className="my-1.5 leading-relaxed">{children}</p>; return <p className="my-2 leading-relaxed">{children}</p>;
}, },
ul({ children }) { ul({ children }) {
return <ul className="list-disc pl-4 my-1.5">{children}</ul>; return <ul className="list-disc pl-4 my-1.5">{children}</ul>;
@@ -100,20 +103,25 @@ export function Markdown({ content }: { content: string }) {
return <ol className="list-decimal pl-4 my-1.5">{children}</ol>; return <ol className="list-decimal pl-4 my-1.5">{children}</ol>;
}, },
h1({ children }) { h1({ children }) {
return <h1 className="text-lg font-bold mt-3 mb-1">{children}</h1>; return (
<h1 className="text-lg font-bold mt-3 mb-2 border-b border-border pb-1">
{children}
</h1>
);
}, },
h2({ children }) { h2({ children }) {
return <h2 className="text-base font-bold mt-2 mb-1">{children}</h2>; return (
<h2 className="text-base font-bold mt-2 mb-2 border-b border-border pb-1">
{children}
</h2>
);
}, },
h3({ children }) { h3({ children }) {
return <h3 className="text-sm font-bold mt-2 mb-1">{children}</h3>; return <h3 className="text-sm font-bold mt-2 mb-1">{children}</h3>;
}, },
blockquote({ children }) { blockquote({ children }) {
return ( return (
<blockquote <blockquote className="border-l-2 border-ring pl-3 my-2 text-sm text-muted-foreground bg-muted/30 rounded-r-md py-2">
className="border-l-2 pl-3 my-2 text-sm"
style={{ borderColor: "var(--color-accent)", color: "var(--color-text-muted)" }}
>
{children} {children}
</blockquote> </blockquote>
); );
@@ -1,14 +1,26 @@
import { CheckCircle2, Clock, MessageSquare, Rocket, User, XCircle } from "lucide-react";
import type { RoleRecord, ThreadRecord, ThreadStartRecord, WorkflowResultRecord } from "../api.ts"; import type { RoleRecord, ThreadRecord, ThreadStartRecord, WorkflowResultRecord } from "../api.ts";
import { cn } from "../lib/utils.ts";
import { Markdown } from "./markdown.tsx"; import { Markdown } from "./markdown.tsx";
import { Badge } from "./ui/badge.tsx";
import { Card } from "./ui/card.tsx";
const ROLE_COLORS: Record<string, string> = { const ROLE_HUES = [262, 210, 35, 150, 330, 180, 15, 280, 55, 195, 345, 120, 240, 75, 305];
preparer: "#8b5cf6",
client: "#3b82f6",
extractor: "#f59e0b",
};
function roleColor(role: string): string { function roleHue(role: string): number {
return ROLE_COLORS[role] ?? "var(--color-accent)"; let hash = 0;
for (let i = 0; i < role.length; i++) {
hash = (hash * 31 + role.charCodeAt(i)) | 0;
}
return ROLE_HUES[Math.abs(hash) % ROLE_HUES.length];
}
function roleBadgeStyle(role: string): { backgroundColor: string; borderColor: string } {
const hue = roleHue(role);
return {
backgroundColor: `oklch(0.58 0.12 ${hue} / 0.85)`,
borderColor: `oklch(0.58 0.12 ${hue} / 0.25)`,
};
} }
function formatTime(ts: number | null): string | null { function formatTime(ts: number | null): string | null {
@@ -18,99 +30,86 @@ function formatTime(ts: number | null): string | null {
function StartCard({ record }: { record: ThreadStartRecord }) { function StartCard({ record }: { record: ThreadStartRecord }) {
return ( return (
<div <Card className="p-4 transition-all duration-200 overflow-hidden relative">
className="p-4 rounded-lg border" <div className="absolute inset-x-0 top-0 h-0.5 bg-gradient-to-r from-primary/80 via-primary/40 to-transparent" />
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<span className="text-lg">🚀</span> <Rocket className="h-5 w-5 text-primary" />
<span className="font-semibold" style={{ color: "var(--color-accent)" }}> <span className="font-semibold text-foreground">{record.workflow}</span>
{record.workflow} <Badge variant={record.status === "active" ? "success" : "secondary"}>
</span>
<span
className="text-xs px-2 py-0.5 rounded"
style={{
background: record.status === "active" ? "var(--color-success)" : "var(--color-border)",
color: record.status === "active" ? "var(--color-bg)" : "var(--color-text-muted)",
}}
>
{record.status} {record.status}
</span> </Badge>
</div> </div>
{record.prompt !== null && ( {record.prompt !== null && (
<div <div className="mt-2 p-3 rounded-md text-sm border-l-2 border-ring bg-muted/50">
className="mt-2 p-3 rounded text-sm border-l-2" <div className="text-xs mb-1 text-muted-foreground flex items-center gap-1">
style={{ <MessageSquare className="h-3 w-3" />
background: "var(--color-bg)",
borderColor: "var(--color-accent)",
color: "var(--color-text)",
}}
>
<div className="text-xs mb-1" style={{ color: "var(--color-text-muted)" }}>
Prompt Prompt
</div> </div>
<Markdown content={record.prompt} /> <Markdown content={record.prompt} />
</div> </div>
)} )}
</div> </Card>
); );
} }
function RoleMessage({ record, highlighted }: { record: RoleRecord; highlighted: boolean }) { function RoleMessage({ record, highlighted }: { record: RoleRecord; highlighted: boolean }) {
const color = roleColor(record.role); const style = roleBadgeStyle(record.role);
return ( return (
<div <Card
className={`p-3 rounded-lg border text-sm ${highlighted ? "wf-record-card-highlight" : ""}`} className={cn(
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }} "p-3 text-sm transition-all duration-200 border-l-4",
highlighted && "wf-record-card-highlight",
)}
style={{ borderLeftColor: style.borderColor }}
> >
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<span <span
className="text-xs px-2 py-0.5 rounded font-mono font-medium" className="text-xs px-2 py-0.5 rounded font-mono font-medium text-white shadow-sm inline-flex items-center gap-1"
style={{ background: color, color: "#fff" }} style={{ backgroundColor: style.backgroundColor }}
> >
<User className="h-3 w-3" />
{record.role} {record.role}
</span> </span>
{formatTime(record.timestamp) !== null && ( {formatTime(record.timestamp) !== null && (
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}> <span className="text-xs ml-auto text-muted-foreground tabular-nums flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatTime(record.timestamp)} {formatTime(record.timestamp)}
</span> </span>
)} )}
</div> </div>
<Markdown content={record.content} /> <Markdown content={record.content} />
</div> </Card>
); );
} }
function ResultCard({ record }: { record: WorkflowResultRecord }) { function ResultCard({ record }: { record: WorkflowResultRecord }) {
const success = record.returnCode === 0; const success = record.returnCode === 0;
return ( return (
<div <Card
className="p-4 rounded-lg border" className={cn(
style={{ "p-4 transition-all duration-200 border-l-4",
background: "var(--color-surface)", success ? "border-l-success" : "border-l-destructive",
borderColor: success ? "var(--color-success)" : "var(--color-error)", )}
}}
> >
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<span className="text-lg">{success ? "✅" : "❌"}</span> {success ? (
<CheckCircle2 className="h-5 w-5 text-success" />
) : (
<XCircle className="h-5 w-5 text-destructive" />
)}
<span className="font-semibold text-sm">{success ? "Completed" : "Failed"}</span> <span className="font-semibold text-sm">{success ? "Completed" : "Failed"}</span>
<span <Badge variant="outline" className="font-mono">
className="text-xs px-2 py-0.5 rounded font-mono"
style={{
background: success ? "var(--color-success)" : "var(--color-error)",
color: "#fff",
}}
>
exit {record.returnCode} exit {record.returnCode}
</span> </Badge>
{formatTime(record.timestamp) !== null && ( {formatTime(record.timestamp) !== null && (
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}> <span className="text-xs ml-auto text-muted-foreground tabular-nums flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatTime(record.timestamp)} {formatTime(record.timestamp)}
</span> </span>
)} )}
</div> </div>
<Markdown content={record.content} /> <Markdown content={record.content} />
</div> </Card>
); );
} }
@@ -2,13 +2,25 @@ import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { listWorkflows, runThread } from "../api.ts"; import { listWorkflows, runThread } from "../api.ts";
import { useFetch } from "../hooks.ts"; import { useFetch } from "../hooks.ts";
import { Button } from "./ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select.tsx";
import { Textarea } from "./ui/textarea.tsx";
type Props = { type Props = {
client: string; client: string;
onClose: () => void; open: boolean;
onOpenChange: (open: boolean) => void;
}; };
export function RunDialog({ client, onClose }: Props) { export function RunDialog({ client, open, onOpenChange }: Props) {
const navigate = useNavigate(); const navigate = useNavigate();
const workflows = useFetch(() => listWorkflows(client), [client]); const workflows = useFetch(() => listWorkflows(client), [client]);
const [workflow, setWorkflow] = useState(""); const [workflow, setWorkflow] = useState("");
@@ -23,7 +35,7 @@ export function RunDialog({ client, onClose }: Props) {
setError(null); setError(null);
try { try {
const result = await runThread(client, workflow, prompt); const result = await runThread(client, workflow, prompt);
onClose(); onOpenChange(false);
navigate(`/${client}/threads/${result.threadId}`); navigate(`/${client}/threads/${result.threadId}`);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
@@ -32,95 +44,54 @@ export function RunDialog({ client, onClose }: Props) {
} }
return ( return (
<div <Dialog open={open} onOpenChange={onOpenChange}>
className="fixed inset-0 flex items-center justify-center z-50" <DialogContent>
style={{ background: "rgba(0,0,0,0.6)" }} <DialogHeader>
> <DialogTitle>Run Thread</DialogTitle>
<div <DialogDescription>Start a new thread on {client}</DialogDescription>
className="w-full max-w-lg p-6 rounded-lg border" </DialogHeader>
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<h3 className="text-lg font-semibold mb-4">Run Thread on {client}</h3>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label <label htmlFor="run-workflow" className="text-sm block mb-1.5 text-muted-foreground">
htmlFor="run-workflow"
className="text-sm block mb-1"
style={{ color: "var(--color-text-muted)" }}
>
Workflow Workflow
</label> </label>
<select <Select value={workflow} onValueChange={setWorkflow}>
id="run-workflow" <SelectTrigger>
value={workflow} <SelectValue placeholder="Select a workflow..." />
onChange={(e) => setWorkflow(e.target.value)} </SelectTrigger>
className="w-full px-3 py-2 rounded border text-sm" <SelectContent>
style={{ {workflows.status === "ok" &&
background: "var(--color-bg)", workflows.data.workflows.map((w) => (
borderColor: "var(--color-border)", <SelectItem key={w.name} value={w.name}>
color: "var(--color-text)", {w.name}
}} </SelectItem>
> ))}
<option value="">Select a workflow...</option> </SelectContent>
{workflows.status === "ok" && </Select>
workflows.data.workflows.map((w) => (
<option key={w.name} value={w.name}>
{w.name}
</option>
))}
</select>
</div> </div>
<div> <div>
<label <label htmlFor="run-prompt" className="text-sm block mb-1.5 text-muted-foreground">
htmlFor="run-prompt"
className="text-sm block mb-1"
style={{ color: "var(--color-text-muted)" }}
>
Prompt Prompt
</label> </label>
<textarea <Textarea
id="run-prompt" id="run-prompt"
value={prompt} value={prompt}
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
rows={4} rows={4}
className="w-full px-3 py-2 rounded border text-sm"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
placeholder="Enter the task prompt..." placeholder="Enter the task prompt..."
/> />
</div> </div>
{error && ( {error && <p className="text-sm text-destructive">{error}</p>}
<p className="text-sm" style={{ color: "var(--color-error)" }}> <DialogFooter>
{error} <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
</p>
)}
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded border"
style={{ borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
>
Cancel Cancel
</button> </Button>
<button <Button type="submit" disabled={submitting || !workflow || !prompt}>
type="submit"
disabled={submitting || !workflow || !prompt}
className="px-4 py-2 text-sm rounded"
style={{
background: submitting ? "var(--color-accent-dim)" : "var(--color-accent)",
color: "#fff",
opacity: !workflow || !prompt ? 0.5 : 1,
}}
>
{submitting ? "Starting..." : "Run"} {submitting ? "Starting..." : "Run"}
</button> </Button>
</div> </DialogFooter>
</form> </form>
</div> </DialogContent>
</div> </Dialog>
); );
} }
@@ -1,13 +1,20 @@
import { Loader2, LogOut, Moon, Package, Sun, Zap } from "lucide-react";
import { useLocation, useNavigate, useParams } from "react-router"; import { useLocation, useNavigate, useParams } from "react-router";
import type { ClientEndpoint } from "../api.ts"; import type { ClientEndpoint } from "../api.ts";
import { listClients } from "../api.ts"; import { listClients } from "../api.ts";
import { useFetch } from "../hooks.ts"; import { useFetch } from "../hooks.ts";
import { cn } from "../lib/utils.ts";
import { Button } from "./ui/button.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select.tsx";
import { Separator } from "./ui/separator.tsx";
type Props = { type Props = {
onLogout: () => void; onLogout: () => void;
theme: "light" | "dark";
onToggleTheme: () => void;
}; };
export function Sidebar({ onLogout }: Props) { export function Sidebar({ onLogout, theme, onToggleTheme }: Props) {
const { client } = useParams(); const { client } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@@ -18,93 +25,107 @@ export function Sidebar({ onLogout }: Props) {
const view = location.pathname.includes("/workflows") ? "workflows" : "threads"; const view = location.pathname.includes("/workflows") ? "workflows" : "threads";
const viewItems = [ const viewItems = [
{ key: "threads" as const, label: "Threads", icon: "⚡" }, { key: "threads" as const, label: "Threads", icon: Zap },
{ key: "workflows" as const, label: "Workflows", icon: "📦" }, { key: "workflows" as const, label: "Workflows", icon: Package },
]; ];
return ( return (
<aside <aside className="w-56 border-r border-border flex flex-col bg-sidebar">
className="w-56 border-r flex flex-col" <div className="p-4 border-b border-primary/20">
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }} <h1 className="text-xl font-bold text-foreground tracking-tight">Workflow</h1>
> <p className="text-xs text-muted-foreground mt-0.5 tracking-wide uppercase">Dashboard</p>
<div className="p-4 border-b" style={{ borderColor: "var(--color-border)" }}>
<h1 className="text-lg font-semibold" style={{ color: "var(--color-accent)" }}>
Workflow
</h1>
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>
Dashboard
</p>
</div> </div>
<div className="px-4 py-3 border-b" style={{ borderColor: "var(--color-border)" }}> <div className="px-3 py-3">
<label <label
className="block text-xs font-medium mb-1" className="block text-xs font-medium mb-1.5 text-muted-foreground"
style={{ color: "var(--color-text-muted)" }}
htmlFor="client-select" htmlFor="client-select"
> >
Client Client
</label> </label>
<select {status === "loading" ? (
id="client-select" <div className="h-9 rounded-md border border-input bg-transparent px-3 py-2 text-xs text-muted-foreground flex items-center gap-2">
className="w-full rounded px-2 py-1.5 text-xs" <Loader2 className="h-3.5 w-3.5 animate-spin" />
style={{ Loading
background: "var(--color-bg)", </div>
color: "var(--color-text)", ) : clients.length === 0 ? (
border: "1px solid var(--color-border)", <div className="h-9 rounded-md border border-input bg-transparent px-3 py-2 text-xs text-muted-foreground flex items-center">
}} No clients online
value={client ?? ""} </div>
onChange={(e) => { ) : (
const name = e.target.value; <Select
if (name) { value={client ?? ""}
navigate(`/${name}/${view}`); onValueChange={(name) => {
} if (name) navigate(`/${name}/${view}`);
}} }}
disabled={status === "loading"} >
> <SelectTrigger className="h-8 text-xs transition-colors duration-200">
{status === "loading" ? ( <SelectValue placeholder="Select client…" />
<option value="">Loading</option> </SelectTrigger>
) : clients.length === 0 ? ( <SelectContent>
<option value="">No clients online</option> {clients.map((a) => (
) : ( <SelectItem key={a.name} value={a.name} className="text-xs">
clients.map((a) => ( <span className="flex items-center gap-2">
<option key={a.name} value={a.name}> <span
{a.status === "online" ? "🟢" : "🔴"} {a.name} className={cn(
</option> "inline-block h-2 w-2 rounded-full",
)) a.status === "online" ? "bg-success animate-pulse" : "bg-destructive",
)} )}
</select> />
{a.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div> </div>
<Separator />
<nav className="flex-1 p-2 space-y-1"> <nav className="flex-1 p-2 space-y-1">
{viewItems.map((item) => ( {viewItems.map((item) => (
<button <Button
type="button"
key={item.key} key={item.key}
variant={view === item.key ? "secondary" : "ghost"}
size="sm"
className={cn(
"w-full justify-start gap-2 transition-colors duration-200",
view === item.key
? "text-foreground border-l-2 border-primary rounded-l-none"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => { onClick={() => {
if (client) { if (client) navigate(`/${client}/${item.key}`);
navigate(`/${client}/${item.key}`);
}
}}
className="w-full text-left px-3 py-2 rounded text-sm transition-colors"
style={{
background: view === item.key ? "var(--color-accent-dim)" : "transparent",
color: view === item.key ? "#fff" : "var(--color-text-muted)",
}} }}
> >
{item.icon} {item.label} <item.icon className="h-4 w-4" />
</button> {item.label}
</Button>
))} ))}
</nav> </nav>
<div className="p-2 border-t" style={{ borderColor: "var(--color-border)" }}> <Separator />
<button
type="button" <div className="p-2 space-y-1">
onClick={onLogout} <Button
className="w-full text-left px-3 py-2 rounded text-xs transition-colors" variant="ghost"
style={{ color: "var(--color-text-muted)" }} size="sm"
className="w-full justify-start gap-2 text-muted-foreground hover:text-foreground transition-colors duration-200"
onClick={onToggleTheme}
> >
🚪 Logout {theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button> {theme === "dark" ? "Light mode" : "Dark mode"}
</Button>
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-muted-foreground hover:text-foreground transition-colors duration-200"
onClick={onLogout}
>
<LogOut className="h-4 w-4" />
Logout
</Button>
</div> </div>
</aside> </aside>
); );
@@ -1,5 +1,7 @@
import { Loader2, Play, Wifi, WifiOff } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { getClientHealth } from "../api.ts"; import { getClientHealth } from "../api.ts";
import { Button } from "./ui/button.tsx";
type HealthStatus = "connected" | "disconnected" | "reconnecting"; type HealthStatus = "connected" | "disconnected" | "reconnecting";
@@ -8,14 +10,29 @@ type Props = {
onRun: () => void; onRun: () => void;
}; };
function statusLabel(status: HealthStatus): { text: string; color: string } { function StatusIndicator({ status }: { status: HealthStatus }) {
if (status === "connected") { if (status === "connected") {
return { text: "● Connected", color: "var(--color-success)" }; return (
<span className="flex items-center gap-1.5 text-xs text-success transition-colors duration-200">
<Wifi className="h-3.5 w-3.5" />
Connected
</span>
);
} }
if (status === "reconnecting") { if (status === "reconnecting") {
return { text: "● Reconnecting...", color: "var(--color-warning, #f59e0b)" }; return (
<span className="flex items-center gap-1.5 text-xs text-warning transition-colors duration-200">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Reconnecting
</span>
);
} }
return { text: "● Offline", color: "var(--color-error)" }; return (
<span className="flex items-center gap-1.5 text-xs text-destructive transition-colors duration-200">
<WifiOff className="h-3.5 w-3.5" />
Offline
</span>
);
} }
export function StatusBar({ client, onRun }: Props) { export function StatusBar({ client, onRun }: Props) {
@@ -48,32 +65,24 @@ export function StatusBar({ client, onRun }: Props) {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [checkHealth]); }, [checkHealth]);
const label = statusLabel(status);
return ( return (
<div <div className="flex items-center justify-between px-6 py-2 text-xs border-b border-border bg-card/80 backdrop-blur-sm">
className="flex items-center justify-between px-6 py-2 text-xs border-b"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span style={{ color: "var(--color-text-muted)" }}> <span className="text-muted-foreground">
{client ? `Client: ${client}` : "No client selected"} {client ? `Client: ${client}` : "No client selected"}
</span> </span>
<button <Button
type="button" variant="default"
onClick={onRun} size="sm"
disabled={!client} disabled={!client}
className="px-3 py-1 rounded text-xs font-medium" onClick={onRun}
style={{ className="h-7 gap-1.5 transition-all duration-200"
background: client ? "var(--color-accent)" : "var(--color-border)",
color: "#fff",
opacity: client ? 1 : 0.5,
}}
> >
Run Thread <Play className="h-3.5 w-3.5" />
</button> Run Thread
</Button>
</div> </div>
<span style={{ color: label.color }}>{label.text}</span> <StatusIndicator status={status} />
</div> </div>
); );
} }
@@ -1,3 +1,4 @@
import { AlertCircle, ArrowLeft, Layers, Loader2, Pause, Play, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { import {
@@ -12,6 +13,10 @@ import {
import { useFetch } from "../hooks.ts"; import { useFetch } from "../hooks.ts";
import { useSSE } from "../use-sse.ts"; import { useSSE } from "../use-sse.ts";
import { RecordCard } from "./record-card.tsx"; import { RecordCard } from "./record-card.tsx";
import { Badge } from "./ui/badge.tsx";
import { Button } from "./ui/button.tsx";
import { Card } from "./ui/card.tsx";
import { ResizablePanel } from "./ui/resizable-panel.tsx";
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts"; import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
function extractWorkflowName(records: readonly ThreadRecord[]): string | null { function extractWorkflowName(records: readonly ThreadRecord[]): string | null {
@@ -92,27 +97,23 @@ export function ThreadDetail() {
return m; return m;
}, [records]); }, [records]);
// Track which occurrence to jump to next per role (cycling)
const clickCycleRef = useRef<Map<string, number>>(new Map()); const clickCycleRef = useRef<Map<string, number>>(new Map());
const handleGraphNodeClick = useCallback( const handleGraphNodeClick = useCallback(
(nodeId: string) => { (nodeId: string) => {
if (nodeStates.get(nodeId) === undefined || nodeStates.get(nodeId) === "default") return; if (nodeStates.get(nodeId) === undefined || nodeStates.get(nodeId) === "default") return;
// __start__: scroll to the first record (thread-start prompt)
if (nodeId === "__start__") { if (nodeId === "__start__") {
const firstCard = document.querySelector('[data-record-index="0"]'); const firstCard = document.querySelector('[data-record-index="0"]');
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" }); if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
return; return;
} }
// __end__: scroll to bottom
if (nodeId === "__end__") { if (nodeId === "__end__") {
recordsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); recordsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
return; return;
} }
// Role nodes: cycle through occurrences
const indices = indicesByRole.get(nodeId); const indices = indicesByRole.get(nodeId);
if (indices === undefined || indices.length === 0) return; if (indices === undefined || indices.length === 0) return;
@@ -150,7 +151,7 @@ export function ThreadDetail() {
try { try {
const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread; const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread;
await fn(client, threadId); await fn(client, threadId);
setActionStatus(`${action} sent ✓`); setActionStatus(null);
} catch (e) { } catch (e) {
setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`); setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`);
} }
@@ -159,88 +160,84 @@ export function ThreadDetail() {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<button <Button
type="button" variant="ghost"
className="gap-1.5 px-2 text-muted-foreground hover:text-foreground transition-colors duration-200"
onClick={() => navigate(`/${client}/threads`)} onClick={() => navigate(`/${client}/threads`)}
className="text-sm hover:underline"
style={{ color: "var(--color-accent)" }}
> >
Back to threads <ArrowLeft className="h-4 w-4" />
</button> Back to threads
<div className="flex gap-2"> </Button>
<button <div className="flex gap-1 rounded-lg border border-border bg-muted/30 p-1">
type="button" <Button
variant="outline"
size="sm"
className="transition-colors duration-200"
onClick={() => handleAction("pause")} onClick={() => handleAction("pause")}
className="px-3 py-1 text-xs rounded border"
style={{ borderColor: "var(--color-warning)", color: "var(--color-warning)" }}
> >
Pause <Pause className="h-3.5 w-3.5 text-warning" />
</button> Pause
<button </Button>
type="button" <Button
variant="outline"
size="sm"
className="transition-colors duration-200"
onClick={() => handleAction("resume")} onClick={() => handleAction("resume")}
className="px-3 py-1 text-xs rounded border"
style={{ borderColor: "var(--color-success)", color: "var(--color-success)" }}
> >
Resume <Play className="h-3.5 w-3.5 text-success" />
</button> Resume
<button </Button>
type="button" <Button
variant="outline"
size="sm"
className="transition-colors duration-200"
onClick={() => handleAction("kill")} onClick={() => handleAction("kill")}
className="px-3 py-1 text-xs rounded border"
style={{ borderColor: "var(--color-error)", color: "var(--color-error)" }}
> >
Kill <X className="h-3.5 w-3.5 text-destructive" />
</button> Kill
</Button>
</div> </div>
</div> </div>
<h2 className="text-xl font-semibold mb-2 font-mono flex items-center gap-2 flex-wrap"> <h2 className="text-xl font-semibold mb-2 font-mono tracking-tight flex items-center gap-2 flex-wrap">
<span>{threadId}</span> <span>{threadId}</span>
{sse.connected && !sse.completed && ( {sse.connected && !sse.completed && (
<span <Badge variant="success" className="animate-pulse flex items-center gap-1.5">
className="text-xs font-medium px-2 py-0.5 rounded" <span className="inline-block h-2 w-2 rounded-full bg-success-foreground" />
style={{ background: "var(--color-success)", color: "var(--color-bg)" }}
>
Live Live
</span> </Badge>
)} )}
</h2> </h2>
{actionStatus && ( {actionStatus && (
<p className="text-xs mb-4" style={{ color: "var(--color-text-muted)" }}> <Badge variant="secondary" className="mb-4 text-xs font-normal">
{actionStatus} {actionStatus}
</p> </Badge>
)} )}
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 120px)" }}> <div className="flex gap-4" style={{ minHeight: "calc(100vh - 120px)" }}>
{descriptor !== null && descriptor.graph.edges.length > 0 && ( {descriptor !== null && descriptor.graph.edges.length > 0 && (
<div <ResizablePanel
className="shrink-0" defaultWidth={360}
minWidth={240}
maxWidth={560}
className={null}
style={{ style={{
width: 280,
position: "sticky", position: "sticky",
top: 16, top: 16,
height: "calc(100vh - 120px)", height: "calc(100vh - 120px)",
alignSelf: "flex-start", alignSelf: "flex-start",
}} }}
> >
<div <Card className="h-full flex flex-col overflow-hidden">
className="rounded-lg border h-full flex flex-col overflow-hidden" <div className="flex items-center justify-between px-3 py-2 text-xs text-muted-foreground bg-muted/50 border-b border-border">
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }} <span className="font-mono flex items-center gap-1.5">
> <Layers className="h-3.5 w-3.5" />
<div
className="flex items-center justify-between px-3 py-2 text-xs"
style={{ color: "var(--color-text-muted)" }}
>
<span className="font-mono">
Workflow graph Workflow graph
{workflowName !== null && ( {workflowName !== null && (
<span className="ml-2" style={{ color: "var(--color-text)" }}> <span className="ml-2 text-foreground">{workflowName}</span>
{workflowName}
</span>
)} )}
</span> </span>
<span> <span className="tabular-nums">
{descriptor.graph.edges.length} edge {descriptor.graph.edges.length} edge
{descriptor.graph.edges.length === 1 ? "" : "s"} {descriptor.graph.edges.length === 1 ? "" : "s"}
</span> </span>
@@ -253,19 +250,25 @@ export function ThreadDetail() {
onNodeClick={handleGraphNodeClick} onNodeClick={handleGraphNodeClick}
/> />
</div> </div>
</div> </Card>
</div> </ResizablePanel>
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{status === "loading" && !liveActive && records.length === 0 && ( {status === "loading" && !liveActive && records.length === 0 && (
<p style={{ color: "var(--color-text-muted)" }}>Loading...</p> <div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-3">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="text-sm">Loading thread...</span>
</div>
)} )}
{status === "error" && !liveActive && ( {status === "error" && !liveActive && (
<p style={{ color: "var(--color-error)" }}>Error: {error}</p> <div className="flex items-center gap-2 py-8 justify-center text-destructive">
<AlertCircle className="h-5 w-5" />
<span className="text-sm">Error: {error}</span>
</div>
)} )}
{(status === "ok" || liveActive || records.length > 0) && ( {(status === "ok" || liveActive || records.length > 0) && (
<div className="space-y-3"> <div className="border-l-2 border-border ml-2 pl-4 space-y-3">
{records.map((r, i) => { {records.map((r, i) => {
const key = `${threadId}-${i}`; const key = `${threadId}-${i}`;
if (r.type === "role") { if (r.type === "role") {
@@ -276,18 +279,21 @@ export function ThreadDetail() {
<div <div
key={key} key={key}
data-record-index={i} data-record-index={i}
className="relative"
ref={(el) => { ref={(el) => {
if (!isFirstForRole) return; if (!isFirstForRole) return;
if (el !== null) firstCardByRoleRef.current.set(r.role, el); if (el !== null) firstCardByRoleRef.current.set(r.role, el);
else firstCardByRoleRef.current.delete(r.role); else firstCardByRoleRef.current.delete(r.role);
}} }}
> >
<div className="absolute -left-[1.3rem] top-4 h-2.5 w-2.5 rounded-full border-2 border-border bg-background" />
<RecordCard record={r} highlighted={flash} /> <RecordCard record={r} highlighted={flash} />
</div> </div>
); );
} }
return ( return (
<div key={key} data-record-index={i}> <div key={key} data-record-index={i} className="relative">
<div className="absolute -left-[1.3rem] top-4 h-2.5 w-2.5 rounded-full border-2 border-border bg-background" />
<RecordCard record={r} highlighted={false} /> <RecordCard record={r} highlighted={false} />
</div> </div>
); );
@@ -1,6 +1,15 @@
import { AlertCircle, Clock, Loader2, Workflow, Zap } from "lucide-react";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { listThreads } from "../api.ts"; import { listThreads } from "../api.ts";
import { useFetch } from "../hooks.ts"; import { useFetch } from "../hooks.ts";
import { Badge } from "./ui/badge.tsx";
import { Card } from "./ui/card.tsx";
function statusVariant(status: string): "success" | "destructive" | "secondary" {
if (status === "completed") return "success";
if (status === "failed") return "destructive";
return "secondary";
}
export function ThreadList() { export function ThreadList() {
const params = useParams(); const params = useParams();
@@ -9,8 +18,20 @@ export function ThreadList() {
const { status, data, error } = useFetch(() => listThreads(client), [client]); const { status, data, error } = useFetch(() => listThreads(client), [client]);
if (status === "loading") if (status === "loading")
return <p style={{ color: "var(--color-text-muted)" }}>Loading threads...</p>; return (
if (status === "error") return <p style={{ color: "var(--color-error)" }}>Error: {error}</p>; <div className="flex flex-col items-center justify-center py-16 gap-3">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Loading threads...</p>
</div>
);
if (status === "error")
return (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<AlertCircle className="h-5 w-5 text-destructive" />
<p className="text-sm text-destructive">Error: {error}</p>
</div>
);
const threads = [...data.threads].sort((a, b) => { const threads = [...data.threads].sort((a, b) => {
if (!a.startedAt && !b.startedAt) return 0; if (!a.startedAt && !b.startedAt) return 0;
@@ -21,51 +42,44 @@ export function ThreadList() {
return ( return (
<div> <div>
<h2 className="text-xl font-semibold mb-4">Threads</h2> <h2 className="text-xl font-semibold tracking-tight mb-4">Threads</h2>
{threads.length === 0 ? ( {threads.length === 0 ? (
<p style={{ color: "var(--color-text-muted)" }}>No threads found.</p> <div className="flex flex-col items-center justify-center py-16 gap-3">
<Zap className="h-12 w-12 text-muted-foreground/50" />
<p className="text-sm font-medium">No threads</p>
<p className="text-xs text-muted-foreground">
Run a workflow to create your first thread.
</p>
</div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{threads.map((t) => ( {threads.map((t) => (
<button <Card
type="button"
key={t.threadId} key={t.threadId}
className="p-4 cursor-pointer hover:bg-accent/50 hover:shadow-sm transition-all duration-200"
onClick={() => navigate(`/${client}/threads/${t.threadId}`)} onClick={() => navigate(`/${client}/threads/${t.threadId}`)}
className="w-full text-left p-4 rounded-lg border transition-colors hover:border-[var(--color-accent-dim)]"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<code className="text-sm font-mono" style={{ color: "var(--color-accent)" }}> <code className="font-mono text-sm text-foreground">{t.threadId}</code>
{t.threadId}
</code>
{t.status && ( {t.status && (
<span <Badge variant={statusVariant(t.status)} className="text-xs">
className="text-xs px-2 py-0.5 rounded"
style={{
background:
t.status === "completed"
? "var(--color-success)"
: t.status === "failed"
? "var(--color-error)"
: "var(--color-accent)",
color: "#000",
}}
>
{t.status} {t.status}
</span> </Badge>
)} )}
</div> </div>
{t.workflow && ( {t.workflow && (
<p className="text-sm mt-1" style={{ color: "var(--color-text-muted)" }}> <p className="text-sm mt-1 font-medium text-foreground flex items-center gap-1.5">
<Workflow className="h-3.5 w-3.5 text-muted-foreground" />
{t.workflow} {t.workflow}
</p> </p>
)} )}
{t.startedAt && ( {t.startedAt && (
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}> <p className="text-xs mt-1 text-muted-foreground flex items-center gap-1.5">
<Clock className="h-3 w-3" />
{t.startedAt} {t.startedAt}
</p> </p>
)} )}
</button> </Card>
))} ))}
</div> </div>
)} )}
@@ -0,0 +1,30 @@
import { cva, type VariantProps } from "class-variance-authority";
import type { HTMLAttributes } from "react";
import { cn } from "../../lib/utils.ts";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground shadow",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive: "border-transparent bg-destructive text-destructive-foreground shadow",
outline: "text-foreground",
success: "border-transparent bg-success text-success-foreground shadow",
warning: "border-transparent bg-warning text-warning-foreground shadow",
},
},
defaultVariants: {
variant: "default",
},
},
);
export type BadgeProps = HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>;
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
@@ -0,0 +1,45 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import type { ButtonHTMLAttributes } from "react";
import { cn } from "../../lib/utils.ts";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
success: "border border-success text-success hover:bg-success/10",
warning: "border border-warning text-warning hover:bg-warning/10",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}
export { Button, buttonVariants };
@@ -0,0 +1,36 @@
import type { HTMLAttributes } from "react";
import { cn } from "../../lib/utils.ts";
function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />;
}
function CardTitle({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("font-semibold leading-none tracking-tight", className)} {...props} />;
}
function CardDescription({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("text-sm text-muted-foreground", className)} {...props} />;
}
function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("p-6 pt-0", className)} {...props} />;
}
function CardFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex items-center p-6 pt-0", className)} {...props} />;
}
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
@@ -0,0 +1,7 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleContent, CollapsibleTrigger };
@@ -0,0 +1,104 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import type { ComponentPropsWithoutRef, HTMLAttributes } from "react";
import { cn } from "../../lib/utils.ts";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
function DialogOverlay({
className,
...props
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
...props
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Content>) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
@@ -0,0 +1,17 @@
import type { InputHTMLAttributes } from "react";
import { cn } from "../../lib/utils.ts";
function Input({ className, type, ...props }: InputHTMLAttributes<HTMLInputElement>) {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Input };
@@ -0,0 +1,73 @@
import {
type CSSProperties,
type PointerEvent as ReactPointerEvent,
useCallback,
useRef,
useState,
} from "react";
import { cn } from "../../lib/utils.ts";
type Props = {
defaultWidth: number;
minWidth: number;
maxWidth: number;
className: string | null;
style: CSSProperties | null;
children: React.ReactNode;
};
export function ResizablePanel({
defaultWidth,
minWidth,
maxWidth,
className,
style,
children,
}: Props) {
const [width, setWidth] = useState(defaultWidth);
const dragging = useRef(false);
const startX = useRef(0);
const startW = useRef(0);
const onPointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
e.preventDefault();
dragging.current = true;
startX.current = e.clientX;
startW.current = width;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
},
[width],
);
const onPointerMove = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
if (!dragging.current) return;
const delta = e.clientX - startX.current;
const next = Math.min(maxWidth, Math.max(minWidth, startW.current + delta));
setWidth(next);
},
[minWidth, maxWidth],
);
const onPointerUp = useCallback(() => {
dragging.current = false;
}, []);
return (
<div
className={cn("relative shrink-0", className)}
style={{ ...style, width }}
>
{children}
<div
className="absolute top-0 -right-1 w-2 h-full cursor-col-resize z-10 group"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<div className="absolute inset-y-0 left-1/2 w-px bg-border opacity-0 group-hover:opacity-100 transition-opacity duration-150" />
</div>
</div>
);
}
@@ -0,0 +1,42 @@
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "../../lib/utils.ts";
function ScrollArea({
className,
children,
...props
}: ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root className={cn("relative overflow-hidden", className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };
@@ -0,0 +1,148 @@
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "../../lib/utils.ts";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
function SelectTrigger({
className,
children,
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>) {
return (
<SelectPrimitive.Trigger
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectScrollUpButton({
className,
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
);
}
function SelectContent({
className,
children,
position = "popper",
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
@@ -0,0 +1,25 @@
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "../../lib/utils.ts";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
);
}
export { Separator };
@@ -0,0 +1,69 @@
import type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
import { cn } from "../../lib/utils.ts";
function Table({ className, ...props }: HTMLAttributes<HTMLTableElement>) {
return (
<div className="relative w-full overflow-auto">
<table className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
);
}
function TableHeader({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return <thead className={cn("[&_tr]:border-b", className)} {...props} />;
}
function TableBody({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return <tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
}
function TableFooter({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return (
<tfoot
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
);
}
function TableRow({ className, ...props }: HTMLAttributes<HTMLTableRowElement>) {
return (
<tr
className={cn(
"border-b border-border transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: ThHTMLAttributes<HTMLTableCellElement>) {
return (
<th
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: TdHTMLAttributes<HTMLTableCellElement>) {
return (
<td
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCaption({ className, ...props }: HTMLAttributes<HTMLTableCaptionElement>) {
return <caption className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />;
}
export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow };
@@ -0,0 +1,16 @@
import type { TextareaHTMLAttributes } from "react";
import { cn } from "../../lib/utils.ts";
function Textarea({ className, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Textarea };
@@ -0,0 +1,28 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "../../lib/utils.ts";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
function TooltipContent({
className,
sideOffset = 4,
...props
}: ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
@@ -1,17 +1,50 @@
import {
AlertCircle,
ArrowLeft,
ChevronDown,
GitBranch,
Hash,
Layers,
Loader2,
User,
} from "lucide-react";
import { useMemo, useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import type { WorkflowDetail as WorkflowDetailData, WorkflowRoleDescriptor } from "../api.ts"; import type { WorkflowDetail as WorkflowDetailData, WorkflowRoleDescriptor } from "../api.ts";
import { getWorkflowDetail } from "../api.ts"; import { getWorkflowDetail } from "../api.ts";
import { useFetch } from "../hooks.ts"; import { useFetch } from "../hooks.ts";
import { cn } from "../lib/utils.ts";
import { Markdown } from "./markdown.tsx"; import { Markdown } from "./markdown.tsx";
import { Button } from "./ui/button.tsx";
import { Card } from "./ui/card.tsx";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible.tsx";
import { ResizablePanel } from "./ui/resizable-panel.tsx";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table.tsx";
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts"; import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
const ROLE_BORDER_COLORS = [
"border-l-blue-400/60",
"border-l-emerald-400/60",
"border-l-amber-400/60",
"border-l-violet-400/60",
"border-l-rose-400/60",
"border-l-cyan-400/60",
"border-l-orange-400/60",
"border-l-teal-400/60",
];
function roleBorderColor(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = (hash * 31 + name.charCodeAt(i)) | 0;
}
return ROLE_BORDER_COLORS[Math.abs(hash) % ROLE_BORDER_COLORS.length];
}
function versionCount(detail: WorkflowDetailData): number { function versionCount(detail: WorkflowDetailData): number {
return detail.history.length + 1; return detail.history.length + 1;
} }
// ── Schema rendering helpers ────────────────────────────────────────
type SchemaRow = { type SchemaRow = {
key: string; key: string;
name: string; name: string;
@@ -42,7 +75,6 @@ function flattenSchema(
): SchemaRow[] { ): SchemaRow[] {
const rows: SchemaRow[] = []; const rows: SchemaRow[] = [];
// Handle oneOf / discriminatedUnion
const oneOf = schema.oneOf as Array<Record<string, unknown>> | undefined; const oneOf = schema.oneOf as Array<Record<string, unknown>> | undefined;
if (Array.isArray(oneOf) && oneOf.length > 0) { if (Array.isArray(oneOf) && oneOf.length > 0) {
for (let vi = 0; vi < oneOf.length; vi++) { for (let vi = 0; vi < oneOf.length; vi++) {
@@ -157,118 +189,88 @@ function flattenProperty(
return rows; return rows;
} }
// ── Components ──────────────────────────────────────────────────────
function RoleCard({ roleName, role }: { roleName: string; role: WorkflowRoleDescriptor }) { function RoleCard({ roleName, role }: { roleName: string; role: WorkflowRoleDescriptor }) {
const rows = flattenSchema(role.schema, 0, "", `${roleName}-`); const rows = flattenSchema(role.schema, 0, "", `${roleName}-`);
const [promptOpen, setPromptOpen] = useState(false);
return ( return (
<div <Card id={`role-${roleName}`} className={cn("p-4 border-l-4", roleBorderColor(roleName))}>
id={`role-${roleName}`} <h4 className="text-sm font-semibold font-mono mb-1 text-foreground flex items-center gap-1.5">
className="rounded-lg border p-4" <User className="h-3.5 w-3.5 text-muted-foreground" />
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<h4 className="text-sm font-semibold font-mono mb-1" style={{ color: "var(--color-text)" }}>
{roleName} {roleName}
</h4> </h4>
{role.description !== "" && ( {role.description !== "" && (
<p className="text-xs mb-3" style={{ color: "var(--color-text-muted)" }}> <p className="text-xs mb-3 text-muted-foreground">{role.description}</p>
{role.description}
</p>
)} )}
{role.systemPrompt !== "" && ( {role.systemPrompt !== "" && (
<details className="mb-3"> <Collapsible open={promptOpen} onOpenChange={setPromptOpen} className="mb-3">
<summary <CollapsibleTrigger asChild>
className="text-[10px] uppercase tracking-wider font-medium cursor-pointer select-none" <Button
style={{ color: "var(--color-text-muted)" }} variant="ghost"
> size="sm"
System Prompt className="gap-1 h-7 px-2 text-[10px] uppercase tracking-wider text-muted-foreground bg-muted/50 rounded-md transition-all duration-200"
</summary> >
<div <ChevronDown
className="mt-1 p-2 rounded overflow-y-auto text-xs" className={cn("h-3 w-3 transition-transform", promptOpen && "rotate-180")}
style={{ />
background: "var(--color-bg)", System Prompt
border: "1px solid var(--color-border)", </Button>
maxHeight: "300px", </CollapsibleTrigger>
}} <CollapsibleContent>
> <div className="mt-1 p-2 rounded-md overflow-y-auto text-xs bg-background border border-border max-h-[300px]">
<Markdown content={role.systemPrompt} /> <Markdown content={role.systemPrompt} />
</div> </div>
</details> </CollapsibleContent>
</Collapsible>
)} )}
{rows.length > 0 && ( {rows.length > 0 && (
<div> <div>
<p <p className="text-[10px] uppercase tracking-wider mb-1 font-medium text-muted-foreground">
className="text-[10px] uppercase tracking-wider mb-1 font-medium"
style={{ color: "var(--color-text-muted)" }}
>
Meta Schema Meta Schema
</p> </p>
<table className="w-full text-xs" style={{ borderCollapse: "collapse" }}> <Table>
<thead> <TableHeader>
<tr style={{ borderBottom: "1px solid var(--color-border)" }}> <TableRow>
<th <TableHead className="text-xs">Field</TableHead>
className="text-left py-1 pr-3 font-medium" <TableHead className="text-xs">Type</TableHead>
style={{ color: "var(--color-text-muted)" }} <TableHead className="text-xs">Description</TableHead>
> </TableRow>
Field </TableHeader>
</th> <TableBody>
<th
className="text-left py-1 pr-3 font-medium"
style={{ color: "var(--color-text-muted)" }}
>
Type
</th>
<th
className="text-left py-1 font-medium"
style={{ color: "var(--color-text-muted)" }}
>
Description
</th>
</tr>
</thead>
<tbody>
{rows.map((r) => ( {rows.map((r) => (
<tr <TableRow
key={r.key} key={r.key}
style={{ className={cn(r.isVariantHeader ? "border-b-0" : "", "even:bg-muted/30")}
borderBottom: r.isVariantHeader ? "none" : "1px solid var(--color-border)",
}}
> >
<td <TableCell
className="py-1 pr-3 font-mono whitespace-pre" className={cn(
style={{ "font-mono whitespace-pre text-xs",
color: r.isVariantHeader ? "var(--color-text-muted)" : "var(--color-accent)", r.isVariantHeader ? "italic text-muted-foreground" : "text-foreground",
fontStyle: r.isVariantHeader ? "italic" : "normal", )}
}}
> >
{r.name} {r.name}
</td> </TableCell>
<td className="py-1 pr-3 font-mono" style={{ color: "var(--color-text-muted)" }}> <TableCell className="font-mono text-xs text-muted-foreground">
{r.type} {r.type}
</td> </TableCell>
<td className="py-1" style={{ color: "var(--color-text)" }}> <TableCell className="text-xs">
{r.description || (r.isVariantHeader ? "" : "—")} {r.description || (r.isVariantHeader ? "" : "—")}
</td> </TableCell>
</tr> </TableRow>
))} ))}
</tbody> </TableBody>
</table> </Table>
</div> </div>
)} )}
{rows.length === 0 && Object.keys(role.schema).length > 0 && ( {rows.length === 0 && Object.keys(role.schema).length > 0 && (
<pre <pre className="text-[10px] font-mono p-2 rounded-md overflow-x-auto bg-background text-muted-foreground">
className="text-[10px] font-mono p-2 rounded overflow-x-auto"
style={{ background: "var(--color-bg)", color: "var(--color-text-muted)" }}
>
{JSON.stringify(role.schema, null, 2)} {JSON.stringify(role.schema, null, 2)}
</pre> </pre>
)} )}
</div> </Card>
); );
} }
// ── Main component ──────────────────────────────────────────────────
export function WorkflowDetail() { export function WorkflowDetail() {
const params = useParams(); const params = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -313,44 +315,52 @@ export function WorkflowDetail() {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<button <Button
type="button" variant="ghost"
className="gap-1.5 px-2 text-muted-foreground hover:text-foreground transition-all duration-200"
onClick={() => navigate(`/${client}/workflows`)} onClick={() => navigate(`/${client}/workflows`)}
className="text-sm hover:underline"
style={{ color: "var(--color-accent)" }}
> >
Back to workflows <ArrowLeft className="h-4 w-4" />
</button> Back to workflows
</Button>
</div> </div>
<h2 className="text-xl font-semibold mb-4 font-mono">{workflowName}</h2> <h2 className="text-xl font-semibold mb-4 font-mono tracking-tight">{workflowName}</h2>
{status === "loading" && <p style={{ color: "var(--color-text-muted)" }}>Loading...</p>} {status === "loading" && (
{status === "error" && <p style={{ color: "var(--color-error)" }}>Error: {error}</p>} <div className="flex items-center justify-center gap-2 py-12 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Loading workflow...</span>
</div>
)}
{status === "error" && (
<div className="flex items-center justify-center gap-2 py-12 text-destructive">
<AlertCircle className="h-5 w-5" />
<span>Error: {error}</span>
</div>
)}
{detail !== null && ( {detail !== null && (
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 160px)" }}> <div className="flex gap-4" style={{ minHeight: "calc(100vh - 160px)" }}>
{/* Left: fixed graph sidebar */}
{hasGraph && ( {hasGraph && (
<div <ResizablePanel
className="shrink-0" defaultWidth={360}
minWidth={240}
maxWidth={560}
className={null}
style={{ style={{
width: 280,
position: "sticky", position: "sticky",
top: 16, top: 16,
height: "calc(100vh - 160px)", height: "calc(100vh - 160px)",
alignSelf: "flex-start", alignSelf: "flex-start",
}} }}
> >
<div <Card className="h-full flex flex-col overflow-hidden">
className="rounded-lg border h-full flex flex-col overflow-hidden" <div className="flex items-center justify-between px-3 py-2 text-xs text-muted-foreground bg-muted/50">
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }} <span className="font-mono flex items-center gap-1.5">
> <Layers className="h-3.5 w-3.5" />
<div Workflow graph
className="flex items-center justify-between px-3 py-2 text-xs" </span>
style={{ color: "var(--color-text-muted)" }}
>
<span className="font-mono">Workflow graph</span>
<span> <span>
{edgeCount} edge{edgeCount === 1 ? "" : "s"} {edgeCount} edge{edgeCount === 1 ? "" : "s"}
</span> </span>
@@ -363,52 +373,44 @@ export function WorkflowDetail() {
onNodeClick={handleGraphNodeClick} onNodeClick={handleGraphNodeClick}
/> />
</div> </div>
</div> </Card>
</div> </ResizablePanel>
)} )}
{/* Right: scrollable content */}
<div className="flex-1 min-w-0 space-y-4"> <div className="flex-1 min-w-0 space-y-4">
{/* Workflow overview */} <Card className="p-4">
<div <div className="rounded-md bg-muted/30 px-3 py-2 mb-3">
className="rounded-lg border p-4" <p className="text-sm whitespace-pre-wrap text-foreground">
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }} {descriptor !== null && descriptor.description !== ""
> ? descriptor.description
<p : "—"}
className="text-sm whitespace-pre-wrap mb-3" </p>
style={{ color: "var(--color-text)" }} </div>
> <div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
{descriptor !== null && descriptor.description !== "" <span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs font-mono">
? descriptor.description <Hash className="h-3 w-3" />
: "—"} <span className="text-foreground">{detail.hash}</span>
</p>
<div className="flex gap-4 text-xs" style={{ color: "var(--color-text-muted)" }}>
<span>
Hash:{" "}
<code className="font-mono" style={{ color: "var(--color-accent)" }}>
{detail.hash}
</code>
</span> </span>
<span> <span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs">
<GitBranch className="h-3 w-3" />
{versionCount(detail)} version{versionCount(detail) !== 1 ? "s" : ""} {versionCount(detail)} version{versionCount(detail) !== 1 ? "s" : ""}
</span> </span>
{roleEntries.length > 0 && ( {roleEntries.length > 0 && (
<span> <span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs">
<User className="h-3 w-3" />
{roleEntries.length} role{roleEntries.length !== 1 ? "s" : ""} {roleEntries.length} role{roleEntries.length !== 1 ? "s" : ""}
</span> </span>
)} )}
</div> </div>
</div> </Card>
{/* Role cards */}
{roleEntries.map(([name, role]) => ( {roleEntries.map(([name, role]) => (
<div <div
key={name} key={name}
style={{ className={cn(
transition: "box-shadow 0.3s", "rounded-lg transition-shadow duration-300",
boxShadow: highlightedRole === name ? "0 0 0 2px var(--color-accent)" : "none", highlightedRole === name && "ring-2 ring-ring",
borderRadius: 8, )}
}}
> >
<RoleCard roleName={name} role={role} /> <RoleCard roleName={name} role={role} />
</div> </div>
@@ -91,7 +91,7 @@ export function ConditionEdge(props: EdgeProps) {
defaultLabelY = result[2]; defaultLabelY = result[2];
} }
const stroke = "var(--color-accent)"; const stroke = "hsl(var(--ring))";
const label = isFallback ? "" : (edgeData?.condition ?? ""); const label = isFallback ? "" : (edgeData?.condition ?? "");
// Use pre-computed label position if available, otherwise fall back to default // Use pre-computed label position if available, otherwise fall back to default
@@ -107,9 +107,9 @@ export function ConditionEdge(props: EdgeProps) {
className="absolute px-1.5 py-0.5 rounded text-[10px] font-mono pointer-events-auto" className="absolute px-1.5 py-0.5 rounded text-[10px] font-mono pointer-events-auto"
style={{ style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`, transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
background: "var(--color-surface)", background: "hsl(var(--card))",
border: "1px solid var(--color-border)", border: "1px solid hsl(var(--border))",
color: "var(--color-text)", color: "hsl(var(--foreground))",
whiteSpace: "nowrap", whiteSpace: "nowrap",
zIndex: 10, zIndex: 10,
}} }}
@@ -1,29 +1,23 @@
import { Handle, type NodeProps, Position } from "@xyflow/react"; import { Handle, type NodeProps, Position } from "@xyflow/react";
import { Check, Circle } from "lucide-react";
import type { RoleNodeData } from "./types.ts"; import type { RoleNodeData } from "./types.ts";
function borderColor(state: RoleNodeData["state"]): string { function borderColor(state: RoleNodeData["state"]): string {
switch (state) { switch (state) {
case "completed": case "completed":
return "var(--color-success)"; return "hsl(var(--success))";
case "active": case "active":
return "var(--color-accent)"; return "hsl(var(--ring))";
default: default:
return "var(--color-border)"; return "hsl(var(--border))";
} }
} }
function stateIcon(state: RoleNodeData["state"]): string | null {
if (state === "completed") return "✓";
if (state === "active") return "●";
return null;
}
export function RoleNode(props: NodeProps) { export function RoleNode(props: NodeProps) {
const data = props.data as RoleNodeData; const data = props.data as RoleNodeData;
const icon = stateIcon(data.state);
const isActive = data.state === "active"; const isActive = data.state === "active";
const handleStyle = { const handleStyle = {
background: "var(--color-text-muted)", background: "hsl(var(--muted-foreground))",
width: 6, width: 6,
height: 6, height: 6,
border: "none", border: "none",
@@ -35,9 +29,9 @@ export function RoleNode(props: NodeProps) {
style={{ style={{
width: 180, width: 180,
height: 60, height: 60,
background: "var(--color-surface)", background: "hsl(var(--card))",
borderColor: borderColor(data.state), borderColor: borderColor(data.state),
color: "var(--color-text)", color: "hsl(var(--foreground))",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
justifyContent: "center", justifyContent: "center",
@@ -81,19 +75,15 @@ export function RoleNode(props: NodeProps) {
isConnectable={false} isConnectable={false}
/> />
<div className="flex items-center gap-1.5 font-mono"> <div className="flex items-center gap-1.5 font-mono">
{icon !== null && ( {data.state === "completed" && <Check className="h-3 w-3 text-success" />}
<span {data.state === "active" && <Circle className="h-3 w-3 fill-current text-ring" />}
style={{
color: data.state === "active" ? "var(--color-accent)" : "var(--color-success)",
}}
>
{icon}
</span>
)}
<span className="truncate">{data.label}</span> <span className="truncate">{data.label}</span>
</div> </div>
{data.description !== "" && ( {data.description !== "" && (
<div className="text-[10px] truncate mt-0.5" style={{ color: "var(--color-text-muted)" }}> <div
className="text-[10px] truncate mt-0.5"
style={{ color: "hsl(var(--muted-foreground))" }}
>
{data.description} {data.description}
</div> </div>
)} )}
@@ -1,21 +1,22 @@
import { Handle, type NodeProps, Position } from "@xyflow/react"; import { Handle, type NodeProps, Position } from "@xyflow/react";
import { Play, Square } from "lucide-react";
import type { TerminalNodeData } from "./types.ts"; import type { TerminalNodeData } from "./types.ts";
function borderColor(state: TerminalNodeData["state"]): string { function borderColor(state: TerminalNodeData["state"]): string {
switch (state) { switch (state) {
case "completed": case "completed":
return "var(--color-success)"; return "hsl(var(--success))";
case "active": case "active":
return "var(--color-accent)"; return "hsl(var(--ring))";
default: default:
return "var(--color-border)"; return "hsl(var(--border))";
} }
} }
function bgColor(state: TerminalNodeData["state"]): string { function bgColor(state: TerminalNodeData["state"]): string {
if (state === "completed") return "var(--color-success)"; if (state === "completed") return "hsl(var(--success))";
if (state === "active") return "var(--color-accent)"; if (state === "active") return "hsl(var(--ring))";
return "var(--color-surface)"; return "hsl(var(--card))";
} }
export function TerminalNode(props: NodeProps) { export function TerminalNode(props: NodeProps) {
@@ -23,7 +24,7 @@ export function TerminalNode(props: NodeProps) {
const isStart = data.kind === "start"; const isStart = data.kind === "start";
const isActive = data.state === "active"; const isActive = data.state === "active";
const handleStyle = { const handleStyle = {
background: "var(--color-text-muted)", background: "hsl(var(--muted-foreground))",
width: 6, width: 6,
height: 6, height: 6,
border: "none", border: "none",
@@ -31,13 +32,16 @@ export function TerminalNode(props: NodeProps) {
return ( return (
<div <div
className={`rounded-full border-2 flex items-center justify-center text-[10px] font-bold ${isActive ? "wf-node-pulse" : ""} ${data.state !== "default" ? "cursor-pointer" : ""}`} className={`rounded-full border-2 flex items-center justify-center ${isActive ? "wf-node-pulse" : ""} ${data.state !== "default" ? "cursor-pointer" : ""}`}
style={{ style={{
width: 40, width: 40,
height: 40, height: 40,
background: bgColor(data.state), background: bgColor(data.state),
borderColor: borderColor(data.state), borderColor: borderColor(data.state),
color: data.state === "default" ? "var(--color-text-muted)" : "var(--color-bg)", color:
data.state === "default"
? "hsl(var(--muted-foreground))"
: "hsl(var(--primary-foreground))",
}} }}
title={isStart ? "Start" : "End"} title={isStart ? "Start" : "End"}
> >
@@ -74,7 +78,7 @@ export function TerminalNode(props: NodeProps) {
/> />
</> </>
)} )}
{isStart ? "▶" : "■"} {isStart ? <Play className="h-3 w-3" /> : <Square className="h-3 w-3" />}
</div> </div>
); );
} }
@@ -3,13 +3,14 @@ import {
type EdgeTypes, type EdgeTypes,
MarkerType, MarkerType,
type Node, type Node,
type NodeMouseHandler,
type NodeTypes, type NodeTypes,
type OnNodeClick,
ReactFlow, ReactFlow,
} from "@xyflow/react"; } from "@xyflow/react";
import "@xyflow/react/dist/style.css"; import "@xyflow/react/dist/style.css";
import { useMemo } from "react"; import { useMemo } from "react";
import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts"; import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts";
import { useTheme } from "../../hooks/use-theme.tsx";
import { ConditionEdge } from "./condition-edge.tsx"; import { ConditionEdge } from "./condition-edge.tsx";
import { RoleNode } from "./role-node.tsx"; import { RoleNode } from "./role-node.tsx";
import { TerminalNode } from "./terminal-node.tsx"; import { TerminalNode } from "./terminal-node.tsx";
@@ -39,9 +40,12 @@ function handleNodeClick(onNodeClick: (nodeId: string) => void, node: Node): voi
export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props) { export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props) {
const layout = useLayout({ edges: graph.edges, roles, nodeStates }); const layout = useLayout({ edges: graph.edges, roles, nodeStates });
const { theme } = useTheme();
const onNodeClickHandler: OnNodeClick | undefined = const onNodeClickHandler: NodeMouseHandler | undefined =
onNodeClick !== null ? (_e, node) => handleNodeClick(onNodeClick, node) : undefined; onNodeClick !== null
? (_e: React.MouseEvent, node: Node) => handleNodeClick(onNodeClick, node)
: undefined;
const styledEdges = useMemo( const styledEdges = useMemo(
() => () =>
@@ -51,7 +55,7 @@ export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props)
type: MarkerType.ArrowClosed, type: MarkerType.ArrowClosed,
width: 14, width: 14,
height: 14, height: 14,
color: "var(--color-text)", color: "hsl(var(--foreground))",
}, },
})), })),
[layout.edges], [layout.edges],
@@ -72,10 +76,10 @@ export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props)
nodesConnectable={false} nodesConnectable={false}
elementsSelectable={false} elementsSelectable={false}
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
colorMode="dark" colorMode={theme}
style={{ background: "var(--color-bg)" }} style={{ background: "hsl(var(--background))" }}
> >
<Background color="var(--color-border)" gap={20} size={1} /> <Background color="hsl(var(--border))" gap={20} size={1} />
</ReactFlow> </ReactFlow>
); );
} }
@@ -1,6 +1,8 @@
import { AlertCircle, Clock, Hash, Loader2, Package } from "lucide-react";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { listWorkflows } from "../api.ts"; import { listWorkflows } from "../api.ts";
import { useFetch } from "../hooks.ts"; import { useFetch } from "../hooks.ts";
import { Card } from "./ui/card.tsx";
export function WorkflowList() { export function WorkflowList() {
const params = useParams(); const params = useParams();
@@ -9,45 +11,55 @@ export function WorkflowList() {
const { status, data, error } = useFetch(() => listWorkflows(client), [client]); const { status, data, error } = useFetch(() => listWorkflows(client), [client]);
if (status === "loading") if (status === "loading")
return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>; return (
if (status === "error") return <p style={{ color: "var(--color-error)" }}>Error: {error}</p>; <div className="flex flex-col items-center justify-center py-16 gap-3">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Loading workflows...</p>
</div>
);
if (status === "error")
return (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<AlertCircle className="h-5 w-5 text-destructive" />
<p className="text-sm text-destructive">Error: {error}</p>
</div>
);
const workflows = data.workflows; const workflows = data.workflows;
return ( return (
<div> <div>
<h2 className="text-xl font-semibold mb-4">Workflows</h2> <h2 className="text-xl font-semibold tracking-tight mb-4">Workflows</h2>
{workflows.length === 0 ? ( {workflows.length === 0 ? (
<p style={{ color: "var(--color-text-muted)" }}>No workflows registered.</p> <div className="flex flex-col items-center justify-center py-16 gap-3">
<Package className="h-12 w-12 text-muted-foreground/50" />
<p className="text-sm font-medium">No workflows</p>
<p className="text-xs text-muted-foreground">Register a workflow to get started.</p>
</div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{workflows.map((w) => ( {workflows.map((w) => (
<button <Card
key={w.name} key={w.name}
type="button" className="p-4 cursor-pointer hover:bg-accent/50 hover:shadow-sm transition-all duration-200"
onClick={() => navigate(`/${client}/workflows/${w.name}`)} onClick={() => navigate(`/${client}/workflows/${w.name}`)}
className="w-full text-left p-4 rounded-lg border hover:opacity-90"
style={{
background: "var(--color-surface)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
> >
<div className="flex items-center gap-2"> <span className="text-sm font-medium text-foreground flex items-center gap-1.5">
<span className="font-medium">{w.name}</span> <Package className="h-3.5 w-3.5 text-muted-foreground" />
</div> {w.name}
<code </span>
className="text-xs mt-1 block font-mono truncate" <code className="text-xs mt-1 font-mono text-muted-foreground flex items-center gap-1.5">
style={{ color: "var(--color-accent)" }} <Hash className="h-3 w-3" />
>
{w.hash !== null ? w.hash : "—"} {w.hash !== null ? w.hash : "—"}
</code> </code>
{w.timestamp !== null ? ( {w.timestamp !== null ? (
<span className="text-xs mt-1 block" style={{ color: "var(--color-text-muted)" }}> <span className="text-xs mt-1 text-muted-foreground flex items-center gap-1.5">
<Clock className="h-3 w-3" />
Updated {new Date(w.timestamp).toLocaleString()} Updated {new Date(w.timestamp).toLocaleString()}
</span> </span>
) : null} ) : null}
</button> </Card>
))} ))}
</div> </div>
)} )}
@@ -0,0 +1,74 @@
import { createContext, useCallback, useContext, useEffect, useState } from "react";
export type Theme = "light" | "dark";
type ThemeContextValue = {
theme: Theme;
setTheme: (t: Theme) => void;
toggleTheme: () => void;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
function getStoredTheme(): Theme | null {
const stored = localStorage.getItem("theme");
if (stored === "light" || stored === "dark") return stored;
return null;
}
function getSystemTheme(): Theme {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function applyTheme(theme: Theme): void {
if (theme === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => getStoredTheme() ?? getSystemTheme());
useEffect(() => {
applyTheme(theme);
}, [theme]);
useEffect(() => {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
function handler() {
if (getStoredTheme() === null) {
const sys = getSystemTheme();
setThemeState(sys);
applyTheme(sys);
}
}
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const setTheme = useCallback((t: Theme) => {
localStorage.setItem("theme", t);
setThemeState(t);
}, []);
const toggleTheme = useCallback(() => {
setThemeState((prev) => {
const next = prev === "dark" ? "light" : "dark";
localStorage.setItem("theme", next);
applyTheme(next);
return next;
});
}, []);
return <ThemeContext value={{ theme, setTheme, toggleTheme }}>{children}</ThemeContext>;
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (ctx === null) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return ctx;
}
+92 -17
View File
@@ -1,32 +1,107 @@
@import "tailwindcss"; @import "tailwindcss";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius: 0.625rem;
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-success: hsl(var(--success));
--color-success-foreground: hsl(var(--success-foreground));
--color-warning: hsl(var(--warning));
--color-warning-foreground: hsl(var(--warning-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-sidebar: hsl(var(--sidebar));
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
}
:root { :root {
--color-bg: #0a0a0f; --radius: 0.625rem;
--color-surface: #12121a; --background: 0 0% 100%;
--color-border: #1e1e2e; --foreground: 240 10% 3.9%;
--color-text: #e4e4ef; --card: 0 0% 100%;
--color-text-muted: #6b6b8a; --card-foreground: 240 10% 3.9%;
--color-accent: #7c6df0; --popover: 0 0% 100%;
--color-accent-dim: #5a4db8; --popover-foreground: 240 10% 3.9%;
--color-success: #34d399; --primary: 240 5.9% 10%;
--color-warning: #fbbf24; --primary-foreground: 0 0% 98%;
--color-error: #f87171; --secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--success: 160 60% 40%;
--success-foreground: 0 0% 98%;
--warning: 38 92% 50%;
--warning-foreground: 0 0% 0%;
--sidebar: 0 0% 98%;
--sidebar-foreground: 240 3.8% 46.1%;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 6% 6.5%;
--card-foreground: 0 0% 98%;
--popover: 240 6% 6.5%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--success: 160 60% 45%;
--success-foreground: 0 0% 98%;
--warning: 38 92% 50%;
--warning-foreground: 0 0% 0%;
--sidebar: 240 6% 6.5%;
--sidebar-foreground: 240 5% 64.9%;
} }
body { body {
margin: 0; margin: 0;
background: var(--color-bg); background: hsl(var(--background));
color: var(--color-text); color: hsl(var(--foreground));
font-family: "Inter", system-ui, -apple-system, sans-serif; font-family: "Inter", system-ui, -apple-system, sans-serif;
} }
@keyframes wf-node-pulse { @keyframes wf-node-pulse {
0%, 0%,
100% { 100% {
box-shadow: 0 0 0 0 rgba(124, 109, 240, 0.55); box-shadow: 0 0 0 0 hsl(var(--ring) / 0.55);
} }
50% { 50% {
box-shadow: 0 0 0 6px rgba(124, 109, 240, 0); box-shadow: 0 0 0 6px hsl(var(--ring) / 0);
} }
} }
@@ -36,13 +111,13 @@ body {
@keyframes wf-record-card-highlight { @keyframes wf-record-card-highlight {
0% { 0% {
border-color: var(--color-accent); border-color: hsl(var(--ring));
} }
35% { 35% {
border-color: var(--color-accent); border-color: hsl(var(--ring));
} }
100% { 100% {
border-color: var(--color-border); border-color: hsl(var(--border));
} }
} }
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
+4 -1
View File
@@ -1,6 +1,7 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { RouterProvider } from "react-router"; import { RouterProvider } from "react-router";
import { ThemeProvider } from "./hooks/use-theme.tsx";
import "./index.css"; import "./index.css";
import { router } from "./router.tsx"; import { router } from "./router.tsx";
@@ -8,7 +9,9 @@ const root = document.getElementById("root");
if (root) { if (root) {
createRoot(root).render( createRoot(root).render(
<StrictMode> <StrictMode>
<RouterProvider router={router} /> <ThemeProvider>
<RouterProvider router={router} />
</ThemeProvider>
</StrictMode>, </StrictMode>,
); );
} }
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+3 -1
View File
@@ -4,7 +4,9 @@
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"strict": true, "strict": true,
"types": ["vite/client"],
"jsx": "react-jsx", "jsx": "react-jsx",
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
@@ -13,5 +15,5 @@
"isolatedModules": true, "isolatedModules": true,
"noEmit": true "noEmit": true
}, },
"include": ["src"] "include": ["src", "plugins"]
} }
+6 -1
View File
@@ -1,10 +1,15 @@
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import { viteLimitLinePlugin } from "./plugins/vite-limit-line-plugin.js";
// biome-ignore lint/style/noDefaultExport: Vite loads config from default export. // biome-ignore lint/style/noDefaultExport: Vite loads config from default export.
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [
react(),
tailwindcss(),
...viteLimitLinePlugin({ maxReactFCLines: 300, maxFileLines: 600 }),
],
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {