feat(dashboard): graph node click improvements

- 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
This commit is contained in:
2026-05-14 16:20:00 +08:00
parent 019d8c1ee9
commit 59b7e89028
5 changed files with 56 additions and 24 deletions
@@ -79,28 +79,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;
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" }); el.scrollIntoView({ behavior: "smooth", block: "center" });
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current); if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
setHighlightedRole(roleName); setHighlightedRole(nodeId);
highlightTimerRef.current = setTimeout(() => { highlightTimerRef.current = setTimeout(() => {
setHighlightedRole(null); setHighlightedRole(null);
highlightTimerRef.current = null; highlightTimerRef.current = null;
}, 1500); }, 1500);
}, []); }
}, [nodeStates, indicesByRole]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -237,11 +267,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 +284,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;
@@ -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,
@@ -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[];
@@ -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(
() => () =>