diff --git a/packages/workflow-dashboard/src/components/thread-detail.tsx b/packages/workflow-dashboard/src/components/thread-detail.tsx index 2f7e951..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) { @@ -79,28 +80,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 +268,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 +285,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/role-node.tsx b/packages/workflow-dashboard/src/components/workflow-graph/role-node.tsx index c9f5b85..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,11 @@ export function RoleNode(props: NodeProps) { }} title={data.description} > - + + + + +
{icon !== null && ( )} - +
); } 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..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 (
) : ( - + )} {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 7e934ac..731f2cc 100644 --- a/packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts +++ b/packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts @@ -12,7 +12,7 @@ const TERMINAL_NODE_SIZE = 40; // Vertical gap between nodes in the spine const LAYER_GAP = 80; // Horizontal offset for feedback (back) edges routed on the right side -const FEEDBACK_OFFSET_X = 140; +const FEEDBACK_OFFSET_X = 80; type LayoutInput = { edges: readonly WorkflowGraphEdge[]; @@ -216,6 +216,8 @@ function computeLayout(input: LayoutInput): LayoutResult { id: edgeKey(e), source: e.from, 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", data: { condition: e.condition, diff --git a/packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx b/packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx index 9c6da85..19fc366 100644 --- a/packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx +++ b/packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx @@ -32,16 +32,16 @@ const edgeTypes: EdgeTypes = { condition: ConditionEdge, }; -function handleRoleNodeClick(onRoleClick: (roleName: string) => 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( () =>