Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 25 additions & 18 deletions frontend/src/components/memory/LineageView.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -16,25 +16,24 @@ export function LineageView({ decisionId, workspaceId, onSelectDecision }: Props
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div aria-live="polite">
Expand All @@ -46,14 +45,22 @@ export function LineageView({ decisionId, workspaceId, onSelectDecision }: Props
}
if (error) {
return (
<StateView tone="error" icon="!" title="Couldn't trace lineage">
<StateView
tone="error"
title="Couldn't trace lineage"
action={
<button type="button" className="btn btn--secondary" onClick={() => void load()}>
Retry
</button>
}
>
{error}
</StateView>
);
}
if (nodes.length === 0) {
return (
<StateView icon="◇" title="No lineage yet">
<StateView title="No lineage yet">
This decision hasn't superseded or been triggered by anything else in the graph yet.
</StateView>
);
Expand Down
136 changes: 78 additions & 58 deletions frontend/src/components/memory/MemoryGraph.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -25,76 +27,94 @@ function graphDimensions(decisionCount: number): { w: number; h: number } {
}

export function MemoryGraph({ decisions, focusId, onFocus }: Props) {
const [hoverId, setHoverId] = useState<string | null>(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 (
<div className="graph-empty">
<p>Search for decisions to see how people, systems, and choices connect.</p>
</div>
<StateView icon={<IconGraph size={28} />} title="No graph data yet">
Search for decisions to see how people, systems, and choices connect.
</StateView>
);
}

return (
<div className="memory-graph" role="group" aria-label="Memory relationship map">
<svg
viewBox={`0 0 ${w} ${h}`}
className="memory-graph__svg"
preserveAspectRatio="xMidYMid meet"
>
<defs>
<linearGradient id="edgeGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="var(--accent)" stopOpacity="0.15" />
<stop offset="100%" stopColor="var(--accent-violet)" stopOpacity="0.5" />
</linearGradient>
</defs>
{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 (
<line
key={`${e.from}-${e.to}`}
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
className="memory-graph__edge"
stroke="url(#edgeGrad)"
/>
);
})}
{nodes.map((n) => (
<g
key={n.id}
className={`memory-graph__node memory-graph__node--${n.kind} ${
focusId === n.id ? "memory-graph__node--focus" : ""
}`}
transform={`translate(${n.x}, ${n.y})`}
onClick={() => 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);
}
}}
>
<circle r={n.kind === "decision" ? 28 : 18} className="memory-graph__circle" />
<text className="memory-graph__label" textAnchor="middle" dy={n.kind === "decision" ? 44 : 32}>
{truncate(n.label, n.kind === "decision" ? 28 : 14)}
</text>
</g>
))}
</svg>
{hoverNode ? (
<div className="memory-graph__tooltip" role="tooltip">
<strong>{hoverNode.kind}</strong> · {hoverNode.label}
</div>
) : null}
<div className="memory-graph__scroll">
<svg
viewBox={`0 0 ${w} ${h}`}
className="memory-graph__svg"
preserveAspectRatio="xMidYMid meet"
>
<defs>
<linearGradient id="edgeGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="var(--accent)" stopOpacity="0.15" />
<stop offset="100%" stopColor="var(--accent-violet)" stopOpacity="0.5" />
</linearGradient>
</defs>
{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 (
<line
key={`${e.from}-${e.to}`}
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
className="memory-graph__edge"
stroke="url(#edgeGrad)"
/>
);
})}
{nodes.map((n) => (
<g
key={n.id}
className={`memory-graph__node memory-graph__node--${n.kind} ${
focusId === n.id ? "memory-graph__node--focus" : ""
} ${hoverId === n.id ? "memory-graph__node--hover" : ""}`}
transform={`translate(${n.x}, ${n.y})`}
onClick={() => 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);
}
}}
>
<circle r={n.kind === "decision" ? 28 : 18} className="memory-graph__circle" />
<text
className="memory-graph__label"
textAnchor="middle"
dy={n.kind === "decision" ? 44 : 32}
>
{truncate(n.label, n.kind === "decision" ? 28 : 14)}
</text>
</g>
))}
</svg>
</div>
<div className="memory-graph__legend">
<span>
<i className="dot dot--decision" /> Decision
Expand Down
Loading