feat: click graph node to scroll and highlight RecordCard (#198 Phase 3)

This commit is contained in:
2026-05-12 10:34:06 +08:00
parent b1e66fa7a4
commit 7265603b55
5 changed files with 108 additions and 13 deletions
@@ -56,11 +56,11 @@ function StartCard({ record }: { record: ThreadStartRecord }) {
);
}
function RoleMessage({ record }: { record: RoleRecord }) {
function RoleMessage({ record, highlighted }: { record: RoleRecord; highlighted: boolean }) {
const color = roleColor(record.role);
return (
<div
className="p-3 rounded-lg border text-sm"
className={`p-3 rounded-lg border text-sm ${highlighted ? "wf-record-card-highlight" : ""}`}
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center gap-2 mb-2">
@@ -114,12 +114,17 @@ function ResultCard({ record }: { record: WorkflowResultRecord }) {
);
}
export function RecordCard({ record }: { record: ThreadRecord }) {
type RecordCardProps = {
record: ThreadRecord;
highlighted: boolean;
};
export function RecordCard({ record, highlighted }: RecordCardProps) {
switch (record.type) {
case "thread-start":
return <StartCard record={record} />;
case "role":
return <RoleMessage record={record} />;
return <RoleMessage record={record} highlighted={highlighted} />;
case "workflow-result":
return <ResultCard record={record} />;
}
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
getThread,
getWorkflowDescriptor,
@@ -30,9 +30,10 @@ type GraphPanelProps = {
descriptor: WorkflowDescriptor;
workflowName: string | null;
nodeStates: Map<string, NodeState>;
onNodeClick: ((roleName: string) => void) | null;
};
function GraphPanel({ descriptor, workflowName, nodeStates }: GraphPanelProps) {
function GraphPanel({ descriptor, workflowName, nodeStates, onNodeClick }: GraphPanelProps) {
const [open, setOpen] = useState(true);
const edgeCount = descriptor.graph.edges.length;
return (
@@ -64,6 +65,7 @@ function GraphPanel({ descriptor, workflowName, nodeStates }: GraphPanelProps) {
graph={descriptor.graph}
roles={descriptor.roles}
nodeStates={nodeStates}
onNodeClick={onNodeClick}
/>
</div>
)}
@@ -102,6 +104,9 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
const { status, data, error } = useFetch(() => getThread(agent, threadId), [agent, threadId]);
const [actionStatus, setActionStatus] = useState<string | null>(null);
const recordsEndRef = useRef<HTMLDivElement>(null);
const firstCardByRoleRef = useRef<Map<string, HTMLDivElement>>(new Map());
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [highlightedRole, setHighlightedRole] = useState<string | null>(null);
const liveActive = sse.connected && !sse.completed;
const records = liveActive
@@ -121,6 +126,35 @@ export function ThreadDetail({ agent, 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>();
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);
}
}
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);
}, []);
useEffect(() => {
return () => {
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
};
}, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the rendered record list grows
useEffect(() => {
recordsEndRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -194,7 +228,12 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
)}
{descriptor !== null && descriptor.graph.edges.length > 0 && (
<GraphPanel descriptor={descriptor} workflowName={workflowName} nodeStates={nodeStates} />
<GraphPanel
descriptor={descriptor}
workflowName={workflowName}
nodeStates={nodeStates}
onNodeClick={handleGraphNodeClick}
/>
)}
{status === "loading" && !liveActive && records.length === 0 && (
@@ -205,9 +244,26 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
)}
{(status === "ok" || liveActive || records.length > 0) && (
<div className="space-y-3">
{records.map((r, i) => (
<RecordCard key={`${threadId}-${i}`} record={r} />
))}
{records.map((r, i) => {
const key = `${threadId}-${i}`;
if (r.type === "role") {
const isFirstForRole = firstIndexByRole.get(r.role) === i;
const flash = highlightedRole === r.role;
return (
<div
key={key}
ref={(el) => {
if (!isFirstForRole) return;
if (el !== null) firstCardByRoleRef.current.set(r.role, el);
else firstCardByRoleRef.current.delete(r.role);
}}
>
<RecordCard record={r} highlighted={flash} />
</div>
);
}
return <RecordCard key={key} record={r} highlighted={false} />;
})}
<div ref={recordsEndRef} aria-hidden />
</div>
)}
@@ -31,7 +31,7 @@ export function RoleNode(props: NodeProps) {
return (
<div
className={`px-3 py-2 rounded-md border-2 text-xs font-medium ${isActive ? "wf-node-pulse" : ""}`}
className={`px-3 py-2 rounded-md border-2 text-xs font-medium cursor-pointer ${isActive ? "wf-node-pulse" : ""}`}
style={{
width: 180,
height: 60,
@@ -1,4 +1,12 @@
import { Background, type EdgeTypes, MarkerType, type NodeTypes, ReactFlow } from "@xyflow/react";
import {
Background,
type EdgeTypes,
MarkerType,
type Node,
type NodeTypes,
type OnNodeClick,
ReactFlow,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { useMemo } from "react";
import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts";
@@ -12,6 +20,7 @@ type Props = {
graph: WorkflowGraphData;
roles: Record<string, { description: string }>;
nodeStates: Map<string, NodeState>;
onNodeClick: ((roleName: string) => void) | null;
};
const nodeTypes: NodeTypes = {
@@ -23,9 +32,17 @@ const edgeTypes: EdgeTypes = {
condition: ConditionEdge,
};
export function WorkflowGraph({ graph, roles, nodeStates }: Props) {
function handleRoleNodeClick(onRoleClick: (roleName: string) => void, node: Node): void {
if (node.type !== "role") return;
onRoleClick(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;
const styledEdges = useMemo(
() =>
layout.edges.map((e) => ({
@@ -46,6 +63,7 @@ export function WorkflowGraph({ graph, roles, nodeStates }: Props) {
edges={styledEdges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodeClick={onNodeClickHandler}
fitView
fitViewOptions={{ padding: 0.15 }}
nodesDraggable={false}
+16
View File
@@ -33,3 +33,19 @@ body {
.wf-node-pulse {
animation: wf-node-pulse 1.6s ease-in-out infinite;
}
@keyframes wf-record-card-highlight {
0% {
border-color: var(--color-accent);
}
35% {
border-color: var(--color-accent);
}
100% {
border-color: var(--color-border);
}
}
.wf-record-card-highlight {
animation: wf-record-card-highlight 1.5s ease-out forwards;
}