diff --git a/backend/src/graph/demo.ts b/backend/src/graph/demo.ts index 57a0e32..a214547 100644 --- a/backend/src/graph/demo.ts +++ b/backend/src/graph/demo.ts @@ -8,9 +8,45 @@ import type { PodGraph } from '@podman/shared'; * auth* — is the continual-learning story the demo lights up. */ export function createDemoPodGraph(podId: string): PodGraph { + const base = Date.now(); + const at = (secAgo: number): string => new Date(base - secAgo * 1000).toISOString(); return { podId, generatedAt: new Date().toISOString(), + loop: [ + { key: 'observe', title: 'OBSERVE', value: '5', detail: '~5/s vision contexts', active: false }, + { key: 'store', title: 'STORE', value: '124', detail: 'memory vectors · Atlas', active: false }, + { key: 'predict', title: 'PREDICT', value: '2', detail: 'collisions flagged', active: true }, + { key: 'outcome', title: 'OUTCOME', value: '1/0', detail: 'accepted · dismissed', active: false }, + { key: 'adapt', title: 'ADAPT', value: '5', detail: 'learned owners', active: false }, + ], + activity: [ + { + id: 'demo-learn', + at: at(20), + kind: 'learned_from', + text: 'Memory updated: Karti owns auth.ts (confidence ↑)', + }, + { id: 'demo-out', at: at(24), kind: 'outcome', text: 'Intervention accepted by the pod' }, + { + id: 'demo-warn', + at: at(40), + kind: 'warns', + text: 'PodMan: "Karti & Yahya are both in auth.ts — open a sync PR?" → card sent', + }, + { + id: 'demo-col', + at: at(58), + kind: 'collision', + text: 'Critical overlap on auth.ts · Karti + Yahya', + }, + { + id: 'demo-edit', + at: at(72), + kind: 'editing', + text: 'Yahya opened auth.ts — unpushed changes', + }, + ], metrics: [ { label: 'Learned owners', diff --git a/backend/src/graph/live.ts b/backend/src/graph/live.ts index 6cbb74d..1b99d0d 100644 --- a/backend/src/graph/live.ts +++ b/backend/src/graph/live.ts @@ -6,6 +6,13 @@ import type { PodGraphNodeKind, PodGraphEdgeKind, PodGraphNodeStatus, + LearningStage, + LearningStageKey, + ActivityEvent, + EngineerContext, + Collision, + Intervention, + InterventionOutcome, } from '@podman/shared'; import { collections, getGitStates, getDb } from '../memory/db.js'; @@ -148,6 +155,185 @@ function layout(nodes: PodGraphNode[]): void { const SEVERITY_WEIGHT: Record = { info: 0.4, warn: 0.7, critical: 1 }; +/** Parse any timestamp-ish value to epoch ms (0 when missing/unparseable). */ +function ms(t: string | Date | null | undefined): number { + if (!t) return 0; + const v = new Date(t).getTime(); + return Number.isFinite(v) ? v : 0; +} + +const OBSERVE_WINDOW_MS = 60_000; + +/** + * Live counts for the learning-loop rail (observe→store→predict→outcome→adapt). + * The "active" stage is the one whose latest underlying event is most recent — + * with deeper stages winning ties so the rail lights up at the furthest point + * the pod reached this session. Additive: derived from already-fetched docs. + */ +function buildLoop(opts: { + now: number; + observations: EngineerContext[]; + collisions: Collision[]; + outcomes: InterventionOutcome[]; + riskPaths: number; + vectorCount: number; + learnedOwners: number; +}): LearningStage[] { + const { now, observations, collisions, outcomes, riskPaths, vectorCount, learnedOwners } = opts; + + const recentObs = observations.filter((o) => now - ms(o.observedAt) < OBSERVE_WINDOW_MS).length; + const rate = (recentObs / 60).toFixed(1); + const accepted = outcomes.filter((o) => o.accepted).length; + const dismissed = outcomes.filter((o) => !o.accepted).length; + + // Latest event time per stage; `store` sits just behind `predict` so a shared + // collision timestamp resolves to PREDICT rather than STORE. + const latestObs = Math.max(0, ...observations.map((o) => ms(o.observedAt))); + const latestCol = Math.max(0, ...collisions.map((c) => ms(c.detectedAt))); + const latestOut = Math.max(0, ...outcomes.map((o) => ms(o.recordedAt))); + const latestAdapt = Math.max( + 0, + ...outcomes.filter((o) => o.accepted && o.wasRealCollision).map((o) => ms(o.recordedAt)), + ); + + const refs: Array<[LearningStageKey, number]> = [ + ['observe', latestObs], + ['store', latestCol ? latestCol - 1 : 0], + ['predict', latestCol], + ['outcome', latestOut], + ['adapt', latestAdapt], + ]; + let activeKey: LearningStageKey = 'observe'; + let best = 0; + for (const [k, t] of refs) { + if (t > 0 && t >= best) { + best = t; + activeKey = k; + } + } + + const stages: Array> = [ + { key: 'observe', title: 'OBSERVE', value: String(recentObs), detail: `~${rate}/s vision contexts` }, + { key: 'store', title: 'STORE', value: String(vectorCount), detail: 'memory vectors · Atlas' }, + { + key: 'predict', + title: 'PREDICT', + value: String(riskPaths), + detail: `${riskPaths === 1 ? 'collision' : 'collisions'} flagged`, + }, + { key: 'outcome', title: 'OUTCOME', value: `${accepted}/${dismissed}`, detail: 'accepted · dismissed' }, + { + key: 'adapt', + title: 'ADAPT', + value: String(learnedOwners), + detail: `learned owner${learnedOwners === 1 ? '' : 's'}`, + }, + ]; + return stages.map((s) => ({ ...s, active: s.key === activeKey })); +} + +/** + * Merge + time-sort recent events into the activity stream feed. Reuses the same + * de-noise (isFilePath / ENGINEER_NOISE / signature collapse) as the graph so + * the feed never shows junk paths or test-artifact engineers. Capped to 8. + */ +function buildActivity(opts: { + observations: EngineerContext[]; + collisions: Collision[]; + interventions: Intervention[]; + outcomes: InterventionOutcome[]; + ownership: Record; +}): ActivityEvent[] { + const { observations, collisions, interventions, outcomes, ownership } = opts; + const cleanEng = (n: string): boolean => Boolean(n) && !ENGINEER_NOISE.test(n); + const out: ActivityEvent[] = []; + + // editing — newest observation per (engineer, file); observations arrive desc. + const seenEdit = new Set(); + for (const o of observations) { + if (!o.engineerId || !cleanEng(o.engineerId)) continue; + const file = o.currentFile ? normalizeFile(o.currentFile) : ''; + if (!isFilePath(file)) continue; + const key = `${o.engineerId.toLowerCase()}|${file}`; + if (seenEdit.has(key)) continue; + seenEdit.add(key); + out.push({ + id: `edit:${o.engineerId}:${file}`, + at: o.observedAt, + kind: 'editing', + text: `${o.engineerId} opened ${shortLabel(file)}${ + o.hasUnpushedChanges ? ' — unpushed changes' : '' + }`, + }); + } + + // collision — collapse by signature, newest first. + const seenCol = new Set(); + for (const c of collisions) { + const file = normalizeFile(c.file); + if (!isFilePath(file)) continue; + const sig = (c as { memorySignature?: string }).memorySignature ?? `${file}#${c.symbol ?? ''}`; + if (seenCol.has(sig)) continue; + seenCol.add(sig); + const engs = c.engineers.filter(cleanEng); + if (!engs.length) continue; + out.push({ + id: `col:${c.id}`, + at: c.detectedAt, + kind: 'collision', + text: `${c.severity === 'critical' ? 'Critical overlap' : 'Overlap'} on ${shortLabel( + file, + )} · ${engs.join(' + ')}`, + }); + } + + // warns — interventions PodMan raised. + for (const iv of interventions) { + if (!iv.message) continue; + const msg = iv.message.length > 64 ? `${iv.message.slice(0, 61)}…` : iv.message; + out.push({ + id: `warn:${iv.id}`, + at: iv.createdAt, + kind: 'warns', + text: `PodMan: "${msg}" → card sent`, + }); + } + + // outcome + learned_from — the supervised learning beat. + const colById = new Map(collisions.map((c) => [c.id, c])); + const ivById = new Map(interventions.map((i) => [i.id, i])); + for (const o of outcomes) { + if (!o.accepted) continue; + out.push({ + id: `out:${o.interventionId}`, + at: o.recordedAt, + kind: 'outcome', + text: 'Intervention accepted by the pod', + }); + if (!o.wasRealCollision) continue; + const iv = ivById.get(o.interventionId); + const col = iv ? colById.get(iv.collisionId) : colById.get(o.collisionId); + if (!col) continue; + const file = normalizeFile(col.file); + if (!isFilePath(file)) continue; + const owner = + (o as { learnedOwner?: string }).learnedOwner ?? + ownership[file] ?? + col.engineers.find(cleanEng) ?? + col.engineers[0]; + if (!owner) continue; + out.push({ + id: `learn:${o.interventionId}`, + at: o.recordedAt, + kind: 'learned_from', + text: `Memory updated: ${owner} owns ${shortLabel(file)} (confidence ↑)`, + }); + } + + out.sort((a, b) => ms(b.at) - ms(a.at)); + return out.slice(0, 8); +} + export async function materializePodGraph(podId: string): Promise { const c = await collections(); const db = await getDb(); @@ -390,11 +576,46 @@ export async function materializePodGraph(podId: string): Promise (c as { embedding?: number[] }).embedding?.length, + ).length; + if (!vectorCount) vectorCount = collisionDocs.length; + + const learnedOwners = Object.keys(ownership).length || acceptedReal; + + const loop = buildLoop({ + now, + observations, + collisions: collisionDocs, + outcomes: outcomeDocs, + riskPaths, + vectorCount, + learnedOwners, + }); + const activity = buildActivity({ + observations, + collisions: collisionDocs, + interventions: interventionDocs, + outcomes: outcomeDocs, + ownership, + }); + return { podId, generatedAt: new Date().toISOString(), nodes, edges: [...b.edges.values()], metrics, + loop, + activity, }; } diff --git a/frontend/src/components/GraphView.tsx b/frontend/src/components/GraphView.tsx index 7c13635..6a97789 100644 --- a/frontend/src/components/GraphView.tsx +++ b/frontend/src/components/GraphView.tsx @@ -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 = { - engineer: BLUE, - file: SLATE, - feature: AMBER, - collision: RED, - intervention: VIOLET, -}; - -const EDGE: Record = { - 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 ; - case 'file': - return ( - - ); - case 'feature': - return ; - case 'collision': - return ; - case 'intervention': - return ( - - ); - 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; - edges: Set; -} - -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(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 (
+
- - -
-
-
-

Team memory

-

What PodMan learned · {podId}

+
+ {/* Header */} +
+
+
+

Team memory

+ + + Live + +
+

+ What PodMan learned · {podId} +

-
- - - + {/* Toggles */} +
+ v && pick(v as Mode)} + variant="outline" + size="sm" + > + Risk path + Learning edges + Whole graph + + + Drag to rearrange · double-click to release · click to inspect +
{error &&

Graph error: {error}

} {!graph && !error && ( -

Loading graph…

+

Loading graph…

)} {graph && ( <> -
-
-

- Workflow metrics -

- {graph.metrics.map((m) => ( -
-

{m.value}

-

- {m.label} -

-

{m.detail}

-
- ))} + {/* Metrics · graph · learning loop */} +
+ + +
+
-
- - {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 ( - - ); - })} - {graph.nodes.map((n) => ( - 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)); - } - }} - > - - - {n.label} - - - ))} - -
+ {graph.loop?.length ? ( + + ) : ( +
+ )} +
-
- {sel ? ( - <> -

- {sel.kind} -

-

{sel.label}

-
- Status - - {sel.status} - -
-
- Relationships - {relCount} -
-

- {sel.summary} -

- - ) : ( - <> -

- Continual learning -

-

It learned

-

- The violet{' '} - - learned_from - {' '} - edges are ownership PodMan retained from accepted interventions — the graph - gets sharper every session. Click any node to trace its relationships. -

- - )} + {/* Activity stream · selected node */} +
+
+ +
+
+
-
- {LEGEND.map((l) => ( + {/* Legend */} +
+ {NODE_LEGEND.map((l) => ( {l.label} ))} - - - collides - - - - learned_from - + + {EDGE_LEGEND.map((l) => ( + + + {l.label} + + ))}
)} diff --git a/frontend/src/components/graph/ActivityStream.tsx b/frontend/src/components/graph/ActivityStream.tsx new file mode 100644 index 0000000..931ce04 --- /dev/null +++ b/frontend/src/components/graph/ActivityStream.tsx @@ -0,0 +1,45 @@ +import type { ActivityEvent } from '@podman/shared'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { ACTIVITY_TAG } from './encoding.js'; + +const fmtTime = new Intl.DateTimeFormat([], { hour: '2-digit', minute: '2-digit', hour12: false }); + +function timeOf(at: string): string { + const t = new Date(at).getTime(); + return Number.isFinite(t) ? fmtTime.format(t) : '--:--'; +} + +export function ActivityStream({ events }: { events: ActivityEvent[] }) { + return ( +
+

+ Activity stream +

+ {events.length === 0 ? ( +

No activity yet.

+ ) : ( + +
    + {events.map((e) => { + const tag = ACTIVITY_TAG[e.kind]; + return ( +
  • + + {timeOf(e.at)} + + + {tag.label} + + {e.text} +
  • + ); + })} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/graph/GraphCanvas.tsx b/frontend/src/components/graph/GraphCanvas.tsx new file mode 100644 index 0000000..b87ed79 --- /dev/null +++ b/frontend/src/components/graph/GraphCanvas.tsx @@ -0,0 +1,333 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type ReactElement, + type PointerEvent, +} from 'react'; +import type { PodGraph, PodGraphNode, PodGraphNodeKind } from '@podman/shared'; +import { ForceSim } from './forceSim.js'; +import { EDGE, KIND_COLOR, nodeRadius, type Highlight } from './encoding.js'; + +const W = 760; +const H = 480; +const MARGIN = 48; + +/** Map the server's 0..720×0..472 layout into the canvas as a seed position. */ +function mapX(x: number): number { + return MARGIN + (Math.max(0, Math.min(720, x)) / 720) * (W - 2 * MARGIN); +} +function mapY(y: number): number { + return MARGIN + (Math.max(0, Math.min(472, y)) / 472) * (H - 2 * MARGIN); +} + +function linkDistance(kind: string): number { + if (kind === 'collides') return 122; + if (kind === 'owns') return 104; + if (kind === 'learned_from') return 150; + return 134; +} +function linkStrength(strength: number): number { + return Math.max(0.18, Math.min(0.9, strength)); +} + +/** Stable +/- so parallel edges between the same pair fan to opposite sides. */ +function curveSign(id: string): number { + let h = 0; + for (let i = 0; i < id.length; i++) h = (h + id.charCodeAt(i)) % 2; + return h === 0 ? 1 : -1; +} + +function edgePath(ax: number, ay: number, bx: number, by: number, id: string): string { + const dx = bx - ax; + const dy = by - ay; + const len = Math.hypot(dx, dy) || 1; + const nx = -dy / len; + const ny = dx / len; + const off = curveSign(id) * len * 0.13; + const cx = (ax + bx) / 2 + nx * off; + const cy = (ay + by) / 2 + ny * off; + return `M${ax.toFixed(1)},${ay.toFixed(1)} Q${cx.toFixed(1)},${cy.toFixed(1)} ${bx.toFixed(1)},${by.toFixed(1)}`; +} + +function nodeShape( + kind: PodGraphNodeKind, + color: string, + cx: number, + cy: number, + r: number, +): ReactElement | null { + switch (kind) { + case 'engineer': + return ; + case 'file': + return ( + + ); + case 'feature': + return ; + case 'collision': + return ( + + ); + case 'intervention': + return ( + + ); + default: + return null; + } +} + +function showLabel( + node: PodGraphNode, + dimmed: boolean, + hovered: boolean, + selected: boolean, +): boolean { + if (hovered || selected) return true; + if (dimmed) return false; + // Collisions cluster and often share a filename — reveal on hover/select only. + if (node.kind === 'collision') return false; + return true; +} + +interface DragState { + id: string; + pointerId: number; + moved: boolean; +} + +export function GraphCanvas({ + graph, + highlight, + selected, + onSelect, +}: { + graph: PodGraph; + highlight: Highlight | null; + selected: string | null; + onSelect: (id: string | null) => void; +}) { + const svgRef = useRef(null); + const simRef = useRef(null); + if (!simRef.current) simRef.current = new ForceSim(W, H); + const rafRef = useRef(null); + const dragRef = useRef(null); + const sigRef = useRef(''); + const [, setFrame] = useState(0); + const [hovered, setHovered] = useState(null); + + const loop = useCallback(() => { + const sim = simRef.current; + if (!sim) return; + const working = sim.tick(); + setFrame((f) => (f + 1) % 1_000_000); + if (working || dragRef.current) { + rafRef.current = requestAnimationFrame(loop); + } else { + rafRef.current = null; + } + }, []); + + const ensureRaf = useCallback(() => { + if (rafRef.current == null) rafRef.current = requestAnimationFrame(loop); + }, [loop]); + + // Rebuild the simulation when the graph data changes, preserving positions. + useEffect(() => { + const sim = simRef.current; + if (!sim) return; + const nodeInputs = graph.nodes.map((n) => ({ + id: n.id, + radius: nodeRadius(n), + seedX: mapX(n.x), + seedY: mapY(n.y), + })); + const linkInputs = graph.edges.map((e) => ({ + source: e.source, + target: e.target, + distance: linkDistance(e.kind), + strength: linkStrength(e.strength), + })); + const sig = + nodeInputs + .map((n) => n.id) + .sort() + .join(',') + + '|' + + graph.edges + .map((e) => e.id) + .sort() + .join(','); + const first = sigRef.current === ''; + const changed = sig !== sigRef.current; + sim.setData(nodeInputs, linkInputs); + if (changed) { + sigRef.current = sig; + sim.reheat(first ? 1 : 0.5); + } + // Always (re)arm the loop — ensureRaf is idempotent via the rafRef==null + // guard. This must NOT be gated on `changed`: under React StrictMode the + // dev double-invoke cancels the frame between effect passes, and pass 2 sees + // an unchanged sig, so a `changed`-gated start would leave the sim frozen. + if (sim.nodes.length) ensureRaf(); + }, [graph, ensureRaf]); + + // Clean up the animation frame on unmount. + useEffect(() => { + return () => { + if (rafRef.current != null) cancelAnimationFrame(rafRef.current); + rafRef.current = null; + }; + }, []); + + function toSvg(evt: PointerEvent): { x: number; y: number } { + const svg = svgRef.current; + if (!svg) return { x: 0, y: 0 }; + const ctm = svg.getScreenCTM(); + if (!ctm) return { x: 0, y: 0 }; + const p = new DOMPoint(evt.clientX, evt.clientY).matrixTransform(ctm.inverse()); + return { x: p.x, y: p.y }; + } + + function onNodePointerDown(evt: PointerEvent, id: string) { + evt.stopPropagation(); + const sim = simRef.current; + if (!sim) return; + (evt.currentTarget as Element).setPointerCapture(evt.pointerId); + dragRef.current = { id, pointerId: evt.pointerId, moved: false }; + const { x, y } = toSvg(evt); + sim.pin(id, x, y); + sim.setActive(true); + ensureRaf(); + } + + function onNodePointerMove(evt: PointerEvent) { + const drag = dragRef.current; + const sim = simRef.current; + if (!drag || !sim || drag.pointerId !== evt.pointerId) return; + drag.moved = true; + const { x, y } = toSvg(evt); + sim.pin(drag.id, x, y); + ensureRaf(); + } + + function onNodePointerUp(evt: PointerEvent, id: string) { + const drag = dragRef.current; + const sim = simRef.current; + if (!drag || !sim || drag.pointerId !== evt.pointerId) return; + (evt.currentTarget as Element).releasePointerCapture?.(evt.pointerId); + sim.setActive(false); + // A press that never moved is a click — toggle selection (node stays pinned). + if (!drag.moved) onSelect(selected === id ? null : id); + dragRef.current = null; + ensureRaf(); + } + + function onNodeDoubleClick(id: string) { + const sim = simRef.current; + if (!sim) return; + sim.unpin(id); + sim.reheat(0.5); + ensureRaf(); + } + + const sim = simRef.current; + const dimNode = (id: string) => (highlight ? !highlight.nodes.has(id) : false); + const dimEdge = (id: string) => (highlight ? !highlight.edges.has(id) : false); + const hotEdge = (id: string) => (highlight ? highlight.edges.has(id) : false); + + return ( + onSelect(null)} + > + + {graph.edges.map((e) => { + const a = sim?.get(e.source); + const b = sim?.get(e.target); + if (!a || !b) return null; + const style = EDGE[e.kind]; + const hot = hotEdge(e.id); + return ( + + ); + })} + + + {graph.nodes.map((n) => { + const p = sim?.get(n.id); + if (!p) return null; + const r = p.radius; + const dimmed = dimNode(n.id); + const isHover = hovered === n.id; + const isSel = selected === n.id; + const color = KIND_COLOR[n.kind]; + const pinned = p.fx != null; + return ( + onNodePointerDown(ev, n.id)} + onPointerMove={onNodePointerMove} + onPointerUp={(ev) => onNodePointerUp(ev, n.id)} + onDoubleClick={() => onNodeDoubleClick(n.id)} + onMouseEnter={() => setHovered(n.id)} + onMouseLeave={() => setHovered((cur) => (cur === n.id ? null : cur))} + onKeyDown={(ev) => { + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + onSelect(selected === n.id ? null : n.id); + } + }} + > + {(isSel || isHover) && ( + + )} + {pinned && !isSel && !isHover && ( + + )} + {nodeShape(n.kind, color, p.x, p.y, r)} + {showLabel(n, dimmed, isHover, isSel) && ( + + {n.label} + + )} + + ); + })} + + + ); +} diff --git a/frontend/src/components/graph/LearningLoop.tsx b/frontend/src/components/graph/LearningLoop.tsx new file mode 100644 index 0000000..88eb77e --- /dev/null +++ b/frontend/src/components/graph/LearningLoop.tsx @@ -0,0 +1,43 @@ +import type { LearningStage } from '@podman/shared'; +import { BLUE } from './encoding.js'; + +/** + * The continual-learning loop rail: observe → store → predict → outcome → adapt. + * The active stage (most-recent activity) gets a pulsing accent bar + ring. + */ +export function LearningLoop({ stages }: { stages: LearningStage[] }) { + return ( +
+

+ Learning loop +

+ {stages.map((s, i) => ( +
+
+ +
+

+ {String(i + 1).padStart(2, '0')} {s.title} +

+

{s.value}

+
+

{s.detail}

+
+ {i < stages.length - 1 && ( +

+ ↓ +

+ )} +
+ ))} +
+ ); +} diff --git a/frontend/src/components/graph/MetricsRail.tsx b/frontend/src/components/graph/MetricsRail.tsx new file mode 100644 index 0000000..23f9d7e --- /dev/null +++ b/frontend/src/components/graph/MetricsRail.tsx @@ -0,0 +1,37 @@ +import type { PodGraphMetric } from '@podman/shared'; +import { BLUE, RED, VIOLET, GREEN, AMBER } from './encoding.js'; + +const ACCENTS: Array<{ test: RegExp; color: string }> = [ + { test: /risk|collision|open/i, color: RED }, + { test: /accept/i, color: GREEN }, + { test: /learn|owner|adapt/i, color: VIOLET }, + { test: /vector|memory|store/i, color: AMBER }, +]; + +function accentFor(label: string, i: number): string { + for (const a of ACCENTS) if (a.test.test(label)) return a.color; + return [BLUE, RED, VIOLET, GREEN, AMBER][i % 5] ?? BLUE; +} + +export function MetricsRail({ metrics }: { metrics: PodGraphMetric[] }) { + return ( +
+

+ Workflow metrics +

+ {metrics.map((m, i) => ( +
+

{m.value}

+

+ {m.label} +

+

{m.detail}

+
+ ))} +
+ ); +} diff --git a/frontend/src/components/graph/SelectedNodePanel.tsx b/frontend/src/components/graph/SelectedNodePanel.tsx new file mode 100644 index 0000000..49c73f3 --- /dev/null +++ b/frontend/src/components/graph/SelectedNodePanel.tsx @@ -0,0 +1,51 @@ +import type { PodGraphNode } from '@podman/shared'; +import { Badge } from '@/components/ui/badge'; +import { statusColor, VIOLET } from './encoding.js'; + +export function SelectedNodePanel({ + node, + relCount, +}: { + node: PodGraphNode | undefined; + relCount: number; +}) { + if (!node) { + return ( +
+

+ Continual learning +

+

It learned

+

+ The violet{' '} + + learned_from + {' '} + edges are ownership PodMan retained from accepted interventions — the graph gets sharper + every session. Click any node to trace its relationships, or drag to rearrange. +

+
+ ); + } + return ( +
+

+ {node.kind} +

+

{node.label}

+
+ Status + + {node.status} + +
+
+ Relationships + {relCount} +
+ {node.summary && ( +

{node.summary}

+ )} +
+ ); +} diff --git a/frontend/src/components/graph/encoding.ts b/frontend/src/components/graph/encoding.ts new file mode 100644 index 0000000..37f96bb --- /dev/null +++ b/frontend/src/components/graph/encoding.ts @@ -0,0 +1,116 @@ +import type { CSSProperties } from 'react'; +import type { + PodGraph, + PodGraphNode, + PodGraphEdge, + PodGraphNodeKind, + ActivityKind, +} from '@podman/shared'; + +/** + * Fixed, light-readable hues for the node/edge encoding. Kept stable across + * light/dark so kinds stay distinguishable; only the chrome uses shadcn tokens. + */ +export const BLUE = '#2563eb'; +export const SLATE = '#475569'; +export const SLATE_EDGE = '#94a3b8'; +export const SLATE_FAINT = '#cbd5e1'; +export const AMBER = '#d97706'; +export const RED = '#dc2626'; +export const VIOLET = '#7c3aed'; +export const GREEN = '#16a34a'; + +/** Tag color + short label per activity-stream kind. */ +export const ACTIVITY_TAG: Record = { + editing: { color: SLATE, label: 'EDITING' }, + collision: { color: RED, label: 'COLLISION' }, + warns: { color: AMBER, label: 'WARNS' }, + outcome: { color: GREEN, label: 'OUTCOME' }, + learned_from: { color: VIOLET, label: 'LEARNED' }, +}; + +export const KIND_COLOR: Record = { + engineer: BLUE, + file: SLATE, + feature: AMBER, + collision: RED, + intervention: VIOLET, +}; + +export interface EdgeStyle { + c: string; + w: number; + dash?: boolean; +} + +export const EDGE: Record = { + owns: { c: BLUE, w: 2.4 }, + editing: { c: SLATE_EDGE, w: 1.9 }, + touches: { c: SLATE_FAINT, w: 1.5 }, + collides: { c: RED, w: 2.8 }, + warns: { c: AMBER, w: 2.8 }, + learned_from: { c: VIOLET, w: 2.4, dash: true }, +}; + +/** Collision/drawing radius for a node — scaled by its 0..1 weight. */ +export function nodeRadius(node: PodGraphNode): number { + const base = node.kind === 'collision' || node.kind === 'intervention' ? 14 : 13; + return base + Math.max(0, Math.min(1, node.weight)) * 7; +} + +export function statusColor(status: string): string { + if (status === 'risk') return RED; + if (status === 'learned') return VIOLET; + if (status === 'active') return BLUE; + return 'var(--muted-foreground)'; +} + +export type Mode = 'risk' | 'learn' | 'all'; + +export interface Highlight { + nodes: Set; + edges: Set; +} + +/** + * The lit set for the current mode/selection. A selected node lights its + * incident edges + neighbors; otherwise the mode lights the risk or learning + * chain (collision → intervention → learned_from). `all` lights everything. + */ +export 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)), + }; +} + +export const NODE_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)' } }, +]; + +export const EDGE_LEGEND: Array<{ label: string; color: string; dash?: boolean }> = [ + { label: 'collides', color: RED }, + { label: 'warns', color: AMBER }, + { label: 'learned_from', color: VIOLET, dash: true }, + { label: 'owns', color: BLUE }, +]; diff --git a/frontend/src/components/graph/forceSim.ts b/frontend/src/components/graph/forceSim.ts new file mode 100644 index 0000000..b2432eb --- /dev/null +++ b/frontend/src/components/graph/forceSim.ts @@ -0,0 +1,284 @@ +/** + * A tiny dependency-free force-directed layout — the same family of forces as + * d3-force (charge repulsion, link springs, centering, collision) integrated + * with velocity-Verlet and an annealing `alpha`. Kept in-house so the dynamic + * graph adds no new package / lockfile churn to a fast-moving shared `main`. + * + * Usage: `setData()` (diff-preserving — existing nodes keep their position), + * then drive `tick()` from a requestAnimationFrame loop until `settled()`. + */ + +export interface SimNodeInput { + id: string; + /** Drawing/collision radius. */ + radius: number; + /** Initial position hint (e.g. the server layout), used only for new nodes. */ + seedX: number; + seedY: number; +} + +export interface SimLinkInput { + source: string; + target: string; + /** Preferred rest length of the spring. */ + distance: number; + /** 0..1 spring strength. */ + strength: number; +} + +export interface SimNode { + id: string; + x: number; + y: number; + vx: number; + vy: number; + /** When non-null the node is pinned (dragged) and forces don't move it. */ + fx: number | null; + fy: number | null; + radius: number; +} + +const ALPHA_MIN = 0.001; +const ALPHA_DECAY = 1 - Math.pow(ALPHA_MIN, 1 / 300); // settle in ~300 ticks +const FRICTION = 0.62; // velocity retained per tick +const REPEL = 4400; // charge repulsion strength — must dominate centering or the graph collapses +const LINK_K = 0.45; // spring stiffness multiplier +const CENTER_STRENGTH = 0.014; // gentle positional pull — only keeps the cloud roughly centered +const RECENTER = 0.5; // per-tick centroid recentering (no compression, keeps graph framed) +const COLLIDE_PAD = 12; +const COLLIDE_STRENGTH = 1; // hard separation so linked nodes never stack +const COLLIDE_ITERS = 2; +const BOUND_PAD = 30; // keep nodes this far inside the canvas edges + +export class ForceSim { + nodes: SimNode[] = []; + links: SimLinkInput[] = []; + alpha = 1; + private byId = new Map(); + private alphaTarget = 0; + private center: { x: number; y: number }; + private width: number; + private height: number; + + constructor(width: number, height: number) { + this.width = width; + this.height = height; + this.center = { x: width / 2, y: height / 2 }; + } + + settled(): boolean { + return this.alpha < ALPHA_MIN && this.alphaTarget === 0; + } + + reheat(a = 0.7): void { + this.alpha = Math.max(this.alpha, a); + } + + /** Hold the simulation warm while dragging, then release. */ + setActive(active: boolean): void { + this.alphaTarget = active ? 0.18 : 0; + if (active) this.reheat(0.25); + } + + get(id: string): SimNode | undefined { + return this.byId.get(id); + } + + pin(id: string, x: number, y: number): void { + const n = this.byId.get(id); + if (n) { + n.fx = x; + n.fy = y; + } + } + + unpin(id: string): void { + const n = this.byId.get(id); + if (n) { + n.fx = null; + n.fy = null; + } + } + + /** Replace the graph, preserving the positions/pins of nodes that persist. */ + setData(nodeInputs: SimNodeInput[], linkInputs: SimLinkInput[]): { added: string[] } { + const prev = this.byId; + const next = new Map(); + const added: string[] = []; + for (const inp of nodeInputs) { + const old = prev.get(inp.id); + if (old) { + old.radius = inp.radius; + next.set(inp.id, old); + } else { + next.set(inp.id, { + id: inp.id, + x: inp.seedX + (Math.random() - 0.5) * 14, + y: inp.seedY + (Math.random() - 0.5) * 14, + vx: 0, + vy: 0, + fx: null, + fy: null, + radius: inp.radius, + }); + added.push(inp.id); + } + } + this.byId = next; + this.nodes = [...next.values()]; + this.links = linkInputs.filter((l) => next.has(l.source) && next.has(l.target)); + return { added }; + } + + /** Advance one step. Returns false when already settled (no work done). */ + tick(): boolean { + if (this.settled()) return false; + this.alpha += (this.alphaTarget - this.alpha) * ALPHA_DECAY; + const a = this.alpha; + this.applyCharge(a); + this.applyLinks(a); + this.applyCenter(a); + for (let k = 0; k < COLLIDE_ITERS; k++) this.applyCollide(); + const maxX = this.width - BOUND_PAD; + const maxY = this.height - BOUND_PAD; + for (const n of this.nodes) { + if (n.fx != null) { + n.x = n.fx; + n.vx = 0; + } else { + n.vx *= FRICTION; + n.x += n.vx; + if (n.x < BOUND_PAD) { + n.x = BOUND_PAD; + n.vx = 0; + } else if (n.x > maxX) { + n.x = maxX; + n.vx = 0; + } + } + if (n.fy != null) { + n.y = n.fy; + n.vy = 0; + } else { + n.vy *= FRICTION; + n.y += n.vy; + if (n.y < BOUND_PAD) { + n.y = BOUND_PAD; + n.vy = 0; + } else if (n.y > maxY) { + n.y = maxY; + n.vy = 0; + } + } + } + return true; + } + + private applyCharge(alpha: number): void { + const ns = this.nodes; + for (let i = 0; i < ns.length; i++) { + const a = ns[i]; + if (!a) continue; + for (let j = i + 1; j < ns.length; j++) { + const b = ns[j]; + if (!b) continue; + let dx = b.x - a.x; + let dy = b.y - a.y; + let d2 = dx * dx + dy * dy; + if (d2 === 0) { + dx = (j - i) * 0.5; + dy = (i + 1) * 0.4; + d2 = dx * dx + dy * dy; + } + const dist = Math.sqrt(d2); + const force = (REPEL * alpha) / d2; + const ux = dx / dist; + const uy = dy / dist; + a.vx -= ux * force; + a.vy -= uy * force; + b.vx += ux * force; + b.vy += uy * force; + } + } + } + + private applyLinks(alpha: number): void { + for (const link of this.links) { + const s = this.byId.get(link.source); + const t = this.byId.get(link.target); + if (!s || !t) continue; + let dx = t.x - s.x; + let dy = t.y - s.y; + let d2 = dx * dx + dy * dy; + if (d2 === 0) { + dx = 0.5; + dy = 0.5; + d2 = 0.5; + } + const dist = Math.sqrt(d2); + const k = ((dist - link.distance) / dist) * alpha * link.strength * LINK_K; + const mx = dx * k * 0.5; + const my = dy * k * 0.5; + s.vx += mx; + s.vy += my; + t.vx -= mx; + t.vy -= my; + } + } + + private applyCenter(alpha: number): void { + const n = this.nodes.length; + if (!n) return; + // Recenter the whole cloud so its centroid sits at canvas center (this does + // NOT compress the layout — repulsion/links set the spread), plus a gentle + // positional pull so stray/isolated nodes don't park against the edge. + let cx = 0; + let cy = 0; + for (const nd of this.nodes) { + cx += nd.x; + cy += nd.y; + } + cx = (this.center.x - cx / n) * RECENTER; + cy = (this.center.y - cy / n) * RECENTER; + for (const nd of this.nodes) { + if (nd.fx == null) { + nd.x += cx; + nd.vx += (this.center.x - nd.x) * CENTER_STRENGTH * alpha; + } + if (nd.fy == null) { + nd.y += cy; + nd.vy += (this.center.y - nd.y) * CENTER_STRENGTH * alpha; + } + } + } + + private applyCollide(): void { + const ns = this.nodes; + for (let i = 0; i < ns.length; i++) { + const a = ns[i]; + if (!a) continue; + for (let j = i + 1; j < ns.length; j++) { + const b = ns[j]; + if (!b) continue; + let dx = b.x - a.x; + let dy = b.y - a.y; + const d2 = dx * dx + dy * dy; + const min = a.radius + b.radius + COLLIDE_PAD; + if (d2 >= min * min) continue; + let dist = Math.sqrt(d2); + if (dist === 0) { + dx = j - i; + dy = i + 1; + dist = Math.sqrt(dx * dx + dy * dy) || 1; + } + const push = ((min - dist) / dist) * 0.5 * COLLIDE_STRENGTH; + const ox = dx * push; + const oy = dy * push; + if (a.fx == null) a.x -= ox; + if (a.fy == null) a.y -= oy; + if (b.fx == null) b.x += ox; + if (b.fy == null) b.y += oy; + } + } + } +} diff --git a/frontend/src/lib/graph.ts b/frontend/src/lib/graph.ts index b3a57c7..9d746de 100644 --- a/frontend/src/lib/graph.ts +++ b/frontend/src/lib/graph.ts @@ -8,3 +8,8 @@ export async function fetchPodGraph(podId: string): Promise { if (!res.ok) throw new Error(`graph request failed: ${res.status}`); return res.json() as Promise; } + +/** WebSocket URL for the live event bus — used to nudge the graph to refetch. */ +export function backendEventsUrl(): string { + return `${BACKEND_URL.replace(/^http/, 'ws')}/api/events`; +} diff --git a/shared/src/graph.ts b/shared/src/graph.ts index c8175fb..df08e5f 100644 --- a/shared/src/graph.ts +++ b/shared/src/graph.ts @@ -48,6 +48,36 @@ export interface PodGraphMetric { detail: string; } +/** The five stages of PodMan's continual-learning loop, in order. */ +export type LearningStageKey = 'observe' | 'store' | 'predict' | 'outcome' | 'adapt'; + +/** One stage of the learning-loop rail (observe→store→predict→outcome→adapt). */ +export interface LearningStage { + key: LearningStageKey; + /** UPPERCASE display title, e.g. "OBSERVE". */ + title: string; + /** Headline figure for the stage, e.g. "5/s" or "124". */ + value: string; + /** One-line detail under the title. */ + detail: string; + /** True for the single most-recently-active stage (pulses in the UI). */ + active: boolean; +} + +/** Kind of an activity-stream entry (drives the colored tag). */ +export type ActivityKind = 'editing' | 'collision' | 'warns' | 'outcome' | 'learned_from'; + +/** One time-tagged entry in the activity stream. */ +export interface ActivityEvent { + /** Stable id (source doc id + kind) so the UI can animate diffs. */ + id: string; + /** ISO timestamp the event happened. */ + at: string; + kind: ActivityKind; + /** Human-readable line, e.g. "Yahya opened auth.ts — unpushed changes". */ + text: string; +} + /** A point-in-time render of a pod's team_model. */ export interface PodGraph { podId: string; @@ -56,6 +86,10 @@ export interface PodGraph { nodes: PodGraphNode[]; edges: PodGraphEdge[]; metrics: PodGraphMetric[]; + /** Continual-learning loop counts (observe→…→adapt). Additive/optional. */ + loop?: LearningStage[]; + /** Recent activity feed, most-recent first, capped ~8. Additive/optional. */ + activity?: ActivityEvent[]; } /** One node as a standalone document in the `graph_nodes` collection. */ diff --git a/shared/src/index.ts b/shared/src/index.ts index b175dc3..dc861df 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -18,6 +18,10 @@ export type { PodGraphNodeKind, PodGraphEdgeKind, PodGraphNodeStatus, + LearningStage, + LearningStageKey, + ActivityEvent, + ActivityKind, GraphNodeDoc, GraphEdgeDoc, } from './graph.js';