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:
@@ -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(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
Reference in New Issue
Block a user