Files
podman/frontend/src/components/GraphView.tsx
T
sb-iam c97863e05e 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>
2026-06-27 23:27:54 -07:00

218 lines
8.4 KiB
TypeScript

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 { 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';
const POLL_MS = 5000;
// 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}
}
`;
export function GraphView({ podId, onClose }: { podId: string; onClose: () => void }) {
const [graph, setGraph] = useState<PodGraph | null>(null);
const [error, setError] = useState<string | null>(null);
const [mode, setMode] = useState<Mode>('risk');
const [selected, setSelected] = useState<string | null>(null);
useEffect(() => {
let alive = true;
let nudge: number | null = null;
setGraph(null);
setError(null);
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 nodeById = useMemo(() => new Map((graph?.nodes ?? []).map((n) => [n.id, n])), [graph]);
// 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 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);
}
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">
<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>
{/* 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-10 text-center text-sm text-muted-foreground">Loading graph</p>
)}
{graph && (
<>
{/* 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>
{graph.loop?.length ? (
<LearningLoop stages={graph.loop} />
) : (
<div />
)}
</div>
{/* 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>
{/* 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="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>
</>
)}
</div>
</div>
</div>
);
}