From 5e783e7a241c7125bb88726f9e9be6b70cb9ee52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Thu, 14 May 2026 15:52:24 +0800 Subject: [PATCH 1/4] fix(dashboard): feedback edges connect from node sides via left/right handles (#247) What: Feedback (back) edges now connect from the left/right side of nodes instead of top/bottom, making the routing visually clearer. Changes: - role-node.tsx: add Left/Right handles for feedback edge connections - use-layout.ts: set sourceHandle/targetHandle for feedback edges - condition-edge.tsx + use-layout.ts: increase FEEDBACK_OFFSET_X to 140 Ref: #247 --- .../src/components/workflow-graph/role-node.tsx | 4 ++++ .../src/components/workflow-graph/use-layout.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/packages/workflow-dashboard/src/components/workflow-graph/role-node.tsx b/packages/workflow-dashboard/src/components/workflow-graph/role-node.tsx index c9f5b85..1f221ae 100644 --- a/packages/workflow-dashboard/src/components/workflow-graph/role-node.tsx +++ b/packages/workflow-dashboard/src/components/workflow-graph/role-node.tsx @@ -46,6 +46,10 @@ export function RoleNode(props: NodeProps) { title={data.description} > + + + +
{icon !== null && ( Date: Thu, 14 May 2026 15:54:21 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20explicit=20handle=20IDs=20=E2=80=94?= =?UTF-8?q?=20forward=20edges=20use=20top/bottom,=20feedback=20uses=20side?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevent React Flow from auto-routing forward edges to side handles. All handles now have explicit IDs and all edges specify sourceHandle/targetHandle. --- .../src/components/workflow-graph/role-node.tsx | 4 ++-- .../src/components/workflow-graph/terminal-node.tsx | 3 ++- .../src/components/workflow-graph/use-layout.ts | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/workflow-dashboard/src/components/workflow-graph/role-node.tsx b/packages/workflow-dashboard/src/components/workflow-graph/role-node.tsx index 1f221ae..a737877 100644 --- a/packages/workflow-dashboard/src/components/workflow-graph/role-node.tsx +++ b/packages/workflow-dashboard/src/components/workflow-graph/role-node.tsx @@ -45,7 +45,7 @@ export function RoleNode(props: NodeProps) { }} title={data.description} > - + @@ -67,7 +67,7 @@ export function RoleNode(props: NodeProps) { {data.description}
)} - + ); } diff --git a/packages/workflow-dashboard/src/components/workflow-graph/terminal-node.tsx b/packages/workflow-dashboard/src/components/workflow-graph/terminal-node.tsx index 4adea67..8a1685c 100644 --- a/packages/workflow-dashboard/src/components/workflow-graph/terminal-node.tsx +++ b/packages/workflow-dashboard/src/components/workflow-graph/terminal-node.tsx @@ -45,11 +45,12 @@ export function TerminalNode(props: NodeProps) { ) : ( - + )} {isStart ? "▶" : "■"} diff --git a/packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts b/packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts index 2d235c3..aacbeb9 100644 --- a/packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts +++ b/packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts @@ -216,8 +216,8 @@ function computeLayout(input: LayoutInput): LayoutResult { id: edgeKey(e), source: e.from, target: e.to, - sourceHandle: isFeedback ? (feedbackSide === "left" ? "left-out" : "right-out") : undefined, - targetHandle: isFeedback ? (feedbackSide === "left" ? "left-in" : "right-in") : undefined, + sourceHandle: isFeedback ? (feedbackSide === "left" ? "left-out" : "right-out") : "bottom-out", + targetHandle: isFeedback ? (feedbackSide === "left" ? "left-in" : "right-in") : "top-in", type: "condition", data: { condition: e.condition, From 59b7e89028ffb856316ce378b20907ef9b14b84b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Thu, 14 May 2026 16:20:00 +0800 Subject: [PATCH 3/4] feat(dashboard): graph node click improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reduce feedback edge offset (140→80) for tighter layout - Terminal nodes (start/end) now clickable when lit - Unlit nodes have no cursor-pointer and ignore clicks - Role nodes cycle through occurrences on repeated clicks - Start node click scrolls to thread prompt - End node click scrolls to bottom - All records get data-record-index for scroll targeting Ref: #247 --- .../src/components/thread-detail.tsx | 66 ++++++++++++++----- .../workflow-graph/condition-edge.tsx | 2 +- .../workflow-graph/terminal-node.tsx | 2 +- .../components/workflow-graph/use-layout.ts | 2 +- .../workflow-graph/workflow-graph.tsx | 8 +-- 5 files changed, 56 insertions(+), 24 deletions(-) diff --git a/packages/workflow-dashboard/src/components/thread-detail.tsx b/packages/workflow-dashboard/src/components/thread-detail.tsx index 2f7e951..a10ea67 100644 --- a/packages/workflow-dashboard/src/components/thread-detail.tsx +++ b/packages/workflow-dashboard/src/components/thread-detail.tsx @@ -79,28 +79,58 @@ export function ThreadDetail({ client, threadId, onBack }: Props) { const descriptor = descriptorFetch.status === "ok" ? descriptorFetch.data : null; const nodeStates = useMemo(() => computeNodeStates(records), [records]); - const firstIndexByRole = useMemo(() => { - const m = new Map(); + const indicesByRole = useMemo(() => { + const m = new Map(); for (let i = 0; i < records.length; i++) { const r = records[i]; - if (r.type === "role" && !m.has(r.role)) { - m.set(r.role, i); + if (r.type === "role") { + const list = m.get(r.role) ?? []; + list.push(i); + m.set(r.role, list); } } return m; }, [records]); - const handleGraphNodeClick = useCallback((roleName: string) => { - const el = firstCardByRoleRef.current.get(roleName); - if (el == null) return; - el.scrollIntoView({ behavior: "smooth", block: "center" }); - if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current); - setHighlightedRole(roleName); - highlightTimerRef.current = setTimeout(() => { - setHighlightedRole(null); - highlightTimerRef.current = null; - }, 1500); - }, []); + // Track which occurrence to jump to next per role (cycling) + const clickCycleRef = useRef>(new Map()); + + const handleGraphNodeClick = useCallback((nodeId: string) => { + // Only allow clicks on lit (non-default) nodes + if (nodeStates.get(nodeId) === undefined || nodeStates.get(nodeId) === "default") return; + + // __start__: scroll to the first record (thread-start prompt) + if (nodeId === "__start__") { + 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(() => { return () => { @@ -237,11 +267,13 @@ export function ThreadDetail({ client, threadId, onBack }: Props) { {records.map((r, i) => { const key = `${threadId}-${i}`; 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; return (
{ if (!isFirstForRole) return; if (el !== null) firstCardByRoleRef.current.set(r.role, el); @@ -252,7 +284,7 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
); } - return ; + return
; })}
diff --git a/packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx b/packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx index 18979fd..629df97 100644 --- a/packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx +++ b/packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx @@ -2,7 +2,7 @@ import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath } from " import type { ConditionEdgeData } from "./types.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 const FEEDBACK_RADIUS = 16; diff --git a/packages/workflow-dashboard/src/components/workflow-graph/terminal-node.tsx b/packages/workflow-dashboard/src/components/workflow-graph/terminal-node.tsx index 8a1685c..935949d 100644 --- a/packages/workflow-dashboard/src/components/workflow-graph/terminal-node.tsx +++ b/packages/workflow-dashboard/src/components/workflow-graph/terminal-node.tsx @@ -31,7 +31,7 @@ export function TerminalNode(props: NodeProps) { return (
void, node: Node): void { - if (node.type !== "role") return; - onRoleClick(node.id); +function handleNodeClick(onNodeClick: (nodeId: string) => void, node: Node): void { + if (node.type !== "role" && node.type !== "terminal") return; + onNodeClick(node.id); } export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props) { const layout = useLayout({ edges: graph.edges, roles, nodeStates }); const onNodeClickHandler: OnNodeClick | undefined = - onNodeClick !== null ? (_e, node) => handleRoleNodeClick(onNodeClick, node) : undefined; + onNodeClick !== null ? (_e, node) => handleNodeClick(onNodeClick, node) : undefined; const styledEdges = useMemo( () => From 4563f1bb5e4d3764b58692ebac06a70ac3fabb3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Thu, 14 May 2026 16:24:30 +0800 Subject: [PATCH 4/4] fix(dashboard): start node lights up when thread-start exists Previously __start__ only lit when role records existed. Now it lights up as soon as a thread-start record is present (i.e. the trigger prompt). --- packages/workflow-dashboard/src/components/thread-detail.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/workflow-dashboard/src/components/thread-detail.tsx b/packages/workflow-dashboard/src/components/thread-detail.tsx index a10ea67..4a638d0 100644 --- a/packages/workflow-dashboard/src/components/thread-detail.tsx +++ b/packages/workflow-dashboard/src/components/thread-detail.tsx @@ -39,7 +39,8 @@ function computeNodeStates(records: readonly ThreadRecord[]): Map 0) { + const hasStart = records.some((r) => r.type === "thread-start"); + if (hasStart) { states.set("__start__", "completed"); } if (hasResult) {