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:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user