diff --git a/frontend/src/components/memory/LineageView.tsx b/frontend/src/components/memory/LineageView.tsx index 0ba3540..c5b5a6a 100644 --- a/frontend/src/components/memory/LineageView.tsx +++ b/frontend/src/components/memory/LineageView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { fetchCausalChain } from "../../api/client"; import type { DecisionResult } from "../../types"; import { formatRelativeTime } from "../../lib/format"; @@ -16,25 +16,24 @@ export function LineageView({ decisionId, workspaceId, onSelectDecision }: Props const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - useEffect(() => { - let cancelled = false; + const load = useCallback(async () => { setLoading(true); setError(null); - fetchCausalChain(decisionId, workspaceId) - .then((res) => { - if (!cancelled) setNodes(res.nodes); - }) - .catch((e) => { - if (!cancelled) setError(e instanceof Error ? e.message : String(e)); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { - cancelled = true; - }; + try { + const res = await fetchCausalChain(decisionId, workspaceId); + setNodes(res.nodes); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setNodes([]); + } finally { + setLoading(false); + } }, [decisionId, workspaceId]); + useEffect(() => { + void load(); + }, [load]); + if (loading) { return (
@@ -46,14 +45,22 @@ export function LineageView({ decisionId, workspaceId, onSelectDecision }: Props } if (error) { return ( - + void load()}> + Retry + + } + > {error} ); } if (nodes.length === 0) { return ( - + This decision hasn't superseded or been triggered by anything else in the graph yet. ); diff --git a/frontend/src/components/memory/MemoryGraph.tsx b/frontend/src/components/memory/MemoryGraph.tsx index 68f8502..513804b 100644 --- a/frontend/src/components/memory/MemoryGraph.tsx +++ b/frontend/src/components/memory/MemoryGraph.tsx @@ -1,6 +1,8 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import type { DecisionResult } from "../../types"; import { truncate } from "../../lib/format"; +import { StateView } from "../ui/StateView"; +import { IconGraph } from "../ui/icons"; type GraphNode = { id: string; @@ -25,76 +27,94 @@ function graphDimensions(decisionCount: number): { w: number; h: number } { } export function MemoryGraph({ decisions, focusId, onFocus }: Props) { + const [hoverId, setHoverId] = useState(null); const { w, h } = graphDimensions(decisions.length); const { nodes, edges } = useMemo( () => buildGraph(decisions, focusId, w, h), [decisions, focusId, w, h], ); + const hoverNode = nodes.find((n) => n.id === hoverId); + if (decisions.length === 0) { return ( -
-

Search for decisions to see how people, systems, and choices connect.

-
+ } title="No graph data yet"> + Search for decisions to see how people, systems, and choices connect. + ); } return (
- - - - - - - - {edges.map((e) => { - const a = nodes.find((n) => n.id === e.from); - const b = nodes.find((n) => n.id === e.to); - if (!a || !b) return null; - return ( - - ); - })} - {nodes.map((n) => ( - onFocus?.(n.id)} - style={{ cursor: onFocus ? "pointer" : "default" }} - role="button" - tabIndex={0} - aria-label={`${n.kind}: ${n.label}`} - aria-pressed={focusId === n.id} - onKeyDown={(ev) => { - if (ev.key === "Enter" || ev.key === " ") { - ev.preventDefault(); - onFocus?.(n.id); - } - }} - > - - - {truncate(n.label, n.kind === "decision" ? 28 : 14)} - - - ))} - + {hoverNode ? ( +
+ {hoverNode.kind} · {hoverNode.label} +
+ ) : null} +
+ + + + + + + + {edges.map((e) => { + const a = nodes.find((n) => n.id === e.from); + const b = nodes.find((n) => n.id === e.to); + if (!a || !b) return null; + return ( + + ); + })} + {nodes.map((n) => ( + onFocus?.(n.id)} + onMouseEnter={() => setHoverId(n.id)} + onMouseLeave={() => setHoverId(null)} + onFocus={() => setHoverId(n.id)} + onBlur={() => setHoverId(null)} + style={{ cursor: onFocus ? "pointer" : "default" }} + role="button" + tabIndex={0} + aria-label={`${n.kind}: ${n.label}`} + aria-pressed={focusId === n.id} + onKeyDown={(ev) => { + if (ev.key === "Enter" || ev.key === " ") { + ev.preventDefault(); + onFocus?.(n.id); + } + }} + > + + + {truncate(n.label, n.kind === "decision" ? 28 : 14)} + + + ))} + +
Decision