diff --git a/packages/workflow-dashboard/index.html b/packages/workflow-dashboard/index.html index 6621a30..da26862 100644 --- a/packages/workflow-dashboard/index.html +++ b/packages/workflow-dashboard/index.html @@ -4,6 +4,14 @@ Workflow Dashboard +
diff --git a/packages/workflow-dashboard/package.json b/packages/workflow-dashboard/package.json index 0ec2e1e..dbf27ee 100644 --- a/packages/workflow-dashboard/package.json +++ b/packages/workflow-dashboard/package.json @@ -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", diff --git a/packages/workflow-dashboard/plugins/vite-limit-line-plugin.ts b/packages/workflow-dashboard/plugins/vite-limit-line-plugin.ts new file mode 100644 index 0000000..08e0765 --- /dev/null +++ b/packages/workflow-dashboard/plugins/vite-limit-line-plugin.ts @@ -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; +}; + +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; +}; + +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; + body: Array; + [key: string]: unknown; +}; + +type AstProgram = { + type: "Program"; + body: Array; +}; + +// --- 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 { + const names: Array = []; + + 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): 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): 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): 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): 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 = {}, +): Array { + const options: LimitLineOptions = { ...DEFAULT_OPTIONS, ...userOptions, overrides: userOptions.overrides ?? [] }; + const resolve = createLimitResolver(options); + + const rawCodeCache = new Map(); + + 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 = []; + 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 }; diff --git a/packages/workflow-dashboard/src/app.tsx b/packages/workflow-dashboard/src/app.tsx index b96d11e..3ace0e7 100644 --- a/packages/workflow-dashboard/src/app.tsx +++ b/packages/workflow-dashboard/src/app.tsx @@ -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 setAuthed(true)} />; + return ; } return ( -
+
{ clearApiKey(); setAuthed(false); }} + theme={theme} + onToggleTheme={toggleTheme} />
- setShowRun(true)} /> + setShowRun(true)} />
- {!client && ( -
-

- Select an client from the sidebar to get started. -

-
- )} - {client && view === "threads" && threadId === null && ( - - )} - {client && view === "threads" && threadId !== null && ( - setThreadId(null)} /> - )} - {client && view === "workflows" && workflowName === null && ( - - )} - {client && view === "workflows" && workflowName !== null && ( - setWorkflowName(null)} - /> - )} +
- {showRun && client && ( - setShowRun(false)} - onCreated={(id) => { - setShowRun(false); - setThreadId(id); - }} - /> - )} + {client && }
); } diff --git a/packages/workflow-dashboard/src/components/client-redirect.tsx b/packages/workflow-dashboard/src/components/client-redirect.tsx new file mode 100644 index 0000000..6381e16 --- /dev/null +++ b/packages/workflow-dashboard/src/components/client-redirect.tsx @@ -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 ( +
+ +

Loading clients...

+
+ ); + } + + if (status === "ok" && data.length > 0) { + return ; + } + + return ( +
+ +

No client selected

+

+ Select a client from the sidebar to get started. +

+
+ ); +} diff --git a/packages/workflow-dashboard/src/components/login.tsx b/packages/workflow-dashboard/src/components/login.tsx index 6ee1afb..7e6eeb9 100644 --- a/packages/workflow-dashboard/src/components/login.tsx +++ b/packages/workflow-dashboard/src/components/login.tsx @@ -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(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 ( -
-
+ - -
+ {theme === "dark" ? : } + + + + + + Workflow Dashboard + + Enter your API key to continue + + +
+ setKey(e.target.value)} + placeholder="API Key" + className="transition-all duration-200" + /> + {error && ( +

+ + {error} +

+ )} + +
+
+
); } diff --git a/packages/workflow-dashboard/src/components/markdown.tsx b/packages/workflow-dashboard/src/components/markdown.tsx index d369e39..4407e90 100644 --- a/packages/workflow-dashboard/src/components/markdown.tsx +++ b/packages/workflow-dashboard/src/components/markdown.tsx @@ -52,19 +52,23 @@ function CodeBlock({ className, children }: { className?: string; children?: Rea if (html !== null) { return ( -
+
+ {lang !== "text" && ( + + {lang} + + )} +
+
); } return ( -
+    
       {code}
     
); @@ -80,8 +84,7 @@ export function Markdown({ content }: { content: string }) { if (isInline) { return ( {children} @@ -91,7 +94,7 @@ export function Markdown({ content }: { content: string }) { return {children}; }, p({ children }) { - return

{children}

; + return

{children}

; }, ul({ children }) { return
    {children}
; @@ -100,20 +103,25 @@ export function Markdown({ content }: { content: string }) { return
    {children}
; }, h1({ children }) { - return

{children}

; + return ( +

+ {children} +

+ ); }, h2({ children }) { - return

{children}

; + return ( +

+ {children} +

+ ); }, h3({ children }) { return

{children}

; }, blockquote({ children }) { return ( -
+
{children}
); diff --git a/packages/workflow-dashboard/src/components/record-card.tsx b/packages/workflow-dashboard/src/components/record-card.tsx index 5127135..1c9e007 100644 --- a/packages/workflow-dashboard/src/components/record-card.tsx +++ b/packages/workflow-dashboard/src/components/record-card.tsx @@ -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 = { - 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 ( -
+ +
- πŸš€ - - {record.workflow} - - + + {record.workflow} + {record.status} - +
{record.prompt !== null && ( -
-
+
+
+ Prompt
)} -
+ ); } function RoleMessage({ record, highlighted }: { record: RoleRecord; highlighted: boolean }) { - const color = roleColor(record.role); + const style = roleBadgeStyle(record.role); return ( -
+ {record.role} {formatTime(record.timestamp) !== null && ( - + + {formatTime(record.timestamp)} )}
-
+ ); } function ResultCard({ record }: { record: WorkflowResultRecord }) { const success = record.returnCode === 0; return ( -
- {success ? "βœ…" : "❌"} + {success ? ( + + ) : ( + + )} {success ? "Completed" : "Failed"} - + exit {record.returnCode} - + {formatTime(record.timestamp) !== null && ( - + + {formatTime(record.timestamp)} )}
-
+ ); } diff --git a/packages/workflow-dashboard/src/components/run-dialog.tsx b/packages/workflow-dashboard/src/components/run-dialog.tsx index 843ca13..6d94050 100644 --- a/packages/workflow-dashboard/src/components/run-dialog.tsx +++ b/packages/workflow-dashboard/src/components/run-dialog.tsx @@ -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 ( -
-
-

Run Thread on {client}

+ + + + Run Thread + Start a new thread on {client} +
-
-