Merge pull request 'feat(dashboard): graph node click improvements' (#255) from feat/graph-interactions into main
This commit is contained in:
@@ -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(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
Reference in New Issue
Block a user