From f5977c46c6e8a799531157feffe582a5c33b457f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Thu, 14 May 2026 21:05:00 +0800 Subject: [PATCH] feat(dashboard): workflow detail as separate page with fixed graph sidebar - Workflow list is now a simple clickable list (no expand/collapse) - Clicking a workflow navigates to dedicated detail page (#client/workflows/name) - Detail page: fixed graph sidebar on left, scrollable role cards on right - Back button returns to workflow list - Route: added workflowName to hash routing --- packages/workflow-dashboard/src/app.tsx | 10 +- .../src/components/workflow-detail.tsx | 372 +++++++++++++ .../src/components/workflow-list.tsx | 511 +----------------- .../workflow-dashboard/src/use-hash-route.ts | 23 +- 4 files changed, 428 insertions(+), 488 deletions(-) create mode 100644 packages/workflow-dashboard/src/components/workflow-detail.tsx diff --git a/packages/workflow-dashboard/src/app.tsx b/packages/workflow-dashboard/src/app.tsx index c45ba72..612dc3f 100644 --- a/packages/workflow-dashboard/src/app.tsx +++ b/packages/workflow-dashboard/src/app.tsx @@ -6,12 +6,13 @@ 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"; export function App() { const [authed, setAuthed] = useState(hasApiKey()); - const { view, client, threadId, setView, setClient, setThreadId } = useHashRoute(); + const { view, client, threadId, workflowName, setView, setClient, setThreadId, setWorkflowName } = useHashRoute(); const [showRun, setShowRun] = useState(false); if (!authed) { @@ -46,7 +47,12 @@ export function App() { {client && view === "threads" && threadId !== null && ( setThreadId(null)} /> )} - {client && view === "workflows" && } + {client && view === "workflows" && workflowName === null && ( + + )} + {client && view === "workflows" && workflowName !== null && ( + setWorkflowName(null)} /> + )} {showRun && client && ( diff --git a/packages/workflow-dashboard/src/components/workflow-detail.tsx b/packages/workflow-dashboard/src/components/workflow-detail.tsx new file mode 100644 index 0000000..0a58779 --- /dev/null +++ b/packages/workflow-dashboard/src/components/workflow-detail.tsx @@ -0,0 +1,372 @@ +import { useMemo, useRef, useState } from "react"; +import type { WorkflowDetail as WorkflowDetailData, WorkflowRoleDescriptor } from "../api.ts"; +import { getWorkflowDetail } from "../api.ts"; +import { useFetch } from "../hooks.ts"; +import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts"; + +type Props = { + client: string; + workflowName: string; + onBack: () => void; +}; + +function versionCount(detail: WorkflowDetailData): number { + return detail.history.length + 1; +} + +// ── Schema rendering helpers ──────────────────────────────────────── + +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]; + 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; + const subRows = flattenProperty(pName, pDef, depth + 1, childPrefix, `${keyPrefix}v${vi}-`, variantRequired); + rows.push(...subRows); + } + } + return rows; + } + + const props = (schema.properties ?? {}) as Record>; + const required = new Set( + Array.isArray(schema.required) ? (schema.required as string[]) : [], + ); + 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, + }); + + if (prop.type === "object" && prop.properties !== undefined) { + const childPrefix = depth > 0 ? `${parentPrefix} ` : " "; + rows.push(...flattenSchema(prop as Record, depth + 1, childPrefix, `${keyPrefix}${name}-`, required)); + } + + 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())); + } + } + + if (hasOneOf) { + const childPrefix = depth > 0 ? `${parentPrefix} ` : " "; + rows.push(...flattenSchema(prop as Record, depth + 1, childPrefix, `${keyPrefix}${name}-`, required)); + } + + return rows; +} + +// ── Components ────────────────────────────────────────────────────── + +function RoleCard({ + roleName, + role, +}: { + roleName: string; + role: WorkflowRoleDescriptor; +}) { + const rows = flattenSchema(role.schema, 0, "", `${roleName}-`, new Set()); + return ( +
+

+ {roleName} +

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

+ {role.description} +

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

+ Meta Schema +

+ + + + + + + + + + {rows.map((r) => ( + + + + + + ))} + +
FieldTypeDescription
+ {r.name} + {r.type}{r.description || (r.isVariantHeader ? "" : "—")}
+
+ )} + {rows.length === 0 && Object.keys(role.schema).length > 0 && ( +
+          {JSON.stringify(role.schema, null, 2)}
+        
+ )} +
+ ); +} + +// ── Main component ────────────────────────────────────────────────── + +export function WorkflowDetail({ client, workflowName, onBack }: Props) { + const { status, data, error } = useFetch( + () => getWorkflowDetail(client, workflowName), + [client, workflowName], + ); + + const [highlightedRole, setHighlightedRole] = useState(null); + const highlightTimerRef = useRef | null>(null); + + const detail = status === "ok" ? data : null; + const descriptor = detail?.descriptor ?? null; + const roleEntries = descriptor !== null ? Object.entries(descriptor.roles) : []; + const edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0; + const hasGraph = descriptor !== null && edgeCount > 0; + + 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]); + + 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); + } + + return ( +
+
+ +
+ +

