Compare commits

..

2 Commits

Author SHA1 Message Date
xingyue 4563f1bb5e 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).
2026-05-14 16:24:30 +08:00
xingyue 59b7e89028 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
2026-05-14 16:20:00 +08:00
5 changed files with 58 additions and 25 deletions
@@ -39,7 +39,8 @@ function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeSt
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");
}
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<string, number>();
const indicesByRole = useMemo(() => {
const m = new Map<string, number[]>();
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<Map<string, number>>(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 (
<div
key={key}
data-record-index={i}
ref={(el) => {
if (!isFirstForRole) return;
if (el !== null) firstCardByRoleRef.current.set(r.role, el);
@@ -252,7 +285,7 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
</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>
@@ -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;
@@ -31,7 +31,7 @@ export function TerminalNode(props: NodeProps) {
return (
<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={{
width: 40,
height: 40,
@@ -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[];
@@ -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(
() =>