From c7b426ff5ad7b6828407b74b79c7e580878e2013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Thu, 14 May 2026 16:47:29 +0800 Subject: [PATCH 1/3] feat(dashboard): redesign workflow detail layout Left sidebar: compact workflow graph with clickable nodes for navigation. Right panel: workflow overview card + per-role cards with meta schema tables. Clicking a node in the graph scrolls to the corresponding role card. All nodes are lit in static view (workflow definition, not a thread). --- .../src/components/workflow-list.tsx | 234 ++++++++++++++---- 1 file changed, 187 insertions(+), 47 deletions(-) diff --git a/packages/workflow-dashboard/src/components/workflow-list.tsx b/packages/workflow-dashboard/src/components/workflow-list.tsx index d2bcb80..651182e 100644 --- a/packages/workflow-dashboard/src/components/workflow-list.tsx +++ b/packages/workflow-dashboard/src/components/workflow-list.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import type { WorkflowDetail } from "../api.ts"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { WorkflowDetail, WorkflowRoleDescriptor } from "../api.ts"; import { getWorkflowDetail, listWorkflows } from "../api.ts"; import { useFetch } from "../hooks.ts"; import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts"; @@ -17,6 +17,88 @@ function versionCount(detail: WorkflowDetail): number { return detail.history.length + 1; } +function schemaPropertiesTable(schema: Record): Array<{ + name: string; + type: string; + description: string; +}> { + const props = (schema.properties ?? {}) as Record>; + const required = new Set( + Array.isArray(schema.required) ? (schema.required as string[]) : [], + ); + return Object.entries(props).map(([name, prop]) => { + let type = String(prop.type ?? "unknown"); + if (!required.has(name)) type += "?"; + const description = String(prop.description ?? ""); + return { name, type, description }; + }); +} + +function RoleCard({ + roleName, + role, +}: { + roleName: string; + role: WorkflowRoleDescriptor; +}) { + const fields = schemaPropertiesTable(role.schema); + return ( +
+

+ {roleName} +