{workflowName}

+ + {status === "loading" && ( +

Loading...

+ )} + {status === "error" && ( +

Error: {error}

+ )} + + {detail !== null && ( +
+ {/* Left: fixed graph sidebar */} + {hasGraph && ( +
+
+
+ Workflow graph + + {edgeCount} edge{edgeCount === 1 ? "" : "s"} + +
+
+ +
+
+
+ )} + + {/* Right: scrollable content */} +
+ {/* Workflow overview */} +
+

+ {descriptor !== null && descriptor.description !== "" + ? descriptor.description + : "—"} +

+
+ + Hash:{" "} + + {detail.hash} + + + + {versionCount(detail)} version{versionCount(detail) !== 1 ? "s" : ""} + + {roleEntries.length > 0 && ( + + {roleEntries.length} role{roleEntries.length !== 1 ? "s" : ""} + + )} +
+
+ + {/* Role cards */} + {roleEntries.map(([name, role]) => ( +
+ +
+ ))} +
+
+ )} +
+ ); +} diff --git a/packages/workflow-dashboard/src/components/workflow-list.tsx b/packages/workflow-dashboard/src/components/workflow-list.tsx index 10ef1a7..c4bbf91 100644 --- a/packages/workflow-dashboard/src/components/workflow-list.tsx +++ b/packages/workflow-dashboard/src/components/workflow-list.tsx @@ -1,441 +1,13 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { WorkflowDetail, WorkflowRoleDescriptor } from "../api.ts"; -import { getWorkflowDetail, listWorkflows } from "../api.ts"; +import { listWorkflows } from "../api.ts"; import { useFetch } from "../hooks.ts"; -import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts"; type Props = { client: string; + onSelect: (name: string) => void; }; -type DetailCacheEntry = - | { status: "loading" } - | { status: "error"; message: string } - | { status: "ok"; detail: WorkflowDetail }; - -function versionCount(detail: WorkflowDetail): number { - return detail.history.length + 1; -} - -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[]) : [], - ); - 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({ - roleName, - role, -}: { - roleName: string; - role: WorkflowRoleDescriptor; -}) { - const rows = flattenSchema(role.schema, 0, "", `${roleName}-`, new Set()); - return ( -
-

- {roleName} -

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

- {role.description} -

