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).
This commit is contained in:
2026-05-14 16:47:29 +08:00
parent d140801337
commit c7b426ff5a
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { WorkflowDetail } from "../api.ts"; import type { WorkflowDetail, WorkflowRoleDescriptor } from "../api.ts";
import { getWorkflowDetail, listWorkflows } from "../api.ts"; import { getWorkflowDetail, listWorkflows } from "../api.ts";
import { useFetch } from "../hooks.ts"; import { useFetch } from "../hooks.ts";
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts"; import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
@@ -17,6 +17,88 @@ function versionCount(detail: WorkflowDetail): number {
return detail.history.length + 1; return detail.history.length + 1;
} }
function schemaPropertiesTable(schema: Record<string, unknown>): Array<{
name: string;
type: string;
description: string;
}> {
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
const required = new Set<string>(
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 (
<div
id={`role-${roleName}`}
className="rounded-lg border p-4"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<h4
className="text-sm font-semibold font-mono mb-1"
style={{ color: "var(--color-text)" }}
>
{roleName}
</h4>
{role.description !== "" && (
<p className="text-xs mb-3" style={{ color: "var(--color-text-muted)" }}>
{role.description}
</p>
)}
{fields.length > 0 && (
<div>
<p
className="text-[10px] uppercase tracking-wider mb-1 font-medium"
style={{ color: "var(--color-text-muted)" }}
>
Meta Schema
</p>
<table className="w-full text-xs" style={{ borderCollapse: "collapse" }}>
<thead>
<tr style={{ borderBottom: "1px solid var(--color-border)" }}>
<th className="text-left py-1 pr-3 font-medium" style={{ color: "var(--color-text-muted)" }}>Field</th>
<th className="text-left py-1 pr-3 font-medium" style={{ color: "var(--color-text-muted)" }}>Type</th>
<th className="text-left py-1 font-medium" style={{ color: "var(--color-text-muted)" }}>Description</th>
</tr>
</thead>
<tbody>
{fields.map((f) => (
<tr key={f.name} style={{ borderBottom: "1px solid var(--color-border)" }}>
<td className="py-1 pr-3 font-mono" style={{ color: "var(--color-accent)" }}>{f.name}</td>
<td className="py-1 pr-3 font-mono" style={{ color: "var(--color-text-muted)" }}>{f.type}</td>
<td className="py-1" style={{ color: "var(--color-text)" }}>{f.description || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{fields.length === 0 && Object.keys(role.schema).length > 0 && (
<pre
className="text-[10px] font-mono p-2 rounded overflow-x-auto"
style={{ background: "var(--color-bg)", color: "var(--color-text-muted)" }}
>
{JSON.stringify(role.schema, null, 2)}
</pre>
)}
</div>
);
}
function ExpandedWorkflowBody({ function ExpandedWorkflowBody({
cacheEntry, cacheEntry,
staticNodeStates, staticNodeStates,
@@ -24,6 +106,9 @@ function ExpandedWorkflowBody({
cacheEntry: DetailCacheEntry | undefined; cacheEntry: DetailCacheEntry | undefined;
staticNodeStates: Map<string, NodeState>; staticNodeStates: Map<string, NodeState>;
}) { }) {
const [highlightedRole, setHighlightedRole] = useState<string | null>(null);
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
if (cacheEntry === undefined || cacheEntry.status === "loading") { if (cacheEntry === undefined || cacheEntry.status === "loading") {
return ( return (
<p className="text-sm py-2" style={{ color: "var(--color-text-muted)" }}> <p className="text-sm py-2" style={{ color: "var(--color-text-muted)" }}>
@@ -46,65 +131,120 @@ function ExpandedWorkflowBody({
const vc = versionCount(detail); const vc = versionCount(detail);
const hasGraph = descriptor !== null && edgeCount > 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}`);
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<string, NodeState>();
m.set("__start__", "completed");
m.set("__end__", "completed");
for (const [name] of roleEntries) {
m.set(name, "completed");
}
return m;
}, [roleEntries]);
return ( return (
<div className="pt-3 border-t flex gap-4" style={{ borderColor: "var(--color-border)" }}> <div className="pt-3 border-t flex gap-4" style={{ borderColor: "var(--color-border)" }}>
<div className="space-y-3 shrink-0" style={{ minWidth: 200, maxWidth: 280 }}> {/* Left: graph sidebar */}
<div> {hasGraph && (
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}> <div
{detail.name} className="shrink-0"
</p> style={{
<p className="text-xs mt-1 mb-1" style={{ color: "var(--color-text-muted)" }}> width: 280,
Hash position: "sticky",
</p> top: 16,
<code className="text-xs font-mono block" style={{ color: "var(--color-accent)" }}> height: "calc(100vh - 120px)",
{detail.hash} alignSelf: "flex-start",
</code> }}
>
<div
className="rounded-lg border h-full flex flex-col overflow-hidden"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div
className="flex items-center justify-between px-3 py-2 text-xs"
style={{ color: "var(--color-text-muted)" }}
>
<span className="font-mono">Workflow graph</span>
<span>
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
</span>
</div>
<div className="flex-1">
<WorkflowGraph
graph={descriptor.graph}
roles={descriptor.roles}
nodeStates={allLitStates}
onNodeClick={handleGraphNodeClick}
/>
</div>
</div>
</div> </div>
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}> )}
{vc} version{vc !== 1 ? "s" : ""}
</p> {/* Right: workflow info + role cards */}
<div> <div className="flex-1 min-w-0 space-y-4">
<p className="text-xs mb-1 font-medium" style={{ color: "var(--color-text-muted)" }}> {/* Workflow overview */}
Description <div
</p> className="rounded-lg border p-4"
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--color-text)" }}> style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<h3 className="text-base font-semibold mb-2" style={{ color: "var(--color-text)" }}>
{detail.name}
</h3>
<p className="text-sm whitespace-pre-wrap mb-3" style={{ color: "var(--color-text)" }}>
{descriptor !== null && descriptor.description !== "" {descriptor !== null && descriptor.description !== ""
? descriptor.description ? descriptor.description
: descriptor !== null : descriptor !== null
? "—" ? "—"
: "No descriptor available for this workflow version."} : "No descriptor available for this workflow version."}
</p> </p>
</div> <div className="flex gap-4 text-xs" style={{ color: "var(--color-text-muted)" }}>
</div>
{hasGraph ? (
<div
className="rounded-lg border overflow-hidden flex-1"
style={{
borderColor: "var(--color-border)",
background: "var(--color-bg)",
minHeight: 500,
}}
>
<div
className="px-3 py-2 text-xs flex justify-between items-center"
style={{ color: "var(--color-text-muted)", background: "var(--color-surface)" }}
>
<span className="font-mono">Workflow graph</span>
<span> <span>
{edgeCount} edge{edgeCount === 1 ? "" : "s"} Hash:{" "}
<code className="font-mono" style={{ color: "var(--color-accent)" }}>
{detail.hash}
</code>
</span> </span>
</div> <span>
<div style={{ height: 600, width: "100%" }}> {vc} version{vc !== 1 ? "s" : ""}
<WorkflowGraph </span>
graph={descriptor.graph} {roleEntries.length > 0 && (
roles={descriptor.roles} <span>
nodeStates={staticNodeStates} {roleEntries.length} role{roleEntries.length !== 1 ? "s" : ""}
onNodeClick={null} </span>
/> )}
</div> </div>
</div> </div>
) : null}
{/* Role cards */}
{roleEntries.map(([name, role]) => (
<div
key={name}
style={{
transition: "box-shadow 0.3s",
boxShadow: highlightedRole === name ? "0 0 0 2px var(--color-accent)" : "none",
borderRadius: 8,
}}
>
<RoleCard roleName={name} role={role} />
</div>
))}
</div>
</div> </div>
); );
} }