Merge pull request 'jshang/optimize-dashboard-ui' (#308) from jshang/optimize-dashboard-ui into main

Reviewed-on: uncaged/workflow#308
This commit is contained in:
2026-05-18 08:45:46 +00:00
41 changed files with 2081 additions and 953 deletions
+8
View File
@@ -4,6 +4,14 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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>
<body>
<div id="root"></div>
+13 -1
View File
@@ -13,11 +13,23 @@
"preview": "vite preview"
},
"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",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-markdown": "^10.1.0",
"shiki": "^4.0.2"
"react-router": "^7.15.1",
"shiki": "^4.0.2",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.4",
@@ -0,0 +1,362 @@
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]*$/;
// --- AST types (Rolldown ESTree subset) ---
type Identifier = {
type: "Identifier";
name: string;
};
type MemberExpression = {
type: "MemberExpression";
object: AstExpression;
property: Identifier;
};
type CallExpression = {
type: "CallExpression";
callee: AstExpression;
arguments: Array<AstExpression>;
};
type AstExpression = Identifier | MemberExpression | CallExpression | {
type: string;
[key: string]: unknown;
};
type VariableDeclarator = {
id: Identifier | null;
init: AstExpression | null;
};
type AstStatement = {
type: string;
id: Identifier | null;
declaration: AstStatement | null;
declarations: Array<VariableDeclarator>;
body: Array<AstStatement>;
[key: string]: unknown;
};
type AstProgram = {
type: "Program";
body: Array<AstStatement>;
};
// --- AST helpers ---
function isFunctionLike(node: AstExpression): boolean {
return node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression";
}
const WRAPPER_NAMES = new Set(["memo", "forwardRef", "lazy"]);
function isWrapperCall(node: AstExpression): boolean {
if (node.type !== "CallExpression") return false;
const call = node as CallExpression;
const callee = call.callee;
if (callee.type === "Identifier") {
return WRAPPER_NAMES.has((callee as Identifier).name);
}
if (callee.type === "MemberExpression") {
const member = callee as MemberExpression;
return member.property.type === "Identifier" && WRAPPER_NAMES.has(member.property.name);
}
return false;
}
function extractComponentNames(ast: AstProgram): Array<string> {
const names: Array<string> = [];
for (const node of ast.body) {
if (node.type === "FunctionDeclaration" && node.id && PASCAL_CASE.test(node.id.name)) {
names.push(node.id.name);
continue;
}
if (node.type === "ExportNamedDeclaration" && node.declaration) {
const decl = node.declaration;
if (decl.type === "FunctionDeclaration" && decl.id && PASCAL_CASE.test(decl.id.name)) {
names.push(decl.id.name);
continue;
}
if (decl.type === "VariableDeclaration") {
collectNamesFromVarDeclaration(decl, names);
continue;
}
}
if (node.type === "VariableDeclaration") {
collectNamesFromVarDeclaration(node, names);
}
}
return names;
}
function collectNamesFromVarDeclaration(node: AstStatement, names: Array<string>): 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)) {
names.push(declarator.id.name);
} else if (isWrapperCall(init)) {
const args = (init as CallExpression).arguments;
if (args.length > 0 && isFunctionLike(args[0])) {
names.push(declarator.id.name);
}
}
}
}
// --- Source measurement ---
function measureComponentInSource(name: string, lines: Array<string>): ComponentInfo | null {
const fnPattern = new RegExp(`^(?:export\\s+)?function\\s+${name}\\s*[(<]`);
const varPattern = new RegExp(`^(?:export\\s+)?const\\s+${name}\\s*[=:]`);
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trimStart();
const isFnDecl = fnPattern.test(trimmed);
const isVarDecl = varPattern.test(trimmed);
if (!isFnDecl && !isVarDecl) continue;
if (isFnDecl) {
const result = measureFromParams(i, lines);
if (result) return { ...result, name };
return null;
}
const result = measureFromArrow(i, lines);
if (result) return { ...result, name };
return null;
}
return null;
}
// function Foo(...) { ... } — skip params via parens, then brace-match the body
function measureFromParams(startLine: number, lines: Array<string>): ComponentInfo | null {
let parenDepth = 0;
let pastParams = false;
let braceDepth = 0;
for (let j = startLine; j < lines.length; j++) {
for (const ch of lines[j]) {
if (!pastParams) {
if (ch === "(") parenDepth++;
else if (ch === ")") {
parenDepth--;
if (parenDepth === 0) pastParams = true;
}
} else {
if (ch === "{") braceDepth++;
else if (ch === "}") {
braceDepth--;
if (braceDepth === 0) {
return { name: "", startLine: startLine + 1, lineCount: j - startLine + 1 };
}
}
}
}
}
return null;
}
// const Foo = (...) => { ... } / const Foo = memo((...) => { ... })
// Find `=>` first, then brace-match from there to skip type annotations in params
function measureFromArrow(startLine: number, lines: Array<string>): ComponentInfo | null {
let arrowFound = false;
let braceDepth = 0;
let foundBrace = false;
for (let j = startLine; j < lines.length; j++) {
const line = lines[j];
for (let c = 0; c < line.length; c++) {
if (!arrowFound) {
if (line[c] === "=" && line[c + 1] === ">") {
arrowFound = true;
c++;
}
continue;
}
if (line[c] === "{") {
braceDepth++;
foundBrace = true;
} else if (line[c] === "}") {
braceDepth--;
if (foundBrace && braceDepth === 0) {
return { name: "", startLine: startLine + 1, lineCount: j - startLine + 1 };
}
}
}
}
return null;
}
// --- Config resolution ---
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));
}
// --- Plugin ---
function viteLimitLinePlugin(
userOptions: Partial<LimitLineOptions> = {},
): Array<Plugin> {
const options: LimitLineOptions = { ...DEFAULT_OPTIONS, ...userOptions, overrides: userOptions.overrides ?? [] };
const resolve = createLimitResolver(options);
const rawCodeCache = new Map<string, string>();
return [
{
name: "vite-plugin-limit-line:pre",
enforce: "pre",
transform(code, id) {
if (!shouldProcess(id, options)) return null;
rawCodeCache.set(id, code);
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-limit-line:fc",
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 AstProgram;
const componentNames = extractComponentNames(ast);
if (componentNames.length === 0) return null;
const raw = rawCodeCache.get(id) ?? code;
rawCodeCache.delete(id);
const rawLines = raw.split("\n");
const maxFCLines = limits.maxReactFCLines;
const violations: Array<ComponentInfo> = [];
for (const name of componentNames) {
const info = measureComponentInSource(name, rawLines);
if (info && info.lineCount > maxFCLines) {
violations.push(info);
}
}
if (violations.length > 0) {
const details = violations
.map(
(v) =>
` ${v.name} (line ${v.startLine}): ${v.lineCount} lines (limit: ${maxFCLines})`,
)
.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;
},
buildEnd() {
rawCodeCache.clear();
},
},
];
}
export { viteLimitLinePlugin };
export type { LimitLineOptions, LimitLineOverride };
+12 -49
View File
@@ -1,75 +1,38 @@
import { useState } from "react";
import { Navigate, Outlet, useParams } from "react-router";
import { clearApiKey, hasApiKey } from "./api.ts";
import { LoginPage } from "./components/login.tsx";
import { RunDialog } from "./components/run-dialog.tsx";
import { Sidebar } from "./components/sidebar.tsx";
import { StatusBar } from "./components/status-bar.tsx";
import { ThreadDetail } from "./components/thread-detail.tsx";
import { ThreadList } from "./components/thread-list.tsx";
import { WorkflowDetail } from "./components/workflow-detail.tsx";
import { WorkflowList } from "./components/workflow-list.tsx";
import { useHashRoute } from "./use-hash-route.ts";
import { useTheme } from "./hooks/use-theme.tsx";
export function App() {
export function Layout() {
const [authed, setAuthed] = useState(hasApiKey());
const { view, client, threadId, workflowName, setView, setClient, setThreadId, setWorkflowName } =
useHashRoute();
const { client } = useParams();
const [showRun, setShowRun] = useState(false);
const { theme, toggleTheme } = useTheme();
if (!authed) {
return <LoginPage onLogin={() => setAuthed(true)} />;
return <Navigate to="/login" replace />;
}
return (
<div className="flex h-screen">
<div className="flex h-screen bg-background">
<Sidebar
view={view}
client={client}
onViewChange={setView}
onClientChange={setClient}
onLogout={() => {
clearApiKey();
setAuthed(false);
}}
theme={theme}
onToggleTheme={toggleTheme}
/>
<main className="flex-1 overflow-hidden flex flex-col">
<StatusBar client={client} onRun={() => setShowRun(true)} />
<StatusBar client={client ?? null} onRun={() => setShowRun(true)} />
<div className="flex-1 overflow-auto p-6">
{!client && (
<div className="flex items-center justify-center h-full">
<p style={{ color: "var(--color-text-muted)" }}>
Select an client from the sidebar to get started.
</p>
</div>
)}
{client && view === "threads" && threadId === null && (
<ThreadList client={client} onSelect={setThreadId} />
)}
{client && view === "threads" && threadId !== null && (
<ThreadDetail client={client} threadId={threadId} onBack={() => setThreadId(null)} />
)}
{client && view === "workflows" && workflowName === null && (
<WorkflowList client={client} onSelect={setWorkflowName} />
)}
{client && view === "workflows" && workflowName !== null && (
<WorkflowDetail
client={client}
workflowName={workflowName}
onBack={() => setWorkflowName(null)}
/>
)}
<Outlet />
</div>
</main>
{showRun && client && (
<RunDialog
client={client}
onClose={() => setShowRun(false)}
onCreated={(id) => {
setShowRun(false);
setThreadId(id);
}}
/>
)}
{client && <RunDialog client={client} open={showRun} onOpenChange={setShowRun} />}
</div>
);
}
@@ -0,0 +1,31 @@
import { Loader2, Users } from "lucide-react";
import { Navigate } from "react-router";
import { listClients } from "../api.ts";
import { useFetch } from "../hooks.ts";
export function ClientRedirect() {
const { status, data } = useFetch(() => listClients(), []);
if (status === "loading") {
return (
<div className="flex flex-col items-center justify-center h-full gap-3">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Loading clients...</p>
</div>
);
}
if (status === "ok" && data.length > 0) {
return <Navigate to={`/${data[0].name}/threads`} replace />;
}
return (
<div className="flex flex-col items-center justify-center h-full gap-3">
<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.
</p>
</div>
);
}
@@ -1,14 +1,18 @@
import { AlertCircle, Loader2, Moon, Settings, Sun } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
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";
type Props = {
onLogin: () => void;
};
export function LoginPage({ onLogin }: Props) {
export function LoginPage() {
const navigate = useNavigate();
const [key, setKey] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const { theme, toggleTheme } = useTheme();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -17,7 +21,6 @@ export function LoginPage({ onLogin }: Props) {
setLoading(true);
setError(null);
// Test the key by hitting the endpoints list
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || "";
try {
const res = await fetch(`${gatewayUrl}/api/gateway/endpoints`, {
@@ -40,56 +43,59 @@ export function LoginPage({ onLogin }: Props) {
}
setApiKey(key.trim());
onLogin();
navigate("/", { replace: true });
}
return (
<div
className="min-h-screen flex items-center justify-center"
style={{ background: "var(--color-bg)" }}
>
<div
className="p-8 rounded-lg border w-full max-w-sm"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
<div className="min-h-screen flex items-center justify-center bg-background relative">
<Button
variant="ghost"
size="icon"
className="absolute top-4 right-4 transition-colors duration-200"
onClick={toggleTheme}
>
<h1 className="text-xl font-bold mb-1" style={{ color: "var(--color-accent)" }}>
Workflow Dashboard
</h1>
<p className="text-sm mb-6" style={{ color: "var(--color-text-muted)" }}>
Enter your API key to continue
</p>
<form onSubmit={handleSubmit}>
<input
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="API Key"
className="w-full px-3 py-2 rounded border text-sm mb-3 outline-none"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
/>
{error && (
<p className="text-xs mb-3" style={{ color: "var(--color-error)" }}>
{error}
</p>
)}
<button
type="submit"
disabled={loading || !key.trim()}
className="w-full px-3 py-2 rounded text-sm font-medium"
style={{
background: "var(--color-accent)",
color: "var(--color-bg)",
opacity: loading || !key.trim() ? 0.5 : 1,
}}
>
{loading ? "Verifying..." : "Login"}
</button>
</form>
</div>
{theme === "dark" ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</Button>
<Card className="w-full max-w-sm shadow-lg transition-all duration-200 hover:shadow-xl hover:border-primary/30">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl tracking-tight">
<Settings className="h-5 w-5" />
Workflow Dashboard
</CardTitle>
<CardDescription>Enter your API key to continue</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="API Key"
className="transition-all duration-200"
/>
{error && (
<p className="text-xs text-destructive flex items-center gap-1.5">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
{error}
</p>
)}
<Button
type="submit"
disabled={loading || !key.trim()}
className="w-full transition-all duration-200"
>
{loading ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Verifying
</span>
) : (
"Login"
)}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
@@ -52,19 +52,23 @@ function CodeBlock({ className, children }: { className?: string; children?: Rea
if (html !== null) {
return (
<div
className="rounded overflow-x-auto text-xs my-2"
// biome-ignore lint/security/noDangerouslySetInnerHtml: shiki output is safe
dangerouslySetInnerHTML={{ __html: html }}
/>
<div className="relative rounded-lg border border-border overflow-hidden my-3">
{lang !== "text" && (
<span className="absolute top-2 right-2 text-[10px] uppercase tracking-wider text-muted-foreground/70 font-mono">
{lang}
</span>
)}
<div
className="overflow-x-auto text-xs"
// biome-ignore lint/security/noDangerouslySetInnerHtml: shiki output is safe
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
);
}
return (
<pre
className="rounded overflow-x-auto text-xs my-2 p-3"
style={{ background: "var(--color-bg)" }}
>
<pre className="rounded-lg overflow-x-auto text-xs my-3 p-3 bg-muted/50 border border-border">
<code>{code}</code>
</pre>
);
@@ -80,8 +84,7 @@ export function Markdown({ content }: { content: string }) {
if (isInline) {
return (
<code
className="text-xs px-1 py-0.5 rounded"
style={{ background: "var(--color-border)", color: "var(--color-accent)" }}
className="bg-muted rounded px-1.5 py-0.5 text-[13px] font-mono text-foreground"
{...props}
>
{children}
@@ -91,7 +94,7 @@ export function Markdown({ content }: { content: string }) {
return <CodeBlock className={className}>{children}</CodeBlock>;
},
p({ children }) {
return <p className="my-1.5 leading-relaxed">{children}</p>;
return <p className="my-2 leading-relaxed">{children}</p>;
},
ul({ children }) {
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>;
},
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 }) {
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 }) {
return <h3 className="text-sm font-bold mt-2 mb-1">{children}</h3>;
},
blockquote({ children }) {
return (
<blockquote
className="border-l-2 pl-3 my-2 text-sm"
style={{ borderColor: "var(--color-accent)", color: "var(--color-text-muted)" }}
>
<blockquote className="border-l-2 border-ring pl-3 my-2 text-sm text-muted-foreground bg-muted/30 rounded-r-md py-2">
{children}
</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 { cn } from "../lib/utils.ts";
import { Markdown } from "./markdown.tsx";
import { Badge } from "./ui/badge.tsx";
import { Card } from "./ui/card.tsx";
const ROLE_COLORS: Record<string, string> = {
preparer: "#8b5cf6",
client: "#3b82f6",
extractor: "#f59e0b",
};
const ROLE_HUES = [262, 210, 35, 150, 330, 180, 15, 280, 55, 195, 345, 120, 240, 75, 305];
function roleColor(role: string): string {
return ROLE_COLORS[role] ?? "var(--color-accent)";
function roleHue(role: string): number {
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 {
@@ -18,99 +30,86 @@ function formatTime(ts: number | null): string | null {
function StartCard({ record }: { record: ThreadStartRecord }) {
return (
<div
className="p-4 rounded-lg border"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<Card className="p-4 transition-all duration-200 overflow-hidden relative">
<div className="absolute inset-x-0 top-0 h-0.5 bg-gradient-to-r from-primary/80 via-primary/40 to-transparent" />
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">🚀</span>
<span className="font-semibold" style={{ color: "var(--color-accent)" }}>
{record.workflow}
</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)",
}}
>
<Rocket className="h-5 w-5 text-primary" />
<span className="font-semibold text-foreground">{record.workflow}</span>
<Badge variant={record.status === "active" ? "success" : "secondary"}>
{record.status}
</span>
</Badge>
</div>
{record.prompt !== null && (
<div
className="mt-2 p-3 rounded text-sm border-l-2"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-accent)",
color: "var(--color-text)",
}}
>
<div className="text-xs mb-1" style={{ color: "var(--color-text-muted)" }}>
<div className="mt-2 p-3 rounded-md text-sm border-l-2 border-ring bg-muted/50">
<div className="text-xs mb-1 text-muted-foreground flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
Prompt
</div>
<Markdown content={record.prompt} />
</div>
)}
</div>
</Card>
);
}
function RoleMessage({ record, highlighted }: { record: RoleRecord; highlighted: boolean }) {
const color = roleColor(record.role);
const style = roleBadgeStyle(record.role);
return (
<div
className={`p-3 rounded-lg border text-sm ${highlighted ? "wf-record-card-highlight" : ""}`}
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
<Card
className={cn(
"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">
<span
className="text-xs px-2 py-0.5 rounded font-mono font-medium"
style={{ background: color, color: "#fff" }}
className="text-xs px-2 py-0.5 rounded font-mono font-medium text-white shadow-sm inline-flex items-center gap-1"
style={{ backgroundColor: style.backgroundColor }}
>
<User className="h-3 w-3" />
{record.role}
</span>
{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)}
</span>
)}
</div>
<Markdown content={record.content} />
</div>
</Card>
);
}
function ResultCard({ record }: { record: WorkflowResultRecord }) {
const success = record.returnCode === 0;
return (
<div
className="p-4 rounded-lg border"
style={{
background: "var(--color-surface)",
borderColor: success ? "var(--color-success)" : "var(--color-error)",
}}
<Card
className={cn(
"p-4 transition-all duration-200 border-l-4",
success ? "border-l-success" : "border-l-destructive",
)}
>
<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="text-xs px-2 py-0.5 rounded font-mono"
style={{
background: success ? "var(--color-success)" : "var(--color-error)",
color: "#fff",
}}
>
<Badge variant="outline" className="font-mono">
exit {record.returnCode}
</span>
</Badge>
{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)}
</span>
)}
</div>
<Markdown content={record.content} />
</div>
</Card>
);
}
@@ -1,14 +1,27 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import { listWorkflows, runThread } from "../api.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 = {
client: string;
onClose: () => void;
onCreated: (threadId: string) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
};
export function RunDialog({ client, onClose, onCreated }: Props) {
export function RunDialog({ client, open, onOpenChange }: Props) {
const navigate = useNavigate();
const workflows = useFetch(() => listWorkflows(client), [client]);
const [workflow, setWorkflow] = useState("");
const [prompt, setPrompt] = useState("");
@@ -22,7 +35,8 @@ export function RunDialog({ client, onClose, onCreated }: Props) {
setError(null);
try {
const result = await runThread(client, workflow, prompt);
onCreated(result.threadId);
onOpenChange(false);
navigate(`/${client}/threads/${result.threadId}`);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setSubmitting(false);
@@ -30,95 +44,54 @@ export function RunDialog({ client, onClose, onCreated }: Props) {
}
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ background: "rgba(0,0,0,0.6)" }}
>
<div
className="w-full max-w-lg p-6 rounded-lg border"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<h3 className="text-lg font-semibold mb-4">Run Thread on {client}</h3>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Run Thread</DialogTitle>
<DialogDescription>Start a new thread on {client}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="run-workflow"
className="text-sm block mb-1"
style={{ color: "var(--color-text-muted)" }}
>
<label htmlFor="run-workflow" className="text-sm block mb-1.5 text-muted-foreground">
Workflow
</label>
<select
id="run-workflow"
value={workflow}
onChange={(e) => setWorkflow(e.target.value)}
className="w-full px-3 py-2 rounded border text-sm"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
>
<option value="">Select a workflow...</option>
{workflows.status === "ok" &&
workflows.data.workflows.map((w) => (
<option key={w.name} value={w.name}>
{w.name}
</option>
))}
</select>
<Select value={workflow} onValueChange={setWorkflow}>
<SelectTrigger>
<SelectValue placeholder="Select a workflow..." />
</SelectTrigger>
<SelectContent>
{workflows.status === "ok" &&
workflows.data.workflows.map((w) => (
<SelectItem key={w.name} value={w.name}>
{w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label
htmlFor="run-prompt"
className="text-sm block mb-1"
style={{ color: "var(--color-text-muted)" }}
>
<label htmlFor="run-prompt" className="text-sm block mb-1.5 text-muted-foreground">
Prompt
</label>
<textarea
<Textarea
id="run-prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
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..."
/>
</div>
{error && (
<p className="text-sm" style={{ color: "var(--color-error)" }}>
{error}
</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)" }}
>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</button>
<button
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,
}}
>
</Button>
<Button type="submit" disabled={submitting || !workflow || !prompt}>
{submitting ? "Starting..." : "Run"}
</button>
</div>
</Button>
</DialogFooter>
</form>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,109 +1,131 @@
import { useEffect } from "react";
import { Loader2, LogOut, Moon, Package, Sun, Zap } from "lucide-react";
import { useLocation, useNavigate, useParams } from "react-router";
import type { ClientEndpoint } from "../api.ts";
import { listClients } from "../api.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 = {
view: "threads" | "workflows";
client: string | null;
onViewChange: (v: "threads" | "workflows") => void;
onClientChange: (a: string | null) => void;
onLogout: () => void;
theme: "light" | "dark";
onToggleTheme: () => void;
};
export function Sidebar({ view, client, onViewChange, onClientChange, onLogout }: Props) {
export function Sidebar({ onLogout, theme, onToggleTheme }: Props) {
const { client } = useParams();
const navigate = useNavigate();
const location = useLocation();
const { status, data } = useFetch(() => listClients(), []);
const clients: ClientEndpoint[] = status === "ok" ? data : [];
// Auto-select first client when none is selected
useEffect(() => {
if (client === null && clients.length > 0) {
onClientChange(clients[0].name);
}
}, [client, clients, onClientChange]);
const view = location.pathname.includes("/workflows") ? "workflows" : "threads";
const viewItems = [
{ key: "threads" as const, label: "Threads", icon: "⚡" },
{ key: "workflows" as const, label: "Workflows", icon: "📦" },
{ key: "threads" as const, label: "Threads", icon: Zap },
{ key: "workflows" as const, label: "Workflows", icon: Package },
];
return (
<aside
className="w-56 border-r flex flex-col"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<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>
<aside className="w-56 border-r border-border flex flex-col bg-sidebar">
<div className="p-4 border-b border-primary/20">
<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>
{/* Client selector */}
<div className="px-4 py-3 border-b" style={{ borderColor: "var(--color-border)" }}>
<div className="px-3 py-3">
<label
className="block text-xs font-medium mb-1"
style={{ color: "var(--color-text-muted)" }}
className="block text-xs font-medium mb-1.5 text-muted-foreground"
htmlFor="client-select"
>
Client
</label>
<select
id="client-select"
className="w-full rounded px-2 py-1.5 text-xs"
style={{
background: "var(--color-bg)",
color: "var(--color-text)",
border: "1px solid var(--color-border)",
}}
value={client ?? ""}
onChange={(e) => onClientChange(e.target.value || null)}
disabled={status === "loading"}
>
{status === "loading" ? (
<option value="">Loading</option>
) : clients.length === 0 ? (
<option value="">No clients online</option>
) : (
clients.map((a) => (
<option key={a.name} value={a.name}>
{a.status === "online" ? "🟢" : "🔴"} {a.name}
</option>
))
)}
</select>
</div>
{/* View navigation */}
<nav className="flex-1 p-2 space-y-1">
{viewItems.map((item) => (
<button
type="button"
key={item.key}
onClick={() => onViewChange(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)",
{status === "loading" ? (
<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">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Loading
</div>
) : clients.length === 0 ? (
<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
</div>
) : (
<Select
value={client ?? ""}
onValueChange={(name) => {
if (name) navigate(`/${name}/${view}`);
}}
>
{item.icon} {item.label}
</button>
<SelectTrigger className="h-8 text-xs transition-colors duration-200">
<SelectValue placeholder="Select client…" />
</SelectTrigger>
<SelectContent>
{clients.map((a) => (
<SelectItem key={a.name} value={a.name} className="text-xs">
<span className="flex items-center gap-2">
<span
className={cn(
"inline-block h-2 w-2 rounded-full",
a.status === "online" ? "bg-success animate-pulse" : "bg-destructive",
)}
/>
{a.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<Separator />
<nav className="flex-1 p-2 space-y-1">
{viewItems.map((item) => (
<Button
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={() => {
if (client) navigate(`/${client}/${item.key}`);
}}
>
<item.icon className="h-4 w-4" />
{item.label}
</Button>
))}
</nav>
<div className="p-2 border-t" style={{ borderColor: "var(--color-border)" }}>
<button
type="button"
onClick={onLogout}
className="w-full text-left px-3 py-2 rounded text-xs transition-colors"
style={{ color: "var(--color-text-muted)" }}
<Separator />
<div className="p-2 space-y-1">
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-muted-foreground hover:text-foreground transition-colors duration-200"
onClick={onToggleTheme}
>
🚪 Logout
</button>
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
{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>
</aside>
);
@@ -1,5 +1,7 @@
import { Loader2, Play, Wifi, WifiOff } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { getClientHealth } from "../api.ts";
import { Button } from "./ui/button.tsx";
type HealthStatus = "connected" | "disconnected" | "reconnecting";
@@ -8,14 +10,29 @@ type Props = {
onRun: () => void;
};
function statusLabel(status: HealthStatus): { text: string; color: string } {
function StatusIndicator({ status }: { status: HealthStatus }) {
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") {
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) {
@@ -48,32 +65,24 @@ export function StatusBar({ client, onRun }: Props) {
return () => clearInterval(interval);
}, [checkHealth]);
const label = statusLabel(status);
return (
<div
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 justify-between px-6 py-2 text-xs border-b border-border bg-card/80 backdrop-blur-sm">
<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"}
</span>
<button
type="button"
onClick={onRun}
<Button
variant="default"
size="sm"
disabled={!client}
className="px-3 py-1 rounded text-xs font-medium"
style={{
background: client ? "var(--color-accent)" : "var(--color-border)",
color: "#fff",
opacity: client ? 1 : 0.5,
}}
onClick={onRun}
className="h-7 gap-1.5 transition-all duration-200"
>
Run Thread
</button>
<Play className="h-3.5 w-3.5" />
Run Thread
</Button>
</div>
<span style={{ color: label.color }}>{label.text}</span>
<StatusIndicator status={status} />
</div>
);
}
@@ -1,4 +1,6 @@
import { AlertCircle, ArrowLeft, Layers, Loader2, Pause, Play, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router";
import {
getThread,
getWorkflowDescriptor,
@@ -11,14 +13,12 @@ import {
import { useFetch } from "../hooks.ts";
import { useSSE } from "../use-sse.ts";
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";
type Props = {
client: string;
threadId: string;
onBack: () => void;
};
function extractWorkflowName(records: readonly ThreadRecord[]): string | null {
for (const r of records) {
if (r.type === "thread-start") return r.workflow;
@@ -53,36 +53,11 @@ function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeSt
return states;
}
function isClickableGraphNode(nodeStates: Map<string, NodeState>, nodeId: string): boolean {
const state = nodeStates.get(nodeId);
return state !== undefined && state !== "default";
}
function scrollToFirstRecord(): void {
const firstCard = document.querySelector('[data-record-index="0"]');
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
}
function scrollToRoleOccurrence(
nodeId: string,
indicesByRole: Map<string, number[]>,
clickCycleRef: { current: Map<string, number> },
onHighlight: (role: string) => void,
): void {
const indices = indicesByRole.get(nodeId);
if (indices === undefined || indices.length === 0) return;
const cycle = clickCycleRef.current.get(nodeId) ?? 0;
const idx = indices[cycle % indices.length];
clickCycleRef.current.set(nodeId, cycle + 1);
const el = document.querySelector(`[data-record-index="${idx}"]`);
if (el === null) return;
el.scrollIntoView({ behavior: "smooth", block: "center" });
onHighlight(nodeId);
}
export function ThreadDetail({ client, threadId, onBack }: Props) {
export function ThreadDetail() {
const params = useParams();
const navigate = useNavigate();
const client = params.client as string;
const threadId = params.threadId as string;
const sse = useSSE(client, threadId);
const { status, data, error } = useFetch(() => getThread(client, threadId), [client, threadId]);
const [actionStatus, setActionStatus] = useState<string | null>(null);
@@ -122,32 +97,42 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
return m;
}, [records]);
// Track which occurrence to jump to next per role (cycling)
const clickCycleRef = useRef<Map<string, number>>(new Map());
const highlightRole = useCallback((role: string) => {
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
setHighlightedRole(role);
highlightTimerRef.current = setTimeout(() => {
setHighlightedRole(null);
highlightTimerRef.current = null;
}, 1500);
}, []);
const handleGraphNodeClick = useCallback(
(nodeId: string) => {
if (!isClickableGraphNode(nodeStates, nodeId)) return;
if (nodeStates.get(nodeId) === undefined || nodeStates.get(nodeId) === "default") return;
if (nodeId === "__start__") {
scrollToFirstRecord();
const firstCard = document.querySelector('[data-record-index="0"]');
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
return;
}
if (nodeId === "__end__") {
recordsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
return;
}
scrollToRoleOccurrence(nodeId, indicesByRole, clickCycleRef, highlightRole);
const indices = indicesByRole.get(nodeId);
if (indices === undefined || indices.length === 0) return;
const cycle = clickCycleRef.current.get(nodeId) ?? 0;
const idx = indices[cycle % indices.length];
clickCycleRef.current.set(nodeId, cycle + 1);
const el = document.querySelector(`[data-record-index="${idx}"]`);
if (el !== null) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
setHighlightedRole(nodeId);
highlightTimerRef.current = setTimeout(() => {
setHighlightedRole(null);
highlightTimerRef.current = null;
}, 1500);
}
},
[nodeStates, indicesByRole, highlightRole],
[nodeStates, indicesByRole],
);
useEffect(() => {
@@ -166,7 +151,7 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
try {
const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread;
await fn(client, threadId);
setActionStatus(`${action} sent ✓`);
setActionStatus(null);
} catch (e) {
setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`);
}
@@ -175,88 +160,84 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
return (
<div>
<div className="flex items-center justify-between mb-4">
<button
type="button"
onClick={onBack}
className="text-sm hover:underline"
style={{ color: "var(--color-accent)" }}
<Button
variant="ghost"
className="gap-1.5 px-2 text-muted-foreground hover:text-foreground transition-colors duration-200"
onClick={() => navigate(`/${client}/threads`)}
>
Back to threads
</button>
<div className="flex gap-2">
<button
type="button"
<ArrowLeft className="h-4 w-4" />
Back to threads
</Button>
<div className="flex gap-1 rounded-lg border border-border bg-muted/30 p-1">
<Button
variant="outline"
size="sm"
className="transition-colors duration-200"
onClick={() => handleAction("pause")}
className="px-3 py-1 text-xs rounded border"
style={{ borderColor: "var(--color-warning)", color: "var(--color-warning)" }}
>
Pause
</button>
<button
type="button"
<Pause className="h-3.5 w-3.5 text-warning" />
Pause
</Button>
<Button
variant="outline"
size="sm"
className="transition-colors duration-200"
onClick={() => handleAction("resume")}
className="px-3 py-1 text-xs rounded border"
style={{ borderColor: "var(--color-success)", color: "var(--color-success)" }}
>
Resume
</button>
<button
type="button"
<Play className="h-3.5 w-3.5 text-success" />
Resume
</Button>
<Button
variant="outline"
size="sm"
className="transition-colors duration-200"
onClick={() => handleAction("kill")}
className="px-3 py-1 text-xs rounded border"
style={{ borderColor: "var(--color-error)", color: "var(--color-error)" }}
>
Kill
</button>
<X className="h-3.5 w-3.5 text-destructive" />
Kill
</Button>
</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>
{sse.connected && !sse.completed && (
<span
className="text-xs font-medium px-2 py-0.5 rounded"
style={{ background: "var(--color-success)", color: "var(--color-bg)" }}
>
<Badge variant="success" className="animate-pulse flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-success-foreground" />
Live
</span>
</Badge>
)}
</h2>
{actionStatus && (
<p className="text-xs mb-4" style={{ color: "var(--color-text-muted)" }}>
<Badge variant="secondary" className="mb-4 text-xs font-normal">
{actionStatus}
</p>
</Badge>
)}
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 120px)" }}>
{descriptor !== null && descriptor.graph.edges.length > 0 && (
<div
className="shrink-0"
<ResizablePanel
defaultWidth={360}
minWidth={240}
maxWidth={560}
className={null}
style={{
width: 280,
position: "sticky",
top: 16,
height: "calc(100vh - 120px)",
alignSelf: "flex-start",
}}
>
<div
className="rounded-lg border h-full flex flex-col overflow-hidden"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div
className="flex items-center justify-between px-3 py-2 text-xs"
style={{ color: "var(--color-text-muted)" }}
>
<span className="font-mono">
<Card className="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">
<span className="font-mono flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5" />
Workflow graph
{workflowName !== null && (
<span className="ml-2" style={{ color: "var(--color-text)" }}>
{workflowName}
</span>
<span className="ml-2 text-foreground">{workflowName}</span>
)}
</span>
<span>
<span className="tabular-nums">
{descriptor.graph.edges.length} edge
{descriptor.graph.edges.length === 1 ? "" : "s"}
</span>
@@ -269,19 +250,25 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
onNodeClick={handleGraphNodeClick}
/>
</div>
</div>
</div>
</Card>
</ResizablePanel>
)}
<div className="flex-1 min-w-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 && (
<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) && (
<div className="space-y-3">
<div className="border-l-2 border-border ml-2 pl-4 space-y-3">
{records.map((r, i) => {
const key = `${threadId}-${i}`;
if (r.type === "role") {
@@ -292,18 +279,21 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
<div
key={key}
data-record-index={i}
className="relative"
ref={(el) => {
if (!isFirstForRole) return;
if (el !== null) firstCardByRoleRef.current.set(r.role, el);
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} />
</div>
);
}
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} />
</div>
);
@@ -1,17 +1,37 @@
import { AlertCircle, Clock, Loader2, Workflow, Zap } from "lucide-react";
import { useNavigate, useParams } from "react-router";
import { listThreads } from "../api.ts";
import { useFetch } from "../hooks.ts";
import { Badge } from "./ui/badge.tsx";
import { Card } from "./ui/card.tsx";
type Props = {
client: string;
onSelect: (id: string) => void;
};
function statusVariant(status: string): "success" | "destructive" | "secondary" {
if (status === "completed") return "success";
if (status === "failed") return "destructive";
return "secondary";
}
export function ThreadList({ client, onSelect }: Props) {
export function ThreadList() {
const params = useParams();
const navigate = useNavigate();
const client = params.client as string;
const { status, data, error } = useFetch(() => listThreads(client), [client]);
if (status === "loading")
return <p style={{ color: "var(--color-text-muted)" }}>Loading threads...</p>;
if (status === "error") return <p style={{ color: "var(--color-error)" }}>Error: {error}</p>;
return (
<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) => {
if (!a.startedAt && !b.startedAt) return 0;
@@ -22,51 +42,44 @@ export function ThreadList({ client, onSelect }: Props) {
return (
<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 ? (
<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">
{threads.map((t) => (
<button
type="button"
<Card
key={t.threadId}
onClick={() => onSelect(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)" }}
className="p-4 cursor-pointer hover:bg-accent/50 hover:shadow-sm transition-all duration-200"
onClick={() => navigate(`/${client}/threads/${t.threadId}`)}
>
<div className="flex items-center justify-between">
<code className="text-sm font-mono" style={{ color: "var(--color-accent)" }}>
{t.threadId}
</code>
<code className="font-mono text-sm text-foreground">{t.threadId}</code>
{t.status && (
<span
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",
}}
>
<Badge variant={statusVariant(t.status)} className="text-xs">
{t.status}
</span>
</Badge>
)}
</div>
{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}
</p>
)}
{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}
</p>
)}
</button>
</Card>
))}
</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,22 +1,50 @@
import {
AlertCircle,
ArrowLeft,
ChevronDown,
GitBranch,
Hash,
Layers,
Loader2,
User,
} from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router";
import type { WorkflowDetail as WorkflowDetailData, WorkflowRoleDescriptor } from "../api.ts";
import { getWorkflowDetail } from "../api.ts";
import { useFetch } from "../hooks.ts";
import { cn } from "../lib/utils.ts";
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";
type Props = {
client: string;
workflowName: string;
onBack: () => void;
};
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 {
return detail.history.length + 1;
}
// ── Schema rendering helpers ────────────────────────────────────────
type SchemaRow = {
key: string;
name: string;
@@ -39,119 +67,66 @@ function resolveType(prop: Record<string, unknown>): string {
return String(prop.type ?? "unknown");
}
function variantLabel(
variantProps: Record<string, Record<string, unknown>>,
variantIndex: number,
): string {
for (const [pName, pDef] of Object.entries(variantProps)) {
if (pDef.const !== undefined) return `${pName}: ${String(pDef.const)}`;
}
return `Variant ${variantIndex + 1}`;
}
function childPrefixForDepth(depth: number, parentPrefix: string): string {
return depth > 0 ? `${parentPrefix} ` : " ";
}
function flattenOneOfVariants(
oneOf: Array<Record<string, unknown>>,
depth: number,
parentPrefix: string,
keyPrefix: string,
): SchemaRow[] {
const rows: SchemaRow[] = [];
for (let vi = 0; vi < oneOf.length; vi++) {
const variant = oneOf[vi];
const variantProps = (variant.properties ?? {}) as Record<string, Record<string, unknown>>;
const isLast = vi === oneOf.length - 1;
const connector = isLast ? "└" : "├";
rows.push({
key: `${keyPrefix}variant-${vi}`,
name: `${parentPrefix}${connector} ${variantLabel(variantProps, vi)}`,
type: "",
description: "",
depth,
prefix: parentPrefix,
isVariantHeader: true,
});
const variantChildPrefix = `${parentPrefix}${isLast ? " " : "│ "}`;
const variantRequired = new Set<string>(
Array.isArray(variant.required) ? (variant.required as string[]) : [],
);
for (const [pName, pDef] of Object.entries(variantProps)) {
if (pDef.const !== undefined) continue;
rows.push(
...flattenProperty(
pName,
pDef,
depth + 1,
variantChildPrefix,
`${keyPrefix}v${vi}-`,
variantRequired,
),
);
}
}
return rows;
}
function flattenSchemaProperties(
schema: Record<string, unknown>,
depth: number,
parentPrefix: string,
keyPrefix: string,
): SchemaRow[] {
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
const required = new Set<string>(
Array.isArray(schema.required) ? (schema.required as string[]) : [],
);
const rows: SchemaRow[] = [];
for (const [name, prop] of Object.entries(props)) {
rows.push(...flattenProperty(name, prop, depth, parentPrefix, keyPrefix, required));
}
return rows;
}
function flattenSchema(
schema: Record<string, unknown>,
depth: number,
parentPrefix: string,
keyPrefix: string,
): SchemaRow[] {
const rows: SchemaRow[] = [];
const oneOf = schema.oneOf as Array<Record<string, unknown>> | undefined;
if (Array.isArray(oneOf) && oneOf.length > 0) {
return flattenOneOfVariants(oneOf, depth, parentPrefix, keyPrefix);
}
return flattenSchemaProperties(schema, depth, parentPrefix, keyPrefix);
}
function flattenNestedPropertyRows(
name: string,
prop: Record<string, unknown>,
depth: number,
parentPrefix: string,
keyPrefix: string,
hasOneOf: boolean,
): SchemaRow[] {
const childPrefix = childPrefixForDepth(depth, parentPrefix);
const nestedKeyPrefix = `${keyPrefix}${name}-`;
if (prop.type === "object" && prop.properties !== undefined) {
return flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, nestedKeyPrefix);
}
if (prop.type === "array") {
const items = prop.items as Record<string, unknown> | undefined;
if (items !== undefined && items.type === "object" && items.properties !== undefined) {
return flattenSchema(items, depth + 1, childPrefix, nestedKeyPrefix);
for (let vi = 0; vi < oneOf.length; vi++) {
const variant = oneOf[vi];
const variantProps = (variant.properties ?? {}) as Record<string, Record<string, unknown>>;
let variantLabel = `Variant ${vi + 1}`;
for (const [pName, pDef] of Object.entries(variantProps)) {
if (pDef.const !== undefined) {
variantLabel = `${pName}: ${String(pDef.const)}`;
break;
}
}
const isLast = vi === oneOf.length - 1;
const connector = isLast ? "└" : "├";
rows.push({
key: `${keyPrefix}variant-${vi}`,
name: `${parentPrefix}${connector} ${variantLabel}`,
type: "",
description: "",
depth,
prefix: parentPrefix,
isVariantHeader: true,
});
const childPrefix = `${parentPrefix}${isLast ? " " : "│ "}`;
const variantRequired = new Set<string>(
Array.isArray(variant.required) ? (variant.required as string[]) : [],
);
for (const [pName, pDef] of Object.entries(variantProps)) {
if (pDef.const !== undefined) continue;
const subRows = flattenProperty(
pName,
pDef,
depth + 1,
childPrefix,
`${keyPrefix}v${vi}-`,
variantRequired,
);
rows.push(...subRows);
}
}
return rows;
}
if (hasOneOf) {
return flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, nestedKeyPrefix);
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
const required = new Set<string>(
Array.isArray(schema.required) ? (schema.required as string[]) : [],
);
for (const [name, prop] of Object.entries(props)) {
const subRows = flattenProperty(name, prop, depth, parentPrefix, keyPrefix, required);
rows.push(...subRows);
}
return [];
return rows;
}
function flattenProperty(
@@ -162,139 +137,145 @@ function flattenProperty(
keyPrefix: string,
required: Set<string>,
): SchemaRow[] {
const rows: SchemaRow[] = [];
const hasOneOf = Array.isArray(prop.oneOf) && (prop.oneOf as unknown[]).length > 0;
let type = hasOneOf ? "⊕ oneOf" : resolveType(prop);
if (!required.has(name)) type += "?";
const description = String(prop.description ?? "");
const displayName = depth > 0 ? `${parentPrefix}└─ ${name}` : name;
const rows: SchemaRow[] = [
{
key: `${keyPrefix}${name}`,
name: depth > 0 ? `${parentPrefix}└─ ${name}` : name,
type,
description: String(prop.description ?? ""),
depth,
prefix: parentPrefix,
isVariantHeader: false,
},
];
rows.push({
key: `${keyPrefix}${name}`,
name: displayName,
type,
description,
depth,
prefix: parentPrefix,
isVariantHeader: false,
});
if (prop.type === "object" && prop.properties !== undefined) {
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
rows.push(
...flattenSchema(
prop as Record<string, unknown>,
depth + 1,
childPrefix,
`${keyPrefix}${name}-`,
),
);
}
if (prop.type === "array") {
const items = prop.items as Record<string, unknown> | undefined;
if (items !== undefined && items.type === "object" && items.properties !== undefined) {
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
rows.push(...flattenSchema(items, depth + 1, childPrefix, `${keyPrefix}${name}-`));
}
}
if (hasOneOf) {
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
rows.push(
...flattenSchema(
prop as Record<string, unknown>,
depth + 1,
childPrefix,
`${keyPrefix}${name}-`,
),
);
}
rows.push(...flattenNestedPropertyRows(name, prop, depth, parentPrefix, keyPrefix, hasOneOf));
return rows;
}
// ── Components ──────────────────────────────────────────────────────
function RoleCard({ roleName, role }: { roleName: string; role: WorkflowRoleDescriptor }) {
const rows = flattenSchema(role.schema, 0, "", `${roleName}-`);
const [promptOpen, setPromptOpen] = useState(false);
return (
<div
id={`role-${roleName}`}
className="rounded-lg border p-4"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<h4 className="text-sm font-semibold font-mono mb-1" style={{ color: "var(--color-text)" }}>
<Card id={`role-${roleName}`} className={cn("p-4 border-l-4", roleBorderColor(roleName))}>
<h4 className="text-sm font-semibold font-mono mb-1 text-foreground flex items-center gap-1.5">
<User className="h-3.5 w-3.5 text-muted-foreground" />
{roleName}
</h4>
{role.description !== "" && (
<p className="text-xs mb-3" style={{ color: "var(--color-text-muted)" }}>
{role.description}
</p>
<p className="text-xs mb-3 text-muted-foreground">{role.description}</p>
)}
{role.systemPrompt !== "" && (
<details className="mb-3">
<summary
className="text-[10px] uppercase tracking-wider font-medium cursor-pointer select-none"
style={{ color: "var(--color-text-muted)" }}
>
System Prompt
</summary>
<div
className="mt-1 p-2 rounded overflow-y-auto text-xs"
style={{
background: "var(--color-bg)",
border: "1px solid var(--color-border)",
maxHeight: "300px",
}}
>
<Markdown content={role.systemPrompt} />
</div>
</details>
<Collapsible open={promptOpen} onOpenChange={setPromptOpen} className="mb-3">
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="gap-1 h-7 px-2 text-[10px] uppercase tracking-wider text-muted-foreground bg-muted/50 rounded-md transition-all duration-200"
>
<ChevronDown
className={cn("h-3 w-3 transition-transform", promptOpen && "rotate-180")}
/>
System Prompt
</Button>
</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} />
</div>
</CollapsibleContent>
</Collapsible>
)}
{rows.length > 0 && (
<div>
<p
className="text-[10px] uppercase tracking-wider mb-1 font-medium"
style={{ color: "var(--color-text-muted)" }}
>
<p className="text-[10px] uppercase tracking-wider mb-1 font-medium text-muted-foreground">
Meta Schema
</p>
<table className="w-full text-xs" style={{ borderCollapse: "collapse" }}>
<thead>
<tr style={{ borderBottom: "1px solid var(--color-border)" }}>
<th
className="text-left py-1 pr-3 font-medium"
style={{ color: "var(--color-text-muted)" }}
>
Field
</th>
<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>
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs">Field</TableHead>
<TableHead className="text-xs">Type</TableHead>
<TableHead className="text-xs">Description</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<tr
<TableRow
key={r.key}
style={{
borderBottom: r.isVariantHeader ? "none" : "1px solid var(--color-border)",
}}
className={cn(r.isVariantHeader ? "border-b-0" : "", "even:bg-muted/30")}
>
<td
className="py-1 pr-3 font-mono whitespace-pre"
style={{
color: r.isVariantHeader ? "var(--color-text-muted)" : "var(--color-accent)",
fontStyle: r.isVariantHeader ? "italic" : "normal",
}}
<TableCell
className={cn(
"font-mono whitespace-pre text-xs",
r.isVariantHeader ? "italic text-muted-foreground" : "text-foreground",
)}
>
{r.name}
</td>
<td className="py-1 pr-3 font-mono" style={{ color: "var(--color-text-muted)" }}>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{r.type}
</td>
<td className="py-1" style={{ color: "var(--color-text)" }}>
</TableCell>
<TableCell className="text-xs">
{r.description || (r.isVariantHeader ? "" : "—")}
</td>
</tr>
</TableCell>
</TableRow>
))}
</tbody>
</table>
</TableBody>
</Table>
</div>
)}
{rows.length === 0 && Object.keys(role.schema).length > 0 && (
<pre
className="text-[10px] font-mono p-2 rounded overflow-x-auto"
style={{ background: "var(--color-bg)", color: "var(--color-text-muted)" }}
>
<pre className="text-[10px] font-mono p-2 rounded-md overflow-x-auto bg-background text-muted-foreground">
{JSON.stringify(role.schema, null, 2)}
</pre>
)}
</div>
</Card>
);
}
// ── Main component ──────────────────────────────────────────────────
export function WorkflowDetail({ client, workflowName, onBack }: Props) {
export function WorkflowDetail() {
const params = useParams();
const navigate = useNavigate();
const client = params.client as string;
const workflowName = params.workflowName as string;
const { status, data, error } = useFetch(
() => getWorkflowDetail(client, workflowName),
[client, workflowName],
@@ -334,44 +315,52 @@ export function WorkflowDetail({ client, workflowName, onBack }: Props) {
return (
<div>
<div className="flex items-center justify-between mb-4">
<button
type="button"
onClick={onBack}
className="text-sm hover:underline"
style={{ color: "var(--color-accent)" }}
<Button
variant="ghost"
className="gap-1.5 px-2 text-muted-foreground hover:text-foreground transition-all duration-200"
onClick={() => navigate(`/${client}/workflows`)}
>
Back to workflows
</button>
<ArrowLeft className="h-4 w-4" />
Back to workflows
</Button>
</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 === "error" && <p style={{ color: "var(--color-error)" }}>Error: {error}</p>}
{status === "loading" && (
<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 && (
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 160px)" }}>
{/* Left: fixed graph sidebar */}
{hasGraph && (
<div
className="shrink-0"
<ResizablePanel
defaultWidth={360}
minWidth={240}
maxWidth={560}
className={null}
style={{
width: 280,
position: "sticky",
top: 16,
height: "calc(100vh - 160px)",
alignSelf: "flex-start",
}}
>
<div
className="rounded-lg border h-full flex flex-col overflow-hidden"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<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</span>
<Card className="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">
<span className="font-mono flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5" />
Workflow graph
</span>
<span>
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
</span>
@@ -384,52 +373,44 @@ export function WorkflowDetail({ client, workflowName, onBack }: Props) {
onNodeClick={handleGraphNodeClick}
/>
</div>
</div>
</div>
</Card>
</ResizablePanel>
)}
{/* Right: scrollable content */}
<div className="flex-1 min-w-0 space-y-4">
{/* Workflow overview */}
<div
className="rounded-lg border p-4"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<p
className="text-sm whitespace-pre-wrap mb-3"
style={{ color: "var(--color-text)" }}
>
{descriptor !== null && descriptor.description !== ""
? descriptor.description
: "—"}
</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>
<Card className="p-4">
<div className="rounded-md bg-muted/30 px-3 py-2 mb-3">
<p className="text-sm whitespace-pre-wrap text-foreground">
{descriptor !== null && descriptor.description !== ""
? descriptor.description
: "—"}
</p>
</div>
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs font-mono">
<Hash className="h-3 w-3" />
<span className="text-foreground">{detail.hash}</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" : ""}
</span>
{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" : ""}
</span>
)}
</div>
</div>
</Card>
{/* Role cards */}
{roleEntries.map(([name, role]) => (
<div
key={name}
style={{
transition: "box-shadow 0.3s",
boxShadow: highlightedRole === name ? "0 0 0 2px var(--color-accent)" : "none",
borderRadius: 8,
}}
className={cn(
"rounded-lg transition-shadow duration-300",
highlightedRole === name && "ring-2 ring-ring",
)}
>
<RoleCard roleName={name} role={role} />
</div>
@@ -91,7 +91,7 @@ export function ConditionEdge(props: EdgeProps) {
defaultLabelY = result[2];
}
const stroke = "var(--color-accent)";
const stroke = "hsl(var(--ring))";
const label = isFallback ? "" : (edgeData?.condition ?? "");
// 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"
style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text)",
background: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
color: "hsl(var(--foreground))",
whiteSpace: "nowrap",
zIndex: 10,
}}
@@ -1,29 +1,23 @@
import { Handle, type NodeProps, Position } from "@xyflow/react";
import { Check, Circle } from "lucide-react";
import type { RoleNodeData } from "./types.ts";
function borderColor(state: RoleNodeData["state"]): string {
switch (state) {
case "completed":
return "var(--color-success)";
return "hsl(var(--success))";
case "active":
return "var(--color-accent)";
return "hsl(var(--ring))";
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) {
const data = props.data as RoleNodeData;
const icon = stateIcon(data.state);
const isActive = data.state === "active";
const handleStyle = {
background: "var(--color-text-muted)",
background: "hsl(var(--muted-foreground))",
width: 6,
height: 6,
border: "none",
@@ -35,9 +29,9 @@ export function RoleNode(props: NodeProps) {
style={{
width: 180,
height: 60,
background: "var(--color-surface)",
background: "hsl(var(--card))",
borderColor: borderColor(data.state),
color: "var(--color-text)",
color: "hsl(var(--foreground))",
display: "flex",
flexDirection: "column",
justifyContent: "center",
@@ -81,19 +75,15 @@ export function RoleNode(props: NodeProps) {
isConnectable={false}
/>
<div className="flex items-center gap-1.5 font-mono">
{icon !== null && (
<span
style={{
color: data.state === "active" ? "var(--color-accent)" : "var(--color-success)",
}}
>
{icon}
</span>
)}
{data.state === "completed" && <Check className="h-3 w-3 text-success" />}
{data.state === "active" && <Circle className="h-3 w-3 fill-current text-ring" />}
<span className="truncate">{data.label}</span>
</div>
{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}
</div>
)}
@@ -1,21 +1,22 @@
import { Handle, type NodeProps, Position } from "@xyflow/react";
import { Play, Square } from "lucide-react";
import type { TerminalNodeData } from "./types.ts";
function borderColor(state: TerminalNodeData["state"]): string {
switch (state) {
case "completed":
return "var(--color-success)";
return "hsl(var(--success))";
case "active":
return "var(--color-accent)";
return "hsl(var(--ring))";
default:
return "var(--color-border)";
return "hsl(var(--border))";
}
}
function bgColor(state: TerminalNodeData["state"]): string {
if (state === "completed") return "var(--color-success)";
if (state === "active") return "var(--color-accent)";
return "var(--color-surface)";
if (state === "completed") return "hsl(var(--success))";
if (state === "active") return "hsl(var(--ring))";
return "hsl(var(--card))";
}
export function TerminalNode(props: NodeProps) {
@@ -23,7 +24,7 @@ export function TerminalNode(props: NodeProps) {
const isStart = data.kind === "start";
const isActive = data.state === "active";
const handleStyle = {
background: "var(--color-text-muted)",
background: "hsl(var(--muted-foreground))",
width: 6,
height: 6,
border: "none",
@@ -31,13 +32,16 @@ export function TerminalNode(props: NodeProps) {
return (
<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={{
width: 40,
height: 40,
background: bgColor(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"}
>
@@ -74,7 +78,7 @@ export function TerminalNode(props: NodeProps) {
/>
</>
)}
{isStart ? "▶" : "■"}
{isStart ? <Play className="h-3 w-3" /> : <Square className="h-3 w-3" />}
</div>
);
}
@@ -3,13 +3,14 @@ import {
type EdgeTypes,
MarkerType,
type Node,
type NodeMouseHandler,
type NodeTypes,
type OnNodeClick,
ReactFlow,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { useMemo } from "react";
import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts";
import { useTheme } from "../../hooks/use-theme.tsx";
import { ConditionEdge } from "./condition-edge.tsx";
import { RoleNode } from "./role-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) {
const layout = useLayout({ edges: graph.edges, roles, nodeStates });
const { theme } = useTheme();
const onNodeClickHandler: OnNodeClick | undefined =
onNodeClick !== null ? (_e, node) => handleNodeClick(onNodeClick, node) : undefined;
const onNodeClickHandler: NodeMouseHandler | undefined =
onNodeClick !== null
? (_e: React.MouseEvent, node: Node) => handleNodeClick(onNodeClick, node)
: undefined;
const styledEdges = useMemo(
() =>
@@ -51,7 +55,7 @@ export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props)
type: MarkerType.ArrowClosed,
width: 14,
height: 14,
color: "var(--color-text)",
color: "hsl(var(--foreground))",
},
})),
[layout.edges],
@@ -72,10 +76,10 @@ export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props)
nodesConnectable={false}
elementsSelectable={false}
proOptions={{ hideAttribution: true }}
colorMode="dark"
style={{ background: "var(--color-bg)" }}
colorMode={theme}
style={{ background: "hsl(var(--background))" }}
>
<Background color="var(--color-border)" gap={20} size={1} />
<Background color="hsl(var(--border))" gap={20} size={1} />
</ReactFlow>
);
}
@@ -1,54 +1,65 @@
import { AlertCircle, Clock, Hash, Loader2, Package } from "lucide-react";
import { useNavigate, useParams } from "react-router";
import { listWorkflows } from "../api.ts";
import { useFetch } from "../hooks.ts";
import { Card } from "./ui/card.tsx";
type Props = {
client: string;
onSelect: (name: string) => void;
};
export function WorkflowList({ client, onSelect }: Props) {
export function WorkflowList() {
const params = useParams();
const navigate = useNavigate();
const client = params.client as string;
const { status, data, error } = useFetch(() => listWorkflows(client), [client]);
if (status === "loading")
return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
if (status === "error") return <p style={{ color: "var(--color-error)" }}>Error: {error}</p>;
return (
<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;
return (
<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 ? (
<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">
{workflows.map((w) => (
<button
<Card
key={w.name}
type="button"
onClick={() => onSelect(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)",
}}
className="p-4 cursor-pointer hover:bg-accent/50 hover:shadow-sm transition-all duration-200"
onClick={() => navigate(`/${client}/workflows/${w.name}`)}
>
<div className="flex items-center gap-2">
<span className="font-medium">{w.name}</span>
</div>
<code
className="text-xs mt-1 block font-mono truncate"
style={{ color: "var(--color-accent)" }}
>
<span className="text-sm font-medium text-foreground flex items-center gap-1.5">
<Package className="h-3.5 w-3.5 text-muted-foreground" />
{w.name}
</span>
<code className="text-xs mt-1 font-mono text-muted-foreground flex items-center gap-1.5">
<Hash className="h-3 w-3" />
{w.hash !== null ? w.hash : "—"}
</code>
{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()}
</span>
) : null}
</button>
</Card>
))}
</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";
@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 {
--color-bg: #0a0a0f;
--color-surface: #12121a;
--color-border: #1e1e2e;
--color-text: #e4e4ef;
--color-text-muted: #6b6b8a;
--color-accent: #7c6df0;
--color-accent-dim: #5a4db8;
--color-success: #34d399;
--color-warning: #fbbf24;
--color-error: #f87171;
--radius: 0.625rem;
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--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 {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
background: hsl(var(--background));
color: hsl(var(--foreground));
font-family: "Inter", system-ui, -apple-system, sans-serif;
}
@keyframes wf-node-pulse {
0%,
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% {
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 {
0% {
border-color: var(--color-accent);
border-color: hsl(var(--ring));
}
35% {
border-color: var(--color-accent);
border-color: hsl(var(--ring));
}
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));
}
+6 -2
View File
@@ -1,13 +1,17 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider } from "react-router";
import { ThemeProvider } from "./hooks/use-theme.tsx";
import "./index.css";
import { App } from "./app.tsx";
import { router } from "./router.tsx";
const root = document.getElementById("root");
if (root) {
createRoot(root).render(
<StrictMode>
<App />
<ThemeProvider>
<RouterProvider router={router} />
</ThemeProvider>
</StrictMode>,
);
}
@@ -0,0 +1,45 @@
import { createHashRouter, redirect } from "react-router";
import { Layout } from "./app.tsx";
import { ClientRedirect } from "./components/client-redirect.tsx";
import { LoginPage } from "./components/login.tsx";
import { ThreadDetail } from "./components/thread-detail.tsx";
import { ThreadList } from "./components/thread-list.tsx";
import { WorkflowDetail } from "./components/workflow-detail.tsx";
import { WorkflowList } from "./components/workflow-list.tsx";
export const router = createHashRouter([
{
path: "/login",
Component: LoginPage,
},
{
path: "/",
Component: Layout,
children: [
{
index: true,
Component: ClientRedirect,
},
{
path: ":client/threads",
Component: ThreadList,
},
{
path: ":client/threads/:threadId",
Component: ThreadDetail,
},
{
path: ":client/workflows",
Component: WorkflowList,
},
{
path: ":client/workflows/:workflowName",
Component: WorkflowDetail,
},
{
path: ":client",
loader: ({ params }) => redirect(`/${params.client}/threads`),
},
],
},
]);
@@ -1,110 +0,0 @@
import { useCallback, useEffect, useState } from "react";
type View = "threads" | "workflows";
type HashRoute = {
view: View;
client: string | null;
threadId: string | null;
workflowName: string | null;
};
function parseHash(hash: string): HashRoute {
const raw = hash.replace(/^#\/?/, "");
// Format: #client/threads/id or #client/workflows or #threads or #workflows
const parts = raw.split("/");
// Check if first part is a known view
if (parts[0] === "threads" || parts[0] === "workflows") {
return {
view: parts[0] as View,
client: null,
threadId: parts[0] === "threads" && parts.length > 1 ? parts.slice(1).join("/") : null,
workflowName: parts[0] === "workflows" && parts.length > 1 ? parts.slice(1).join("/") : null,
};
}
// First part is client name
const client = parts[0] || null;
const viewPart = parts[1] ?? "threads";
const view: View = viewPart === "workflows" ? "workflows" : "threads";
const threadId = view === "threads" && parts.length > 2 ? parts.slice(2).join("/") : null;
const workflowName = view === "workflows" && parts.length > 2 ? parts.slice(2).join("/") : null;
return { view, client, threadId, workflowName };
}
function buildHash(route: HashRoute): string {
const prefix = route.client ? `${route.client}/` : "";
if (route.view === "workflows") {
if (route.workflowName !== null) {
return `#${prefix}workflows/${route.workflowName}`;
}
return `#${prefix}workflows`;
}
if (route.threadId !== null) {
return `#${prefix}threads/${route.threadId}`;
}
return `#${prefix}threads`;
}
export function useHashRoute(): {
view: View;
client: string | null;
threadId: string | null;
workflowName: string | null;
setView: (v: View) => void;
setClient: (a: string | null) => void;
setThreadId: (id: string | null) => void;
setWorkflowName: (name: string | null) => void;
} {
const [route, setRoute] = useState<HashRoute>(() => parseHash(window.location.hash));
useEffect(() => {
function onHashChange(): void {
setRoute(parseHash(window.location.hash));
}
window.addEventListener("hashchange", onHashChange);
return () => window.removeEventListener("hashchange", onHashChange);
}, []);
const navigate = useCallback((next: HashRoute) => {
const hash = buildHash(next);
window.location.hash = hash;
setRoute(next);
}, []);
const setView = useCallback(
(v: View) => navigate({ view: v, client: route.client, threadId: null, workflowName: null }),
[navigate, route.client],
);
const setClient = useCallback(
(a: string | null) =>
navigate({ view: route.view, client: a, threadId: null, workflowName: null }),
[navigate, route.view],
);
const setThreadId = useCallback(
(id: string | null) =>
navigate({ view: "threads", client: route.client, threadId: id, workflowName: null }),
[navigate, route.client],
);
const setWorkflowName = useCallback(
(name: string | null) =>
navigate({ view: "workflows", client: route.client, threadId: null, workflowName: name }),
[navigate, route.client],
);
return {
view: route.view,
client: route.client,
threadId: route.threadId,
workflowName: route.workflowName,
setView,
setClient,
setThreadId,
setWorkflowName,
};
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+3 -1
View File
@@ -4,7 +4,9 @@
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"strict": true,
"types": ["vite/client"],
"jsx": "react-jsx",
"esModuleInterop": true,
"skipLibCheck": true,
@@ -13,5 +15,5 @@
"isolatedModules": true,
"noEmit": true
},
"include": ["src"]
"include": ["src", "plugins"]
}
+6 -1
View File
@@ -1,10 +1,15 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { viteLimitLinePlugin } from "./plugins/vite-limit-line-plugin.js";
// biome-ignore lint/style/noDefaultExport: Vite loads config from default export.
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [
react(),
tailwindcss(),
...viteLimitLinePlugin({ maxReactFCLines: 300, maxFileLines: 600 }),
],
server: {
port: 5173,
proxy: {