feat: click graph node to scroll and highlight RecordCard (#198 Phase 3)
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user