From f705d9b8ea7ef29d7229d8c3321b1f2dadc40c61 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/package.json | 13 +- .../plugins/vite-limit-line-plugin.ts | 362 ++++++++++++++++++ .../src/components/thread-detail.tsx | 66 ++-- packages/workflow-dashboard/vite.config.ts | 7 +- 4 files changed, 404 insertions(+), 44 deletions(-) create mode 100644 packages/workflow-dashboard/plugins/vite-limit-line-plugin.ts 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..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/components/thread-detail.tsx b/packages/workflow-dashboard/src/components/thread-detail.tsx index d0c8c22..33a11a0 100644 --- a/packages/workflow-dashboard/src/components/thread-detail.tsx +++ b/packages/workflow-dashboard/src/components/thread-detail.tsx @@ -53,35 +53,6 @@ function computeNodeStates(records: readonly ThreadRecord[]): Map, 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, - clickCycleRef: { current: Map }, - 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() { const params = useParams(); const navigate = useNavigate(); @@ -128,29 +99,40 @@ export function ThreadDetail() { const clickCycleRef = useRef>(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(() => { diff --git a/packages/workflow-dashboard/vite.config.ts b/packages/workflow-dashboard/vite.config.ts index 25341b6..5e94c60 100644 --- a/packages/workflow-dashboard/vite.config.ts +++ b/packages/workflow-dashboard/vite.config.ts @@ -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: {