+ {role.description !== "" && ( +

+ {role.description} +

+ )} + {fields.length > 0 && ( +
+

+ Meta Schema +

+ + + + + + + + + + {fields.map((f) => ( + + + + + + ))} + +
FieldTypeDescription
{f.name}{f.type}{f.description || "—"}
+
+ )} + {fields.length === 0 && Object.keys(role.schema).length > 0 && ( +
+          {JSON.stringify(role.schema, null, 2)}
+        
+ )} +
+ ); +} + function ExpandedWorkflowBody({ cacheEntry, staticNodeStates, @@ -24,6 +106,9 @@ function ExpandedWorkflowBody({ cacheEntry: DetailCacheEntry | undefined; staticNodeStates: Map; }) { + const [highlightedRole, setHighlightedRole] = useState(null); + const highlightTimerRef = useRef | null>(null); + if (cacheEntry === undefined || cacheEntry.status === "loading") { return (

@@ -46,65 +131,120 @@ function ExpandedWorkflowBody({ const vc = versionCount(detail); const hasGraph = descriptor !== null && edgeCount > 0; + const roleEntries = + descriptor !== null ? Object.entries(descriptor.roles) : []; + + function handleGraphNodeClick(nodeId: string) { + const el = document.getElementById(`role-${nodeId}`); + if (el === null) return; + el.scrollIntoView({ behavior: "smooth", block: "center" }); + if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current); + setHighlightedRole(nodeId); + highlightTimerRef.current = setTimeout(() => { + setHighlightedRole(null); + highlightTimerRef.current = null; + }, 1500); + } + + // All roles are "completed" (static view, all nodes lit) + const allLitStates = useMemo(() => { + const m = new Map(); + m.set("__start__", "completed"); + m.set("__end__", "completed"); + for (const [name] of roleEntries) { + m.set(name, "completed"); + } + return m; + }, [roleEntries]); return (

-
-
-

- {detail.name} -

-

- Hash -

- - {detail.hash} - + {/* Left: graph sidebar */} + {hasGraph && ( +
+
+
+ Workflow graph + + {edgeCount} edge{edgeCount === 1 ? "" : "s"} + +
+
+ +
+
-

- {vc} version{vc !== 1 ? "s" : ""} -

-
-

- Description -

-

+ )} + + {/* Right: workflow info + role cards */} +

+ {/* Workflow overview */} +
+

+ {detail.name} +

+

{descriptor !== null && descriptor.description !== "" ? descriptor.description : descriptor !== null ? "—" : "No descriptor available for this workflow version."}

-
-
- {hasGraph ? ( -
-
- Workflow graph +
- {edgeCount} edge{edgeCount === 1 ? "" : "s"} + Hash:{" "} + + {detail.hash} + -
-
- + + {vc} version{vc !== 1 ? "s" : ""} + + {roleEntries.length > 0 && ( + + {roleEntries.length} role{roleEntries.length !== 1 ? "s" : ""} + + )}
- ) : null} + + {/* Role cards */} + {roleEntries.map(([name, role]) => ( +
+ +
+ ))} +
); } From b9d543a46565d7a6648fc9b9484bc078e6aeec5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Thu, 14 May 2026 16:53:47 +0800 Subject: [PATCH 2/3] fix: move hooks before early returns to fix Rules of Hooks crash --- .../src/components/workflow-list.tsx | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/workflow-dashboard/src/components/workflow-list.tsx b/packages/workflow-dashboard/src/components/workflow-list.tsx index 651182e..d6325fd 100644 --- a/packages/workflow-dashboard/src/components/workflow-list.tsx +++ b/packages/workflow-dashboard/src/components/workflow-list.tsx @@ -109,6 +109,21 @@ function ExpandedWorkflowBody({ const [highlightedRole, setHighlightedRole] = useState(null); const highlightTimerRef = useRef | null>(null); + const detail = cacheEntry?.status === "ok" ? cacheEntry.detail : null; + const descriptor = detail?.descriptor ?? null; + const roleEntries = descriptor !== null ? Object.entries(descriptor.roles) : []; + + // All roles are "completed" (static view, all nodes lit) — must be before early returns + const allLitStates = useMemo(() => { + const m = new Map(); + m.set("__start__", "completed"); + m.set("__end__", "completed"); + for (const [name] of roleEntries) { + m.set(name, "completed"); + } + return m; + }, [roleEntries]); + if (cacheEntry === undefined || cacheEntry.status === "loading") { return (

@@ -125,14 +140,10 @@ function ExpandedWorkflowBody({ ); } - const { detail } = cacheEntry; - const descriptor = detail.descriptor; const edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0; - const vc = versionCount(detail); + const vc = detail !== null ? versionCount(detail) : 0; const hasGraph = descriptor !== null && edgeCount > 0; - const roleEntries = - descriptor !== null ? Object.entries(descriptor.roles) : []; function handleGraphNodeClick(nodeId: string) { const el = document.getElementById(`role-${nodeId}`); @@ -146,17 +157,6 @@ function ExpandedWorkflowBody({ }, 1500); } - // All roles are "completed" (static view, all nodes lit) - const allLitStates = useMemo(() => { - const m = new Map(); - m.set("__start__", "completed"); - m.set("__end__", "completed"); - for (const [name] of roleEntries) { - m.set(name, "completed"); - } - return m; - }, [roleEntries]); - return (

{/* Left: graph sidebar */} From d037eca4ae5744d7b16f0f5c21247a35b3ce2a19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Thu, 14 May 2026 18:19:01 +0800 Subject: [PATCH 3/3] feat(dashboard): recursive schema rendering with nested object, array, oneOf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nested object: expand properties with └─ indentation - object[]: show type as 'object[]', expand items.properties - string[]/number[]: show type directly, no expansion - oneOf/discriminatedUnion: variant headers with ├/└ connectors - Auto-detect discriminator field (const values) - Skip discriminator in variant field list - Recursive to arbitrary depth Phase 1+2 of #258 --- .../src/components/workflow-list.tsx | 156 ++++++++++++++++-- 1 file changed, 141 insertions(+), 15 deletions(-) diff --git a/packages/workflow-dashboard/src/components/workflow-list.tsx b/packages/workflow-dashboard/src/components/workflow-list.tsx index d6325fd..10ef1a7 100644 --- a/packages/workflow-dashboard/src/components/workflow-list.tsx +++ b/packages/workflow-dashboard/src/components/workflow-list.tsx @@ -17,21 +17,134 @@ function versionCount(detail: WorkflowDetail): number { return detail.history.length + 1; } -function schemaPropertiesTable(schema: Record): Array<{ +type SchemaRow = { + key: string; name: string; type: string; description: string; -}> { + depth: number; + prefix: string; + isVariantHeader: boolean; +}; + +function resolveType(prop: Record): string { + if (prop.type === "array") { + const items = prop.items as Record | undefined; + if (items !== undefined) { + const itemType = String(items.type ?? "unknown"); + return `${itemType}[]`; + } + return "array"; + } + return String(prop.type ?? "unknown"); +} + +function flattenSchema( + schema: Record, + depth: number, + parentPrefix: string, + keyPrefix: string, + parentRequired: Set, +): SchemaRow[] { + const rows: SchemaRow[] = []; + + // Handle oneOf / discriminatedUnion + const oneOf = schema.oneOf as Array> | undefined; + if (Array.isArray(oneOf) && oneOf.length > 0) { + for (let vi = 0; vi < oneOf.length; vi++) { + const variant = oneOf[vi]; + // Try to find a distinguishing literal (e.g. status: "approved") + const variantProps = (variant.properties ?? {}) as Record>; + 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( + Array.isArray(variant.required) ? (variant.required as string[]) : [], + ); + for (const [pName, pDef] of Object.entries(variantProps)) { + if (pDef.const !== undefined) continue; // skip discriminator field + const subRows = flattenProperty(pName, pDef, depth + 1, childPrefix, `${keyPrefix}v${vi}-`, variantRequired); + rows.push(...subRows); + } + } + return rows; + } + + // Handle regular object with properties const props = (schema.properties ?? {}) as Record>; const required = new Set( Array.isArray(schema.required) ? (schema.required as string[]) : [], ); - return Object.entries(props).map(([name, prop]) => { - let type = String(prop.type ?? "unknown"); - if (!required.has(name)) type += "?"; - const description = String(prop.description ?? ""); - return { name, type, description }; + for (const [name, prop] of Object.entries(props)) { + const subRows = flattenProperty(name, prop, depth, parentPrefix, keyPrefix, required); + rows.push(...subRows); + } + return rows; +} + +function flattenProperty( + name: string, + prop: Record, + depth: number, + parentPrefix: string, + keyPrefix: string, + required: Set, +): 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; + + rows.push({ + key: `${keyPrefix}${name}`, + name: displayName, + type, + description, + depth, + prefix: parentPrefix, + isVariantHeader: false, }); + + // Recurse into nested object + if (prop.type === "object" && prop.properties !== undefined) { + const childPrefix = depth > 0 ? `${parentPrefix} ` : " "; + rows.push(...flattenSchema(prop as Record, depth + 1, childPrefix, `${keyPrefix}${name}-`, required)); + } + + // Recurse into array of objects + if (prop.type === "array") { + const items = prop.items as Record | undefined; + if (items !== undefined && items.type === "object" && items.properties !== undefined) { + const childPrefix = depth > 0 ? `${parentPrefix} ` : " "; + rows.push(...flattenSchema(items, depth + 1, childPrefix, `${keyPrefix}${name}-`, new Set())); + } + } + + // Recurse into oneOf + if (hasOneOf) { + const childPrefix = depth > 0 ? `${parentPrefix} ` : " "; + rows.push(...flattenSchema(prop as Record, depth + 1, childPrefix, `${keyPrefix}${name}-`, required)); + } + + return rows; } function RoleCard({ @@ -41,7 +154,7 @@ function RoleCard({ roleName: string; role: WorkflowRoleDescriptor; }) { - const fields = schemaPropertiesTable(role.schema); + const rows = flattenSchema(role.schema, 0, "", `${roleName}-`, new Set()); return (
)} - {fields.length > 0 && ( + {rows.length > 0 && (

- {fields.map((f) => ( - - {f.name} - {f.type} - {f.description || "—"} + {rows.map((r) => ( + + + {r.name} + + {r.type} + {r.description || (r.isVariantHeader ? "" : "—")} ))}

)} - {fields.length === 0 && Object.keys(role.schema).length > 0 && ( + {rows.length === 0 && Object.keys(role.schema).length > 0 && (