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/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: {