feat(graph): dynamic force-directed Team-memory graph + learning-loop & activity rails

Rebuild the live "Team memory" view so the light/real-data version matches the
dark Bauhaus mock and the graph is genuinely DYNAMIC instead of dead static columns.

Frontend (frontend/src/components/graph/*, composed into GraphView.tsx):
- forceSim.ts: a tiny dependency-free force layout (charge repulsion, link springs,
  centroid recentering + gentle pull, 2-pass collision, bounds clamp, alpha anneal).
  No d3-force dependency added — keeps the shared pnpm-lock untouched so CI's
  frozen-lockfile install and the deploy path are unaffected.
- GraphCanvas.tsx: SVG render driven by the sim — draggable + pinnable nodes
  (double-click to release), curved edges that fan parallel pairs, weight-sized
  geometric node shapes, fade-in on new nodes/edges, animated learned_from dash,
  risk-path lighting with the rest dimmed, label collision-avoidance.
- MetricsRail / LearningLoop / ActivityStream / SelectedNodePanel / encoding.ts:
  the mock's rails + stream + detail panel, light shadcn (ToggleGroup, ScrollArea,
  Badge, Button) on theme tokens; only the SVG is bespoke.
- GraphView polls /api/pods/:id/graph every 5s and diffs (positions preserved across
  refreshes), with a best-effort ws /api/events nudge. A stale selection (node gone
  across a poll) is dropped so the canvas can't dim entirely.

Backend (additive — materializer de-noise untouched):
- live.ts: buildLoop() (observe→store→predict→outcome→adapt counts, deepest-recent
  stage active) and buildActivity() (time-sorted typed feed, same isFilePath /
  ENGINEER_NOISE / signature de-noise) emitted alongside nodes/edges/metrics.
- demo.ts: fallback loop + activity so the panels render on the demo path.
- shared/src/graph.ts: additive optional PodGraph.loop / .activity + LearningStage /
  ActivityEvent types.

Verified: pnpm lint + -r typecheck + -r build pass; Playwright on the dev build
confirmed force layout (distinct positions, ticks on load under StrictMode), drag,
risk-mode dimming (opacity 0.14), selection panel, legend, and no overlaps on both
the clean demo and the 31-node live hairball.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sb-iam
2026-06-27 23:27:54 -07:00
parent 03a8af6da0
commit c97863e05e
13 changed files with 1362 additions and 253 deletions
+153 -253
View File
@@ -1,111 +1,36 @@
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
import type { PodGraph, PodGraphNode, PodGraphEdge, PodGraphNodeKind } from '@podman/shared';
import { fetchPodGraph } from '../lib/graph.js';
import { useEffect, useMemo, useState } from 'react';
import type { PodGraph } from '@podman/shared';
import { fetchPodGraph, backendEventsUrl } from '../lib/graph.js';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { GraphCanvas } from './graph/GraphCanvas.js';
import { MetricsRail } from './graph/MetricsRail.js';
import { LearningLoop } from './graph/LearningLoop.js';
import { ActivityStream } from './graph/ActivityStream.js';
import { SelectedNodePanel } from './graph/SelectedNodePanel.js';
import { highlightFor, NODE_LEGEND, EDGE_LEGEND, type Mode } from './graph/encoding.js';
type Mode = 'risk' | 'learn' | 'all';
const POLL_MS = 5000;
// Fixed, light-readable hues for the node/edge encoding (kept stable across
// light/dark so kinds stay distinguishable; the chrome uses shadcn tokens).
const BLUE = '#2563eb';
const SLATE = '#475569';
const SLATE_EDGE = '#94a3b8';
const SLATE_FAINT = '#cbd5e1';
const AMBER = '#d97706';
const RED = '#dc2626';
const VIOLET = '#7c3aed';
const KIND_COLOR: Record<PodGraphNodeKind, string> = {
engineer: BLUE,
file: SLATE,
feature: AMBER,
collision: RED,
intervention: VIOLET,
};
const EDGE: Record<PodGraphEdge['kind'], { c: string; w: number; dash?: boolean }> = {
owns: { c: BLUE, w: 2.6 },
editing: { c: SLATE_EDGE, w: 2 },
touches: { c: SLATE_FAINT, w: 1.6 },
collides: { c: RED, w: 3.2 },
warns: { c: AMBER, w: 3.2 },
learned_from: { c: VIOLET, w: 2.4, dash: true },
};
function NodeShape({ node }: { node: PodGraphNode }) {
const c = KIND_COLOR[node.kind];
const { x, y } = node;
switch (node.kind) {
case 'engineer':
return <rect x={x - 15} y={y - 15} width={30} height={30} rx={4} fill={c} />;
case 'file':
return (
<rect
x={x - 15}
y={y - 15}
width={30}
height={30}
rx={4}
fill="none"
stroke={c}
strokeWidth={2.6}
/>
);
case 'feature':
return <circle cx={x} cy={y} r={17} fill={c} />;
case 'collision':
return <polygon points={`${x},${y - 18} ${x + 17},${y + 13} ${x - 17},${y + 13}`} fill={c} />;
case 'intervention':
return (
<polygon points={`${x},${y - 18} ${x + 18},${y} ${x},${y + 18} ${x - 18},${y}`} fill={c} />
);
default:
return null;
// Note: pm-enter must NOT use animation-fill-mode (both/forwards) — a held final
// keyframe (opacity:1) would override the .pm-dim cascade and defeat dimming.
const GRAPH_CSS = `
.pm-node{cursor:grab;transition:opacity .25s ease}
.pm-node:active{cursor:grabbing}
.pm-edge{transition:opacity .25s ease}
.pm-lbl{fill:var(--foreground);font-size:11px;font-weight:500;pointer-events:none;
paint-order:stroke;stroke:var(--card);stroke-width:3.5px;stroke-linejoin:round}
.pm-dim{opacity:.14}
.pm-enter{animation:pm-fade .45s ease}
.pm-dash{animation:pm-flow 1s linear infinite}
.pm-pulse{animation:pm-pulse 1.7s ease-in-out infinite}
@keyframes pm-fade{from{opacity:0}to{opacity:1}}
@keyframes pm-flow{to{stroke-dashoffset:-26}}
@keyframes pm-pulse{0%,100%{opacity:.45}50%{opacity:1}}
@media (prefers-reduced-motion:reduce){
.pm-enter,.pm-dash,.pm-pulse{animation:none}
}
}
interface Highlight {
nodes: Set<string>;
edges: Set<string>;
}
function highlightFor(graph: PodGraph, mode: Mode, selected: string | null): Highlight | null {
if (selected) {
const es = graph.edges.filter((e) => e.source === selected || e.target === selected);
return {
nodes: new Set([selected, ...es.flatMap((e) => [e.source, e.target])]),
edges: new Set(es.map((e) => e.id)),
};
}
if (mode === 'all') return null;
const kinds: PodGraphEdge['kind'][] =
mode === 'risk' ? ['collides', 'warns', 'learned_from'] : ['learned_from', 'warns'];
const collisions = new Set(graph.nodes.filter((n) => n.kind === 'collision').map((n) => n.id));
const es = graph.edges.filter(
(e) =>
kinds.includes(e.kind) ||
(mode === 'risk' && (collisions.has(e.target) || collisions.has(e.source))),
);
return {
nodes: new Set(es.flatMap((e) => [e.source, e.target])),
edges: new Set(es.map((e) => e.id)),
};
}
const LEGEND: Array<{ label: string; swatch: CSSProperties }> = [
{ label: 'engineer', swatch: { background: BLUE } },
{ label: 'file', swatch: { border: `2px solid ${SLATE}` } },
{ label: 'feature', swatch: { background: AMBER, borderRadius: '50%' } },
{ label: 'collision', swatch: { background: RED, clipPath: 'polygon(50% 0,100% 100%,0 100%)' } },
{ label: 'intervention', swatch: { background: VIOLET, transform: 'rotate(45deg)' } },
];
function statusColor(status: string): string {
if (status === 'risk') return RED;
if (status === 'learned') return VIOLET;
return 'var(--foreground)';
}
`;
export function GraphView({ podId, onClose }: { podId: string; onClose: () => void }) {
const [graph, setGraph] = useState<PodGraph | null>(null);
@@ -115,198 +40,173 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo
useEffect(() => {
let alive = true;
let nudge: number | null = null;
setGraph(null);
setError(null);
fetchPodGraph(podId)
.then((g) => alive && setGraph(g))
.catch((e: unknown) => alive && setError(e instanceof Error ? e.message : String(e)));
setSelected(null);
const load = () =>
fetchPodGraph(podId)
.then((g) => {
if (alive) {
setGraph(g);
setError(null);
}
})
.catch((e: unknown) => {
if (alive) setError(e instanceof Error ? e.message : String(e));
});
void load();
const poll = window.setInterval(() => void load(), POLL_MS);
// Best-effort realtime nudge: refetch (debounced) when the agent broadcasts.
let ws: WebSocket | null = null;
try {
ws = new WebSocket(backendEventsUrl());
ws.onmessage = () => {
if (nudge != null) return;
nudge = window.setTimeout(() => {
nudge = null;
void load();
}, 800);
};
} catch {
/* event bus is optional */
}
return () => {
alive = false;
window.clearInterval(poll);
if (nudge != null) window.clearTimeout(nudge);
ws?.close();
};
}, [podId]);
const hi = useMemo(
() => (graph ? highlightFor(graph, mode, selected) : null),
[graph, mode, selected],
);
const nodeById = useMemo(() => new Map((graph?.nodes ?? []).map((n) => [n.id, n])), [graph]);
const sel = selected ? nodeById.get(selected) : undefined;
const relCount = selected
? (graph?.edges ?? []).filter((e) => e.source === selected || e.target === selected).length
: 0;
// A selected node can vanish across a poll/WS refresh. Ignore a stale id so the
// graph doesn't dim entirely (highlightFor would otherwise light only a dead id).
const liveSelected = selected && nodeById.has(selected) ? selected : null;
useEffect(() => {
if (selected && graph && !nodeById.has(selected)) setSelected(null);
}, [graph, nodeById, selected]);
const dimNode = (id: string) => (hi ? !hi.nodes.has(id) : false);
const dimEdge = (id: string) => (hi ? !hi.edges.has(id) : false);
const hotEdge = (id: string) => (hi ? hi.edges.has(id) : false);
const highlight = useMemo(
() => (graph ? highlightFor(graph, mode, liveSelected) : null),
[graph, mode, liveSelected],
);
const sel = liveSelected ? nodeById.get(liveSelected) : undefined;
const relCount = liveSelected
? (graph?.edges ?? []).filter((e) => e.source === liveSelected || e.target === liveSelected)
.length
: 0;
function pick(next: Mode) {
setMode(next);
setSelected(null);
}
const toggleVariant = (m: Mode) => (mode === m && !selected ? 'default' : 'outline');
return (
<div className="min-h-screen bg-background text-foreground">
<style>{GRAPH_CSS}</style>
<div className="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
<style>{`
.pm-node{cursor:pointer}
.pm-lbl{fill:var(--foreground);font-size:11px;font-weight:500}
.pm-dim{opacity:.18;transition:opacity .25s}
`}</style>
<div className="overflow-hidden rounded-xl border bg-card text-card-foreground">
<div className="flex items-center justify-between border-b px-5 py-4">
<div>
<h2 className="text-base font-medium">Team memory</h2>
<p className="mt-0.5 text-xs text-muted-foreground">What PodMan learned · {podId}</p>
<div className="overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm">
{/* Header */}
<div className="flex items-center justify-between gap-3 border-b px-5 py-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="font-heading text-base font-medium">Team memory</h2>
<span className="inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[0.65rem] font-medium uppercase tracking-wide text-muted-foreground">
<span className="pm-pulse inline-block size-1.5 rounded-full bg-[#16a34a]" />
Live
</span>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
What PodMan learned · {podId}
</p>
</div>
<Button variant="outline" size="sm" onClick={onClose}>
Pods
</Button>
</div>
<div className="flex flex-wrap gap-2 border-b px-4 py-3">
<Button variant={toggleVariant('risk')} size="sm" onClick={() => pick('risk')}>
Risk path
</Button>
<Button variant={toggleVariant('learn')} size="sm" onClick={() => pick('learn')}>
Learning edges
</Button>
<Button variant={toggleVariant('all')} size="sm" onClick={() => pick('all')}>
Whole graph
</Button>
{/* Toggles */}
<div className="flex flex-wrap items-center gap-2 border-b px-4 py-3">
<ToggleGroup
type="single"
value={mode}
onValueChange={(v) => v && pick(v as Mode)}
variant="outline"
size="sm"
>
<ToggleGroupItem value="risk">Risk path</ToggleGroupItem>
<ToggleGroupItem value="learn">Learning edges</ToggleGroupItem>
<ToggleGroupItem value="all">Whole graph</ToggleGroupItem>
</ToggleGroup>
<span className="ml-auto hidden text-xs text-muted-foreground sm:inline">
Drag to rearrange · double-click to release · click to inspect
</span>
</div>
{error && <p className="px-4 py-4 text-sm text-destructive">Graph error: {error}</p>}
{!graph && !error && (
<p className="px-4 py-4 text-sm text-muted-foreground">Loading graph</p>
<p className="px-4 py-10 text-center text-sm text-muted-foreground">Loading graph</p>
)}
{graph && (
<>
<div className="grid lg:grid-cols-[190px_1fr_250px]">
<div className="space-y-3 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Workflow metrics
</p>
{graph.metrics.map((m) => (
<div key={m.label} className="rounded-lg border bg-card px-3 py-2.5">
<p className="text-2xl font-medium tabular-nums">{m.value}</p>
<p className="mt-1 text-xs font-medium uppercase text-muted-foreground">
{m.label}
</p>
<p className="mt-1 text-xs leading-snug text-muted-foreground">{m.detail}</p>
</div>
))}
{/* Metrics · graph · learning loop */}
<div className="grid gap-4 p-4 lg:grid-cols-[180px_minmax(0,1fr)_212px]">
<MetricsRail metrics={graph.metrics} />
<div className="flex min-h-[440px] flex-col overflow-hidden rounded-xl border bg-card">
<GraphCanvas
graph={graph}
highlight={highlight}
selected={liveSelected}
onSelect={setSelected}
/>
</div>
<div className="min-h-[472px] border-y bg-card lg:border-x lg:border-y-0">
<svg
viewBox="0 0 720 472"
role="img"
aria-label="PodMan team-memory graph"
className="block h-auto w-full"
>
{graph.edges.map((e) => {
const a = nodeById.get(e.source);
const b = nodeById.get(e.target);
if (!a || !b) return null;
const s = EDGE[e.kind];
return (
<line
key={e.id}
className={dimEdge(e.id) ? 'pm-dim' : undefined}
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
stroke={s.c}
strokeWidth={hotEdge(e.id) ? s.w + 1.6 : s.w}
strokeDasharray={s.dash ? '7 6' : undefined}
strokeLinecap="round"
/>
);
})}
{graph.nodes.map((n) => (
<g
key={n.id}
className={`pm-node ${dimNode(n.id) ? 'pm-dim' : ''}`}
role="button"
tabIndex={0}
aria-label={`${n.kind}: ${n.label}`}
onClick={() => setSelected((cur) => (cur === n.id ? null : n.id))}
onKeyDown={(ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
setSelected((cur) => (cur === n.id ? null : n.id));
}
}}
>
<NodeShape node={n} />
<text className="pm-lbl" x={n.x} y={n.y + 33} textAnchor="middle">
{n.label}
</text>
</g>
))}
</svg>
</div>
{graph.loop?.length ? (
<LearningLoop stages={graph.loop} />
) : (
<div />
)}
</div>
<div className="border-t bg-muted p-4 lg:border-l lg:border-t-0">
{sel ? (
<>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
{sel.kind}
</p>
<h3 className="mb-3 mt-1 text-lg font-medium">{sel.label}</h3>
<div className="flex items-center justify-between border-b py-1.5 text-sm text-muted-foreground">
<span>Status</span>
<Badge variant="outline" style={{ color: statusColor(sel.status) }}>
{sel.status}
</Badge>
</div>
<div className="flex items-center justify-between border-b py-1.5 text-sm text-muted-foreground">
<span>Relationships</span>
<span className="font-medium text-foreground">{relCount}</span>
</div>
<p className="mt-2.5 text-sm leading-relaxed text-muted-foreground">
{sel.summary}
</p>
</>
) : (
<>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Continual learning
</p>
<h3 className="mb-3 mt-1 text-lg font-medium">It learned</h3>
<p className="text-sm leading-relaxed text-muted-foreground">
The violet{' '}
<span className="font-medium" style={{ color: VIOLET }}>
learned_from
</span>{' '}
edges are ownership PodMan retained from accepted interventions the graph
gets sharper every session. Click any node to trace its relationships.
</p>
</>
)}
{/* Activity stream · selected node */}
<div className="grid gap-4 border-t px-4 py-4 lg:grid-cols-[minmax(0,1fr)_320px]">
<div className="rounded-xl border bg-card p-4">
<ActivityStream events={graph.activity ?? []} />
</div>
<div className="rounded-xl border bg-muted/40 p-4">
<SelectedNodePanel node={sel} relCount={relCount} />
</div>
</div>
<div className="flex flex-wrap gap-3 border-t px-4 py-2.5 text-xs text-muted-foreground">
{LEGEND.map((l) => (
{/* Legend */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-t px-4 py-2.5 text-xs text-muted-foreground">
{NODE_LEGEND.map((l) => (
<span key={l.label} className="flex items-center gap-1.5">
<span className="inline-block size-3" style={l.swatch} />
{l.label}
</span>
))}
<span className="flex items-center gap-1.5">
<span className="inline-block h-[3px] w-3" style={{ background: RED }} />
collides
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-[3px] w-3" style={{ background: VIOLET }} />
learned_from
</span>
<span className="mx-1 h-3 w-px bg-border" aria-hidden />
{EDGE_LEGEND.map((l) => (
<span key={l.label} className="flex items-center gap-1.5">
<span
className="inline-block h-[3px] w-3.5"
style={
l.dash
? { backgroundImage: `repeating-linear-gradient(90deg, ${l.color} 0 3px, transparent 3px 6px)` }
: { background: l.color }
}
/>
{l.label}
</span>
))}
</div>
</>
)}