- )} - {rows.length > 0 && ( -
-

- Meta Schema -

- - - - - - - - - - {rows.map((r) => ( - - - - - - ))} - -
FieldTypeDescription
- {r.name} - {r.type}{r.description || (r.isVariantHeader ? "" : "—")}
-
- )} - {rows.length === 0 && Object.keys(role.schema).length > 0 && ( -
-          {JSON.stringify(role.schema, null, 2)}
-        
- )} -
- ); -} - -function ExpandedWorkflowBody({ - cacheEntry, - staticNodeStates, -}: { - cacheEntry: DetailCacheEntry | undefined; - staticNodeStates: Map; -}) { - 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 ( -

- Loading workflow details... -

- ); - } - - if (cacheEntry.status === "error") { - return ( -

- {cacheEntry.message} -

- ); - } - - const edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0; - const vc = detail !== null ? versionCount(detail) : 0; - - const hasGraph = descriptor !== null && edgeCount > 0; - - 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); - } - - return ( -
- {/* Left: graph sidebar */} - {hasGraph && ( -
-
-
- Workflow graph - - {edgeCount} edge{edgeCount === 1 ? "" : "s"} - -
-
- -
-
-
- )} - - {/* Right: workflow info + role cards */} -
- {/* Workflow overview */} -
-

- {detail.name} -

-

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

-
- - Hash:{" "} - - {detail.hash} - - - - {vc} version{vc !== 1 ? "s" : ""} - - {roleEntries.length > 0 && ( - - {roleEntries.length} role{roleEntries.length !== 1 ? "s" : ""} - - )} -
-
- - {/* Role cards */} - {roleEntries.map(([name, role]) => ( -
- -
- ))} -
-
- ); -} - -export function WorkflowList({ client }: Props) { +export function WorkflowList({ client, onSelect }: Props) { const { status, data, error } = useFetch(() => listWorkflows(client), [client]); - const [expanded, setExpanded] = useState>(() => new Set()); - const [detailsByName, setDetailsByName] = useState>( - () => new Map(), - ); - - const staticNodeStates = useMemo(() => new Map(), []); - - // biome-ignore lint/correctness/useExhaustiveDependencies: reset expansion when switching clients - useEffect(() => { - setExpanded(new Set()); - setDetailsByName(new Map()); - }, [client]); - - const ensureDetailLoaded = useCallback( - (name: string) => { - setDetailsByName((prev) => { - const cur = prev.get(name); - if (cur !== undefined && (cur.status === "ok" || cur.status === "loading")) { - return prev; - } - return new Map(prev).set(name, { status: "loading" }); - }); - - void (async () => { - try { - const detail = await getWorkflowDetail(client, name); - setDetailsByName((prev) => { - const next = new Map(prev); - next.set(name, { status: "ok", detail }); - return next; - }); - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - setDetailsByName((prev) => { - const next = new Map(prev); - next.set(name, { status: "error", message }); - return next; - }); - } - })(); - }, - [client], - ); - - function toggleExpanded(name: string) { - const wasExpanded = expanded.has(name); - setExpanded((prev) => { - const next = new Set(prev); - if (next.has(name)) { - next.delete(name); - } else { - next.add(name); - } - return next; - }); - if (!wasExpanded) { - ensureDetailLoaded(name); - } - } if (status === "loading") return

Loading workflows...

; @@ -450,58 +22,33 @@ export function WorkflowList({ client }: Props) {

No workflows registered.

) : (
- {workflows.map((w) => { - const isOpen = expanded.has(w.name); - return ( -
- - {isOpen ? ( -
- -
- ) : null} + {workflows.map((w) => ( + + ))}
)}
diff --git a/packages/workflow-dashboard/src/use-hash-route.ts b/packages/workflow-dashboard/src/use-hash-route.ts index 897129f..b3d6dc7 100644 --- a/packages/workflow-dashboard/src/use-hash-route.ts +++ b/packages/workflow-dashboard/src/use-hash-route.ts @@ -6,6 +6,7 @@ type HashRoute = { view: View; client: string | null; threadId: string | null; + workflowName: string | null; }; function parseHash(hash: string): HashRoute { @@ -19,6 +20,7 @@ function parseHash(hash: string): HashRoute { view: parts[0] as View, client: null, threadId: parts[0] === "threads" && parts.length > 1 ? parts.slice(1).join("/") : null, + workflowName: parts[0] === "workflows" && parts.length > 1 ? parts.slice(1).join("/") : null, }; } @@ -27,13 +29,17 @@ function parseHash(hash: string): HashRoute { const viewPart = parts[1] ?? "threads"; const view: View = viewPart === "workflows" ? "workflows" : "threads"; const threadId = view === "threads" && parts.length > 2 ? parts.slice(2).join("/") : null; + const workflowName = view === "workflows" && parts.length > 2 ? parts.slice(2).join("/") : null; - return { view, client, threadId }; + return { view, client, threadId, workflowName }; } function buildHash(route: HashRoute): string { const prefix = route.client ? `${route.client}/` : ""; if (route.view === "workflows") { + if (route.workflowName !== null) { + return `#${prefix}workflows/${route.workflowName}`; + } return `#${prefix}workflows`; } if (route.threadId !== null) { @@ -46,9 +52,11 @@ export function useHashRoute(): { view: View; client: string | null; threadId: string | null; + workflowName: string | null; setView: (v: View) => void; setClient: (a: string | null) => void; setThreadId: (id: string | null) => void; + setWorkflowName: (name: string | null) => void; } { const [route, setRoute] = useState(() => parseHash(window.location.hash)); @@ -67,17 +75,22 @@ export function useHashRoute(): { }, []); const setView = useCallback( - (v: View) => navigate({ view: v, client: route.client, threadId: null }), + (v: View) => navigate({ view: v, client: route.client, threadId: null, workflowName: null }), [navigate, route.client], ); const setClient = useCallback( - (a: string | null) => navigate({ view: route.view, client: a, threadId: null }), + (a: string | null) => navigate({ view: route.view, client: a, threadId: null, workflowName: null }), [navigate, route.view], ); const setThreadId = useCallback( - (id: string | null) => navigate({ view: "threads", client: route.client, threadId: id }), + (id: string | null) => navigate({ view: "threads", client: route.client, threadId: id, workflowName: null }), + [navigate, route.client], + ); + + const setWorkflowName = useCallback( + (name: string | null) => navigate({ view: "workflows", client: route.client, threadId: null, workflowName: name }), [navigate, route.client], ); @@ -85,8 +98,10 @@ export function useHashRoute(): { view: route.view, client: route.client, threadId: route.threadId, + workflowName: route.workflowName, setView, setClient, setThreadId, + setWorkflowName, }; }