From 4c31a5f80e593cb41a9de25588ceb6713ab10901 Mon Sep 17 00:00:00 2001 From: flowingfate Date: Mon, 18 May 2026 14:47:08 +0800 Subject: [PATCH] refactor: optimize ui for dashboard --- packages/workflow-dashboard/index.html | 8 + packages/workflow-dashboard/package.json | 13 +- .../plugins/vite-limit-line-plugin.ts | 226 ++++++++++++++ packages/workflow-dashboard/src/app.tsx | 10 +- .../src/components/client-redirect.tsx | 12 +- .../src/components/login.tsx | 100 ++++--- .../src/components/markdown.tsx | 44 +-- .../src/components/record-card.tsx | 115 ++++---- .../src/components/run-dialog.tsx | 121 +++----- .../src/components/sidebar.tsx | 153 +++++----- .../src/components/status-bar.tsx | 55 ++-- .../src/components/thread-detail.tsx | 126 ++++---- .../src/components/thread-list.tsx | 68 +++-- .../src/components/ui/badge.tsx | 30 ++ .../src/components/ui/button.tsx | 45 +++ .../src/components/ui/card.tsx | 36 +++ .../src/components/ui/collapsible.tsx | 7 + .../src/components/ui/dialog.tsx | 104 +++++++ .../src/components/ui/input.tsx | 17 ++ .../src/components/ui/resizable-panel.tsx | 73 +++++ .../src/components/ui/scroll-area.tsx | 42 +++ .../src/components/ui/select.tsx | 148 ++++++++++ .../src/components/ui/separator.tsx | 25 ++ .../src/components/ui/table.tsx | 69 +++++ .../src/components/ui/textarea.tsx | 16 + .../src/components/ui/tooltip.tsx | 28 ++ .../src/components/workflow-detail.tsx | 278 +++++++++--------- .../workflow-graph/condition-edge.tsx | 8 +- .../components/workflow-graph/role-node.tsx | 36 +-- .../workflow-graph/terminal-node.tsx | 24 +- .../workflow-graph/workflow-graph.tsx | 18 +- .../src/components/workflow-list.tsx | 54 ++-- .../src/hooks/use-theme.tsx | 74 +++++ packages/workflow-dashboard/src/index.css | 109 +++++-- packages/workflow-dashboard/src/lib/utils.ts | 6 + packages/workflow-dashboard/src/main.tsx | 5 +- packages/workflow-dashboard/src/vite-env.d.ts | 1 + packages/workflow-dashboard/tsconfig.json | 4 +- packages/workflow-dashboard/vite.config.ts | 7 +- 39 files changed, 1710 insertions(+), 605 deletions(-) create mode 100644 packages/workflow-dashboard/plugins/vite-limit-line-plugin.ts create mode 100644 packages/workflow-dashboard/src/components/ui/badge.tsx create mode 100644 packages/workflow-dashboard/src/components/ui/button.tsx create mode 100644 packages/workflow-dashboard/src/components/ui/card.tsx create mode 100644 packages/workflow-dashboard/src/components/ui/collapsible.tsx create mode 100644 packages/workflow-dashboard/src/components/ui/dialog.tsx create mode 100644 packages/workflow-dashboard/src/components/ui/input.tsx create mode 100644 packages/workflow-dashboard/src/components/ui/resizable-panel.tsx create mode 100644 packages/workflow-dashboard/src/components/ui/scroll-area.tsx create mode 100644 packages/workflow-dashboard/src/components/ui/select.tsx create mode 100644 packages/workflow-dashboard/src/components/ui/separator.tsx create mode 100644 packages/workflow-dashboard/src/components/ui/table.tsx create mode 100644 packages/workflow-dashboard/src/components/ui/textarea.tsx create mode 100644 packages/workflow-dashboard/src/components/ui/tooltip.tsx create mode 100644 packages/workflow-dashboard/src/hooks/use-theme.tsx create mode 100644 packages/workflow-dashboard/src/lib/utils.ts create mode 100644 packages/workflow-dashboard/src/vite-env.d.ts 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 c6027d7..dbf27ee 100644 --- a/packages/workflow-dashboard/package.json +++ b/packages/workflow-dashboard/package.json @@ -13,12 +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", "react-router": "^7.15.1", - "shiki": "^4.0.2" + "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..d0774ca --- /dev/null +++ b/packages/workflow-dashboard/plugins/vite-limit-line-plugin.ts @@ -0,0 +1,226 @@ +import { createFilter, type Plugin } from "vite"; + +type LimitLineOverride = { + files: string; + maxReactFCLines: number | null; + maxFileLines: number | null; +}; + +type LimitLineOptions = { + maxReactFCLines: number; + maxFileLines: number; + include: RegExp; + exclude: RegExp | null; + overrides: Array; +}; + +const DEFAULT_OPTIONS: LimitLineOptions = { + maxReactFCLines: 300, + maxFileLines: 600, + include: /\.[tj]sx$/, + exclude: null, + overrides: [], +}; + +type ResolvedLimits = { + maxReactFCLines: number | null; + maxFileLines: number | null; +}; + +type ComponentInfo = { + name: string; + startLine: number; + lineCount: number; +}; + +const PASCAL_CASE = /^[A-Z][A-Za-z0-9]*$/; + +type AstNode = { + type: string; + start: number; + end: number; + id: { name: string } | null; + init: AstNode | null; + declaration: AstNode | null; + declarations: Array<{ id: { name: string }; init: AstNode | null }>; + callee: { name: string } | null; + arguments: Array; + body: Array; + [key: string]: unknown; +}; + +function lineAt(code: string, offset: number): number { + let line = 1; + for (let i = 0; i < offset && i < code.length; i++) { + if (code[i] === "\n") line++; + } + return line; +} + +function lineSpan(code: string, start: number, end: number): { startLine: number; lineCount: number } { + const startLine = lineAt(code, start); + const endLine = lineAt(code, end); + return { startLine, lineCount: endLine - startLine + 1 }; +} + +function extractComponents(ast: AstNode, code: string): Array { + const results: Array = []; + + for (const node of ast.body ?? []) { + if (node.type === "FunctionDeclaration" && node.id && PASCAL_CASE.test(node.id.name)) { + const span = lineSpan(code, node.start, node.end); + results.push({ name: node.id.name, ...span }); + continue; + } + + if (node.type === "ExportNamedDeclaration" && node.declaration) { + const decl = node.declaration; + + if (decl.type === "FunctionDeclaration" && decl.id && PASCAL_CASE.test(decl.id.name)) { + const span = lineSpan(code, node.start, node.end); + results.push({ name: decl.id.name, ...span }); + continue; + } + + if (decl.type === "VariableDeclaration") { + collectFromVarDeclaration(decl, code, results); + continue; + } + } + + if (node.type === "VariableDeclaration") { + collectFromVarDeclaration(node, code, results); + } + } + + return results; +} + +function collectFromVarDeclaration(node: AstNode, code: string, results: Array): void { + for (const declarator of node.declarations ?? []) { + if (!declarator.id || !PASCAL_CASE.test(declarator.id.name) || !declarator.init) continue; + + const init = declarator.init; + + if (isFunctionLike(init)) { + const span = lineSpan(code, node.start, node.end); + results.push({ name: declarator.id.name, ...span }); + } else if (isWrapperCall(init) && init.arguments.length > 0 && isFunctionLike(init.arguments[0])) { + const span = lineSpan(code, node.start, node.end); + results.push({ name: declarator.id.name, ...span }); + } + } +} + +function isFunctionLike(node: AstNode): boolean { + return node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression"; +} + +const WRAPPER_NAMES = new Set(["memo", "forwardRef", "lazy"]); + +function isWrapperCall(node: AstNode): boolean { + return node.type === "CallExpression" && node.callee !== null && node.callee.name !== undefined && WRAPPER_NAMES.has(node.callee.name); +} + +function createLimitResolver(options: LimitLineOptions): (id: string) => ResolvedLimits { + const matchers = options.overrides.map((override) => ({ + match: createFilter(override.files), + maxReactFCLines: override.maxReactFCLines, + maxFileLines: override.maxFileLines, + })); + + return (id: string): ResolvedLimits => { + let maxReactFCLines: number | null = options.maxReactFCLines; + let maxFileLines: number | null = options.maxFileLines; + + for (const matcher of matchers) { + if (matcher.match(id)) { + maxReactFCLines = matcher.maxReactFCLines; + maxFileLines = matcher.maxFileLines; + } + } + + return { maxReactFCLines, maxFileLines }; + }; +} + +function shouldProcess(id: string, options: LimitLineOptions): boolean { + return options.include.test(id) && !id.includes("node_modules") && (options.exclude === null || !options.exclude.test(id)); +} + +function viteLimitLinePlugin( + userOptions: Partial = {}, +): Array { + const options: LimitLineOptions = { ...DEFAULT_OPTIONS, ...userOptions, overrides: userOptions.overrides ?? [] }; + const resolve = createLimitResolver(options); + + return [ + { + name: "vite-plugin-file-line-limit", + enforce: "pre", + + transform(code, id) { + if (!shouldProcess(id, options)) return null; + + const limits = resolve(id); + if (limits.maxFileLines === null) return null; + + const totalLines = code.split("\n").length; + if (totalLines > limits.maxFileLines) { + this.error( + [ + `[vite-limit-line] File too long: ${totalLines} lines (limit: ${limits.maxFileLines})`, + ` file: ${id}`, + "", + "How to fix:", + " Split this file into smaller modules β€” extract related types, helpers,", + " or sub-components into separate files and re-export from an index.ts.", + ].join("\n"), + ); + } + + return null; + }, + }, + { + name: "vite-plugin-react-fc-line-limit", + + transform(code, id) { + if (!shouldProcess(id, options)) return null; + + const limits = resolve(id); + if (limits.maxReactFCLines === null) return null; + + const ast = this.parse(code) as unknown as AstNode; + const components = extractComponents(ast, code); + const violations = components.filter((c) => c.lineCount > (limits.maxReactFCLines as number)); + + if (violations.length > 0) { + const details = violations + .map( + (v) => + ` ${v.name} (line ${v.startLine}): ${v.lineCount} lines (limit: ${limits.maxReactFCLines})`, + ) + .join("\n"); + + this.error( + [ + `[vite-limit-line] React component too long in ${id}:`, + details, + "", + "How to fix:", + " Break each oversized component into smaller ones. Extract reusable", + " sections into child components, move complex logic into custom hooks,", + " and keep each component focused on a single responsibility.", + ].join("\n"), + ); + } + + return null; + }, + }, + ]; +} + +export { viteLimitLinePlugin }; +export type { LimitLineOptions, LimitLineOverride }; diff --git a/packages/workflow-dashboard/src/app.tsx b/packages/workflow-dashboard/src/app.tsx index e0710d1..3ace0e7 100644 --- a/packages/workflow-dashboard/src/app.tsx +++ b/packages/workflow-dashboard/src/app.tsx @@ -4,23 +4,27 @@ import { clearApiKey, hasApiKey } from "./api.ts"; import { RunDialog } from "./components/run-dialog.tsx"; import { Sidebar } from "./components/sidebar.tsx"; import { StatusBar } from "./components/status-bar.tsx"; +import { useTheme } from "./hooks/use-theme.tsx"; export function Layout() { const [authed, setAuthed] = useState(hasApiKey()); const { client } = useParams(); const [showRun, setShowRun] = useState(false); + const { theme, toggleTheme } = useTheme(); if (!authed) { return ; } return ( -
+
{ clearApiKey(); setAuthed(false); }} + theme={theme} + onToggleTheme={toggleTheme} />
setShowRun(true)} /> @@ -28,9 +32,7 @@ export function Layout() {
- {showRun && client && ( - setShowRun(false)} /> - )} + {client && }
); } diff --git a/packages/workflow-dashboard/src/components/client-redirect.tsx b/packages/workflow-dashboard/src/components/client-redirect.tsx index a38ae34..6381e16 100644 --- a/packages/workflow-dashboard/src/components/client-redirect.tsx +++ b/packages/workflow-dashboard/src/components/client-redirect.tsx @@ -1,3 +1,4 @@ +import { Loader2, Users } from "lucide-react"; import { Navigate } from "react-router"; import { listClients } from "../api.ts"; import { useFetch } from "../hooks.ts"; @@ -7,8 +8,9 @@ export function ClientRedirect() { if (status === "loading") { return ( -
-

Loading clients...

+
+ +

Loading clients...

); } @@ -18,8 +20,10 @@ export function ClientRedirect() { } 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 9fff4b2..7e6eeb9 100644 --- a/packages/workflow-dashboard/src/components/login.tsx +++ b/packages/workflow-dashboard/src/components/login.tsx @@ -1,12 +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"; 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(); @@ -15,7 +21,6 @@ export function LoginPage() { 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`, { @@ -42,52 +47,55 @@ export function LoginPage() { } 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 ba32fc5..6d94050 100644 --- a/packages/workflow-dashboard/src/components/run-dialog.tsx +++ b/packages/workflow-dashboard/src/components/run-dialog.tsx @@ -2,13 +2,25 @@ 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; + open: boolean; + onOpenChange: (open: boolean) => void; }; -export function RunDialog({ client, onClose }: Props) { +export function RunDialog({ client, open, onOpenChange }: Props) { const navigate = useNavigate(); const workflows = useFetch(() => listWorkflows(client), [client]); const [workflow, setWorkflow] = useState(""); @@ -23,7 +35,7 @@ export function RunDialog({ client, onClose }: Props) { setError(null); try { const result = await runThread(client, workflow, prompt); - onClose(); + onOpenChange(false); navigate(`/${client}/threads/${result.threadId}`); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -32,95 +44,54 @@ export function RunDialog({ client, onClose }: Props) { } return ( -
-
-

Run Thread on {client}

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