Merge pull request 'feat(dashboard): graph node click improvements' (#255) from feat/graph-interactions into main

This commit is contained in:
2026-05-14 08:29:29 +00:00
6 changed files with 68 additions and 28 deletions
@@ -39,7 +39,8 @@ function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeSt
states.set(role, !hasResult && isLast ? "active" : "completed"); states.set(role, !hasResult && isLast ? "active" : "completed");
} }
if (roleRecords.length > 0) { const hasStart = records.some((r) => r.type === "thread-start");
if (hasStart) {
states.set("__start__", "completed"); states.set("__start__", "completed");
} }
if (hasResult) { if (hasResult) {
@@ -79,28 +80,58 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
const descriptor = descriptorFetch.status === "ok" ? descriptorFetch.data : null; const descriptor = descriptorFetch.status === "ok" ? descriptorFetch.data : null;
const nodeStates = useMemo(() => computeNodeStates(records), [records]); const nodeStates = useMemo(() => computeNodeStates(records), [records]);
const firstIndexByRole = useMemo(() => { const indicesByRole = useMemo(() => {
const m = new Map<string, number>(); const m = new Map<string, number[]>();
for (let i = 0; i < records.length; i++) { for (let i = 0; i < records.length; i++) {
const r = records[i]; const r = records[i];
if (r.type === "role" && !m.has(r.role)) { if (r.type === "role") {
m.set(r.role, i); const list = m.get(r.role) ?? [];
list.push(i);
m.set(r.role, list);
} }
} }
return m; return m;
}, [records]); }, [records]);
const handleGraphNodeClick = useCallback((roleName: string) => { // Track which occurrence to jump to next per role (cycling)
const el = firstCardByRoleRef.current.get(roleName); const clickCycleRef = useRef<Map<string, number>>(new Map());
if (el == null) return;
el.scrollIntoView({ behavior: "smooth", block: "center" }); const handleGraphNodeClick = useCallback((nodeId: string) => {
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current); // Only allow clicks on lit (non-default) nodes
setHighlightedRole(roleName); if (nodeStates.get(nodeId) === undefined || nodeStates.get(nodeId) === "default") return;
highlightTimerRef.current = setTimeout(() => {
setHighlightedRole(null); // __start__: scroll to the first record (thread-start prompt)
highlightTimerRef.current = null; if (nodeId === "__start__") {
}, 1500); const firstCard = document.querySelector('[data-record-index="0"]');
}, []); if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
return;
}
// __end__: scroll to bottom
if (nodeId === "__end__") {
recordsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
return;
}
// Role nodes: cycle through occurrences
const indices = indicesByRole.get(nodeId);
if (indices === undefined || indices.length === 0) return;
const cycle = clickCycleRef.current.get(nodeId) ?? 0;
const idx = indices[cycle % indices.length];
clickCycleRef.current.set(nodeId, cycle + 1);
const el = document.querySelector(`[data-record-index="${idx}"]`);
if (el !== null) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
setHighlightedRole(nodeId);
highlightTimerRef.current = setTimeout(() => {
setHighlightedRole(null);
highlightTimerRef.current = null;
}, 1500);
}
}, [nodeStates, indicesByRole]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -237,11 +268,13 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
{records.map((r, i) => { {records.map((r, i) => {
const key = `${threadId}-${i}`; const key = `${threadId}-${i}`;
if (r.type === "role") { if (r.type === "role") {
const isFirstForRole = firstIndexByRole.get(r.role) === i; const roleIndices = indicesByRole.get(r.role);
const isFirstForRole = roleIndices !== undefined && roleIndices[0] === i;
const flash = highlightedRole === r.role; const flash = highlightedRole === r.role;
return ( return (
<div <div
key={key} key={key}
data-record-index={i}
ref={(el) => { ref={(el) => {
if (!isFirstForRole) return; if (!isFirstForRole) return;
if (el !== null) firstCardByRoleRef.current.set(r.role, el); if (el !== null) firstCardByRoleRef.current.set(r.role, el);
@@ -252,7 +285,7 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
</div> </div>
); );
} }
return <RecordCard key={key} record={r} highlighted={false} />; return <div key={key} data-record-index={i}><RecordCard record={r} highlighted={false} /></div>;
})} })}
<div ref={recordsEndRef} aria-hidden /> <div ref={recordsEndRef} aria-hidden />
</div> </div>
@@ -2,7 +2,7 @@ import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath } from "
import type { ConditionEdgeData } from "./types.ts"; import type { ConditionEdgeData } from "./types.ts";
// Must match the FEEDBACK_OFFSET_X in use-layout.ts // Must match the FEEDBACK_OFFSET_X in use-layout.ts
const FEEDBACK_OFFSET_X = 140; const FEEDBACK_OFFSET_X = 80;
// Radius for feedback edge corners // Radius for feedback edge corners
const FEEDBACK_RADIUS = 16; const FEEDBACK_RADIUS = 16;
@@ -45,7 +45,11 @@ export function RoleNode(props: NodeProps) {
}} }}
title={data.description} title={data.description}
> >
<Handle type="target" position={Position.Top} style={handleStyle} isConnectable={false} /> <Handle type="target" position={Position.Top} id="top-in" style={handleStyle} isConnectable={false} />
<Handle type="target" position={Position.Left} id="left-in" style={handleStyle} isConnectable={false} />
<Handle type="target" position={Position.Right} id="right-in" style={handleStyle} isConnectable={false} />
<Handle type="source" position={Position.Left} id="left-out" style={handleStyle} isConnectable={false} />
<Handle type="source" position={Position.Right} id="right-out" style={handleStyle} isConnectable={false} />
<div className="flex items-center gap-1.5 font-mono"> <div className="flex items-center gap-1.5 font-mono">
{icon !== null && ( {icon !== null && (
<span <span
@@ -63,7 +67,7 @@ export function RoleNode(props: NodeProps) {
{data.description} {data.description}
</div> </div>
)} )}
<Handle type="source" position={Position.Bottom} style={handleStyle} isConnectable={false} /> <Handle type="source" position={Position.Bottom} id="bottom-out" style={handleStyle} isConnectable={false} />
</div> </div>
); );
} }
@@ -31,7 +31,7 @@ export function TerminalNode(props: NodeProps) {
return ( return (
<div <div
className={`rounded-full border-2 flex items-center justify-center text-[10px] font-bold ${isActive ? "wf-node-pulse" : ""}`} className={`rounded-full border-2 flex items-center justify-center text-[10px] font-bold ${isActive ? "wf-node-pulse" : ""} ${data.state !== "default" ? "cursor-pointer" : ""}`}
style={{ style={{
width: 40, width: 40,
height: 40, height: 40,
@@ -45,11 +45,12 @@ export function TerminalNode(props: NodeProps) {
<Handle <Handle
type="source" type="source"
position={Position.Bottom} position={Position.Bottom}
id="bottom-out"
style={handleStyle} style={handleStyle}
isConnectable={false} isConnectable={false}
/> />
) : ( ) : (
<Handle type="target" position={Position.Top} style={handleStyle} isConnectable={false} /> <Handle type="target" position={Position.Top} id="top-in" style={handleStyle} isConnectable={false} />
)} )}
{isStart ? "▶" : "■"} {isStart ? "▶" : "■"}
</div> </div>
@@ -12,7 +12,7 @@ const TERMINAL_NODE_SIZE = 40;
// Vertical gap between nodes in the spine // Vertical gap between nodes in the spine
const LAYER_GAP = 80; const LAYER_GAP = 80;
// Horizontal offset for feedback (back) edges routed on the right side // Horizontal offset for feedback (back) edges routed on the right side
const FEEDBACK_OFFSET_X = 140; const FEEDBACK_OFFSET_X = 80;
type LayoutInput = { type LayoutInput = {
edges: readonly WorkflowGraphEdge[]; edges: readonly WorkflowGraphEdge[];
@@ -216,6 +216,8 @@ function computeLayout(input: LayoutInput): LayoutResult {
id: edgeKey(e), id: edgeKey(e),
source: e.from, source: e.from,
target: e.to, target: e.to,
sourceHandle: isFeedback ? (feedbackSide === "left" ? "left-out" : "right-out") : "bottom-out",
targetHandle: isFeedback ? (feedbackSide === "left" ? "left-in" : "right-in") : "top-in",
type: "condition", type: "condition",
data: { data: {
condition: e.condition, condition: e.condition,
@@ -32,16 +32,16 @@ const edgeTypes: EdgeTypes = {
condition: ConditionEdge, condition: ConditionEdge,
}; };
function handleRoleNodeClick(onRoleClick: (roleName: string) => void, node: Node): void { function handleNodeClick(onNodeClick: (nodeId: string) => void, node: Node): void {
if (node.type !== "role") return; if (node.type !== "role" && node.type !== "terminal") return;
onRoleClick(node.id); onNodeClick(node.id);
} }
export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props) { export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props) {
const layout = useLayout({ edges: graph.edges, roles, nodeStates }); const layout = useLayout({ edges: graph.edges, roles, nodeStates });
const onNodeClickHandler: OnNodeClick | undefined = const onNodeClickHandler: OnNodeClick | undefined =
onNodeClick !== null ? (_e, node) => handleRoleNodeClick(onNodeClick, node) : undefined; onNodeClick !== null ? (_e, node) => handleNodeClick(onNodeClick, node) : undefined;
const styledEdges = useMemo( const styledEdges = useMemo(
() => () =>