diff --git a/packages/workflow-dashboard/src/components/record-card.tsx b/packages/workflow-dashboard/src/components/record-card.tsx index da51773..ecbb28d 100644 --- a/packages/workflow-dashboard/src/components/record-card.tsx +++ b/packages/workflow-dashboard/src/components/record-card.tsx @@ -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 (
@@ -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 ; case "role": - return ; + return ; case "workflow-result": return ; } diff --git a/packages/workflow-dashboard/src/components/thread-detail.tsx b/packages/workflow-dashboard/src/components/thread-detail.tsx index 3e4b822..819f2f1 100644 --- a/packages/workflow-dashboard/src/components/thread-detail.tsx +++ b/packages/workflow-dashboard/src/components/thread-detail.tsx @@ -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; + 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} />
)} @@ -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(null); const recordsEndRef = useRef(null); + const firstCardByRoleRef = useRef>(new Map()); + const highlightTimerRef = useRef | null>(null); + const [highlightedRole, setHighlightedRole] = useState(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(); + 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 && ( - + )} {status === "loading" && !liveActive && records.length === 0 && ( @@ -205,9 +244,26 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) { )} {(status === "ok" || liveActive || records.length > 0) && (
- {records.map((r, i) => ( - - ))} + {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 ( +
{ + if (!isFirstForRole) return; + if (el !== null) firstCardByRoleRef.current.set(r.role, el); + else firstCardByRoleRef.current.delete(r.role); + }} + > + +
+ ); + } + return ; + })}
)} diff --git a/packages/workflow-dashboard/src/components/workflow-graph/role-node.tsx b/packages/workflow-dashboard/src/components/workflow-graph/role-node.tsx index ae73cde..51933ce 100644 --- a/packages/workflow-dashboard/src/components/workflow-graph/role-node.tsx +++ b/packages/workflow-dashboard/src/components/workflow-graph/role-node.tsx @@ -31,7 +31,7 @@ export function RoleNode(props: NodeProps) { return (
; nodeStates: Map; + 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} diff --git a/packages/workflow-dashboard/src/index.css b/packages/workflow-dashboard/src/index.css index 7c44386..869b8ab 100644 --- a/packages/workflow-dashboard/src/index.css +++ b/packages/workflow-dashboard/src/index.css @@ -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; +}