refactor(dashboard): replace vertical layout with side-by-side graph+cards

Change thread-detail from vertical (graph on top, cards below) to a
side-by-side layout:
- Left panel (280px, sticky): workflow graph, always visible
- Right panel (flex-1, scrollable): record cards
- Remove collapsible GraphPanel wrapper
- Graph acts as navigation (click node → scroll to card)

Refs: workflow thread 06F1NX4C9ET6HPXJAH7CWWF8MR
This commit is contained in:
2026-05-12 14:58:50 +08:00
parent ec3c97b200
commit 1871ef31b4
@@ -26,53 +26,6 @@ function extractWorkflowName(records: readonly ThreadRecord[]): string | null {
return null; return null;
} }
type GraphPanelProps = {
descriptor: WorkflowDescriptor;
workflowName: string | null;
nodeStates: Map<string, NodeState>;
onNodeClick: ((roleName: string) => void) | null;
};
function GraphPanel({ descriptor, workflowName, nodeStates, onNodeClick }: GraphPanelProps) {
const [open, setOpen] = useState(true);
const edgeCount = descriptor.graph.edges.length;
return (
<div
className="mb-4 rounded-lg border overflow-hidden"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between px-3 py-2 text-xs"
style={{ color: "var(--color-text-muted)" }}
>
<span className="font-mono">
{open ? "▼" : "▶"} Workflow graph
{workflowName !== null && (
<span className="ml-2" style={{ color: "var(--color-text)" }}>
{workflowName}
</span>
)}
</span>
<span>
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
</span>
</button>
{open && (
<div style={{ height: 300, width: "100%" }}>
<WorkflowGraph
graph={descriptor.graph}
roles={descriptor.roles}
nodeStates={nodeStates}
onNodeClick={onNodeClick}
/>
</div>
)}
</div>
);
}
function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeState> { function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeState> {
const states = new Map<string, NodeState>(); const states = new Map<string, NodeState>();
const roleRecords = records.filter( const roleRecords = records.filter(
@@ -227,46 +180,85 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
</p> </p>
)} )}
{descriptor !== null && descriptor.graph.edges.length > 0 && ( <div className="flex gap-4" style={{ minHeight: "calc(100vh - 120px)" }}>
<GraphPanel {descriptor !== null && descriptor.graph.edges.length > 0 && (
descriptor={descriptor} <div
workflowName={workflowName} className="shrink-0"
nodeStates={nodeStates} style={{
onNodeClick={handleGraphNodeClick} width: 280,
/> position: "sticky",
)} top: 16,
height: "calc(100vh - 120px)",
alignSelf: "flex-start",
}}
>
<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
{workflowName !== null && (
<span className="ml-2" style={{ color: "var(--color-text)" }}>
{workflowName}
</span>
)}
</span>
<span>
{descriptor.graph.edges.length} edge
{descriptor.graph.edges.length === 1 ? "" : "s"}
</span>
</div>
<div className="flex-1">
<WorkflowGraph
graph={descriptor.graph}
roles={descriptor.roles}
nodeStates={nodeStates}
onNodeClick={handleGraphNodeClick}
/>
</div>
</div>
</div>
)}
{status === "loading" && !liveActive && records.length === 0 && ( <div className="flex-1 min-w-0">
<p style={{ color: "var(--color-text-muted)" }}>Loading...</p> {status === "loading" && !liveActive && records.length === 0 && (
)} <p style={{ color: "var(--color-text-muted)" }}>Loading...</p>
{status === "error" && !liveActive && ( )}
<p style={{ color: "var(--color-error)" }}>Error: {error}</p> {status === "error" && !liveActive && (
)} <p style={{ color: "var(--color-error)" }}>Error: {error}</p>
{(status === "ok" || liveActive || records.length > 0) && ( )}
<div className="space-y-3"> {(status === "ok" || liveActive || records.length > 0) && (
{records.map((r, i) => { <div className="space-y-3">
const key = `${threadId}-${i}`; {records.map((r, i) => {
if (r.type === "role") { const key = `${threadId}-${i}`;
const isFirstForRole = firstIndexByRole.get(r.role) === i; if (r.type === "role") {
const flash = highlightedRole === r.role; const isFirstForRole = firstIndexByRole.get(r.role) === i;
return ( const flash = highlightedRole === r.role;
<div return (
key={key} <div
ref={(el) => { key={key}
if (!isFirstForRole) return; ref={(el) => {
if (el !== null) firstCardByRoleRef.current.set(r.role, el); if (!isFirstForRole) return;
else firstCardByRoleRef.current.delete(r.role); if (el !== null) firstCardByRoleRef.current.set(r.role, el);
}} else firstCardByRoleRef.current.delete(r.role);
> }}
<RecordCard record={r} highlighted={flash} /> >
</div> <RecordCard record={r} highlighted={flash} />
); </div>
} );
return <RecordCard key={key} record={r} highlighted={false} />; }
})} return <RecordCard key={key} record={r} highlighted={false} />;
<div ref={recordsEndRef} aria-hidden /> })}
<div ref={recordsEndRef} aria-hidden />
</div>
)}
</div> </div>
)} </div>
</div> </div>
); );
} }