Files
oikos/web/src/lib/components/SessionGraph.svelte
dtoro b423cf4dea
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
plan-approve-once policy + cooler empty states + remove graph header
Backend:
- proposePlan sets plan window in autonomy_settings (nomos:plan:<session>)
- run handler checks plan window — auto-executes config_mutation commands
  within plan without per-action approval
- planWindowActive function in server.go
- Plan window cleaned up on completeTask (already covered by LIKE '%:' || )

Frontend:
- Removed 'Session graph' header bar
- Cooler empty states: Plan shows animated dots + 'Awaiting plan…',
  Activity shows pulsing dots + 'Waiting for activity…'
2026-07-14 13:04:51 +02:00

480 lines
18 KiB
Svelte

<script lang="ts">
import { onDestroy, untrack } from 'svelte'
import {
forceSimulation,
forceLink,
forceManyBody,
forceCenter,
forceCollide,
forceX,
forceY,
type Simulation
} from 'd3-force'
import { fetchGraph, type Entity } from '$lib/api'
import { messages } from '$lib/stores/chat'
import { touched, healthDiffs } from '$lib/stores/workspace'
import { relativeTime } from '$lib/utils'
import { Badge } from '$lib/components/ui/badge'
import { Button } from '$lib/components/ui/button'
import EntitySheet from '$lib/components/EntitySheet.svelte'
import ExternalLinkIcon from '@lucide/svelte/icons/external-link'
interface Node extends Entity {
x?: number
y?: number
vx?: number
vy?: number
fx?: number | null
fy?: number | null
degree: number
}
interface Edge {
source: string | Node
target: string | Node
type: string
}
// Probe/bookkeeping entity types are excluded — a health conversation
// mentions dozens of check:… slugs that would swamp the fleet topology.
const EXCLUDED = new Set(['check', 'execution'])
// Slug shape: lowercase type prefix, then one or more colon-separated
// segments (host:hubris, check:ping:8cf, lxc:caddy, investigation:foo/bar).
const SLUG_RE = /\b[a-z][a-z-]*:[a-z0-9][a-z0-9._/-]*(?::[a-z0-9._/-]+)*/g
let nodes = $state<Node[]>([])
let links = $state<Edge[]>([])
let selected = $state<Node | null>(null)
let sheetSlug = $state<string | null>(null)
let sheetOpen = $state(false)
let sim: Simulation<Node, Edge> | null = null
// Non-reactive caches (persist across message deltas). resolvedVersion is a
// reactive counter bumped when async resolution finishes, so the reconcile
// effect re-runs once entities come back.
const resolvedCache = new Map<string, Node | null>()
const edgeCache: { source: string; target: string; type: string }[] = []
const edgeKeys = new Set<string>()
const resolving = new Set<string>()
let resolvedVersion = $state(0)
// container size drives the simulation coordinate space (1:1 with pixels so
// node dragging maps cleanly regardless of the resizable panel width).
let container = $state<HTMLDivElement | null>(null)
let cw = $state(300)
let ch = $state(300)
function collectSlugs(value: unknown, out: Set<string>) {
if (typeof value === 'string') {
const m = value.match(SLUG_RE)
if (m) for (const s of m) out.add(s.replace(/[.,;)\]]+$/, ''))
} else if (Array.isArray(value)) {
for (const v of value) collectSlugs(v, out)
} else if (value && typeof value === 'object') {
for (const v of Object.values(value)) collectSlugs(v, out)
}
}
// Only pull from what the conversation is *about*: message text and the
// arguments the agent passed to tools — never bulk result rows (a single
// get_health_summary would otherwise dump all 168 entities into the graph).
const candidateSlugs = $derived.by(() => {
const out = new Set<string>()
for (const m of $messages) {
collectSlugs(m.text, out)
for (const t of m.tools) collectSlugs(t.args, out)
}
return out
})
async function resolveSlugs(slugs: string[]) {
const todo = slugs.filter((s) => !resolvedCache.has(s) && !resolving.has(s))
if (!todo.length) return
for (const s of todo) resolving.add(s)
await Promise.all(
todo.map(async (s) => {
try {
const g = await fetchGraph({ root: s, depth: 1 })
const root = g?.nodes.find((n) => n.slug === s) ?? null
resolvedCache.set(s, root ? { ...root, degree: 0 } : null)
if (g && root) {
for (const e of g.edges) {
const k = `${e.source}|${e.target}|${e.type}`
if (!edgeKeys.has(k)) {
edgeKeys.add(k)
edgeCache.push({ source: e.source, target: e.target, type: e.type })
}
}
}
} catch {
resolvedCache.set(s, null)
} finally {
resolving.delete(s)
}
})
)
resolvedVersion++
}
function reconcile(cands: Set<string>) {
const desired: Node[] = []
const seen = new Set<string>()
for (const s of cands) {
const e = resolvedCache.get(s)
if (e && !EXCLUDED.has(e.type) && !seen.has(e.slug)) {
seen.add(e.slug)
desired.push(e)
}
}
const desiredSlugs = new Set(desired.map((e) => e.slug))
const current = nodes
const curSlugs = new Set(current.map((n) => n.slug))
let changed = desiredSlugs.size !== curSlugs.size
if (!changed) for (const s of desiredSlugs) if (!curSlugs.has(s)) { changed = true; break }
if (!changed) return
const bySlug = new Map(current.map((n) => [n.slug, n]))
const ls = edgeCache
.filter((e) => desiredSlugs.has(e.source) && desiredSlugs.has(e.target))
.map((e) => ({ ...e }))
const deg = new Map<string, number>()
for (const l of ls) {
deg.set(l.source as string, (deg.get(l.source as string) ?? 0) + 1)
deg.set(l.target as string, (deg.get(l.target as string) ?? 0) + 1)
}
const next = desired.map((e) => {
const p = bySlug.get(e.slug)
return { ...e, x: p?.x, y: p?.y, vx: p?.vx, vy: p?.vy, degree: deg.get(e.slug) ?? 0 }
})
nodes = next
links = ls
if (selected && !desiredSlugs.has(selected.slug)) selected = null
buildSim()
}
$effect(() => {
const cands = candidateSlugs
void resolvedVersion
const missing = [...cands].filter((s) => !resolvedCache.has(s))
if (missing.length) resolveSlugs(missing)
untrack(() => reconcile(cands))
})
function buildSim() {
sim?.stop()
if (!nodes.length) {
sim = null
return
}
sim = forceSimulation(nodes)
.force('link', forceLink<Node, Edge>(links).id((n) => n.slug).distance(48).strength(0.5))
.force('charge', forceManyBody().strength(-150).distanceMax(240))
.force('center', forceCenter(cw / 2, ch / 2))
.force('collide', forceCollide<Node>((n) => nodeRadius(n) + 6))
.force('x', forceX(cw / 2).strength(0.06))
.force('y', forceY(ch / 2).strength(0.06))
.velocityDecay(0.34)
.alphaDecay(0.045)
.on('tick', () => {
nodes = [...nodes]
})
}
// keep the layout centred as the panel resizes
$effect(() => {
const w = cw
const h = ch
if (sim) {
sim.force('center', forceCenter(w / 2, h / 2))
sim.force('x', forceX(w / 2).strength(0.06))
sim.force('y', forceY(h / 2).strength(0.06))
sim.alpha(0.3).restart()
}
})
$effect(() => {
if (!container) return
const ro = new ResizeObserver((entries) => {
const r = entries[0].contentRect
cw = Math.max(r.width, 1)
ch = Math.max(r.height, 1)
})
ro.observe(container)
return () => ro.disconnect()
})
onDestroy(() => sim?.stop())
const healthColor: Record<string, string> = {
healthy: 'var(--success)',
degraded: 'var(--warning)',
down: 'var(--destructive)',
stale: 'var(--warning)',
unknown: 'var(--muted-foreground)'
}
function nodeColor(n: Node): string {
return n.health ? healthColor[n.health] ?? 'var(--muted-foreground)' : 'var(--muted-foreground)'
}
function nodeRadius(n: Node): number {
return 6 + Math.min(Math.sqrt(n.degree) * 1.5, 6)
}
function shortName(slug: string): string {
return slug.split(':').pop() ?? slug
}
// Live touch/health-diff lookups, keyed by slug for O(1) per-node checks
// during render. Kept as plain objects (not Maps) since Svelte 5 runes track
// object identity fine and this is small (≤12 touched, ≤8 diffs).
const touchedBySlug = $derived.by(() => {
const m: Record<string, true> = {}
for (const t of $touched) m[t.slug] = true
return m
})
const diffBySlug = $derived.by(() => {
const m: Record<string, { from: string; to: string }> = {}
for (const d of $healthDiffs) if (!(d.slug in m)) m[d.slug] = d
return m
})
const nowTouching = $derived($touched[0] ?? null)
function endpoint(end: string | Node): Node | undefined {
return typeof end === 'object' ? end : nodes.find((n) => n.slug === end)
}
function endpointSlug(end: string | Node): string {
return typeof end === 'object' ? end.slug : end
}
// ─── drag / select ───────────────────────────────────────────────────
let dragState: { node: Node; moved: boolean } | null = null
function toLocal(clientX: number, clientY: number) {
const rect = container!.getBoundingClientRect()
return { x: clientX - rect.left, y: clientY - rect.top }
}
function onNodeDown(e: PointerEvent, node: Node) {
e.stopPropagation()
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
dragState = { node, moved: false }
sim?.alphaTarget(0.2).restart()
}
function onMove(e: PointerEvent) {
if (!dragState) return
const p = toLocal(e.clientX, e.clientY)
dragState.node.fx = p.x
dragState.node.fy = p.y
dragState.moved = true
nodes = [...nodes]
}
function onUp() {
if (!dragState) return
const { node, moved } = dragState
node.fx = null
node.fy = null
sim?.alphaTarget(0)
dragState = null
if (!moved) selected = selected?.slug === node.slug ? null : node
}
const selectedRelations = $derived(
selected
? links
.filter((l) => endpointSlug(l.source) === selected!.slug || endpointSlug(l.target) === selected!.slug)
.map((l) => {
const outgoing = endpointSlug(l.source) === selected!.slug
return { dir: outgoing ? '→' : '←', type: l.type, other: outgoing ? endpointSlug(l.target) : endpointSlug(l.source) }
})
: []
)
function openFull() {
if (!selected) return
sheetSlug = selected.slug
sheetOpen = true
}
</script>
<aside class="flex h-full min-h-0 flex-col bg-card/40">
{#if nowTouching}
<div class="flex shrink-0 items-center gap-1.5 border-b bg-primary/5 px-3 py-1.5 text-[11px] text-primary">
<span class="size-1.5 animate-pulse rounded-full bg-primary"></span>
Now touching <code class="font-mono">{nowTouching.slug}</code>
</div>
{/if}
<div bind:this={container} class="relative min-h-0 flex-1 overflow-hidden">
{#if nodes.length === 0}
<div class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-4 px-6 text-center">
<svg viewBox="0 0 120 120" class="size-24 text-muted-foreground/40" fill="none">
<circle cx="60" cy="60" r="6" fill="currentColor">
<animate attributeName="opacity" values="0.4;1;0.4" dur="2.4s" repeatCount="indefinite" />
</circle>
<g stroke="currentColor" stroke-width="1" opacity="0.5">
<line x1="60" y1="60" x2="26" y2="34"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="3s" repeatCount="indefinite" /></line>
<line x1="60" y1="60" x2="96" y2="40"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="3.4s" repeatCount="indefinite" /></line>
<line x1="60" y1="60" x2="34" y2="92"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="2.8s" repeatCount="indefinite" /></line>
<line x1="60" y1="60" x2="92" y2="90"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="3.1s" repeatCount="indefinite" /></line>
</g>
<g fill="currentColor">
<circle cx="26" cy="34" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="3s" repeatCount="indefinite" /></circle>
<circle cx="96" cy="40" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="3.4s" repeatCount="indefinite" /></circle>
<circle cx="34" cy="92" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="2.8s" repeatCount="indefinite" /></circle>
<circle cx="92" cy="90" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="3.1s" repeatCount="indefinite" /></circle>
</g>
</svg>
<p class="max-w-[16rem] text-xs leading-relaxed text-muted-foreground">
Entities Nomos explores in this conversation appear here, wired up by their relationships.
</p>
</div>
{:else}
<svg
width={cw}
height={ch}
viewBox="0 0 {cw} {ch}"
class="h-full w-full touch-none select-none"
role="application"
aria-label="Session entity graph"
onpointermove={onMove}
onpointerup={onUp}
onpointercancel={onUp}
>
<g>
{#each links as link}
{@const s = endpoint(link.source)}
{@const t = endpoint(link.target)}
{#if s?.x != null && t?.x != null && s?.y != null && t?.y != null}
{@const focus = selected && (s.slug === selected.slug || t.slug === selected.slug)}
<line
x1={s.x}
y1={s.y}
x2={t.x}
y2={t.y}
stroke="var(--muted-foreground)"
stroke-width={focus ? 1.6 : 1}
opacity={selected ? (focus ? 0.7 : 0.12) : 0.35}
>
<title>{link.type}</title>
</line>
{/if}
{/each}
</g>
<g>
{#each nodes as node (node.slug)}
{#if node.x != null && node.y != null}
{@const r = nodeRadius(node)}
{@const isSel = selected?.slug === node.slug}
{@const dim = selected && !isSel && !selectedRelations.some((rel) => rel.other === node.slug)}
{@const isTouched = node.slug in touchedBySlug}
{@const diff = diffBySlug[node.slug]}
<g
transform="translate({node.x},{node.y})"
class="cursor-pointer"
opacity={dim ? 0.35 : 1}
role="button"
tabindex="0"
onpointerdown={(e) => onNodeDown(e, node)}
onkeydown={(e) => e.key === 'Enter' && (selected = node)}
>
{#if isSel}
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
{/if}
{#if isTouched}
<circle r={r + 4} fill="none" stroke="var(--primary)" stroke-width="1.5" opacity="0.8">
<animate attributeName="r" values="{r + 3};{r + 8};{r + 3}" dur="1.6s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.8;0.1;0.8" dur="1.6s" repeatCount="indefinite" />
</circle>
{/if}
<circle r={r} fill={nodeColor(node)} stroke={isSel ? 'var(--foreground)' : 'var(--background)'} stroke-width={isSel ? 2 : 1.5} />
<text
y={r + 10}
text-anchor="middle"
font-size="9"
fill={isSel ? 'var(--foreground)' : 'var(--muted-foreground)'}
paint-order="stroke"
stroke="var(--background)"
stroke-width="2.5"
class="pointer-events-none"
>
{shortName(node.slug)}
</text>
{#if diff}
<text
y={-r - 6}
text-anchor="middle"
font-size="8"
fill="var(--warning)"
paint-order="stroke"
stroke="var(--background)"
stroke-width="2.5"
class="pointer-events-none"
>
{diff.from}{diff.to}
</text>
{/if}
</g>
{/if}
{/each}
</g>
</svg>
{/if}
</div>
{#if selected}
<div class="max-h-[55%] shrink-0 space-y-3 overflow-y-auto border-t p-3 text-xs">
<div class="flex flex-wrap items-center gap-1.5">
<span class="font-mono text-sm font-semibold">{selected.slug}</span>
<Badge variant="outline">{selected.type}</Badge>
{#if selected.state}<Badge variant="secondary">{selected.state}</Badge>{/if}
</div>
{#if selected.health}
<div class="flex items-center gap-1.5 text-muted-foreground">
<span class="size-2 rounded-full" style="background: {nodeColor(selected)}"></span>
{selected.health} · checked {relativeTime(selected.last_check_at)}
</div>
{/if}
{#if selected.attributes && Object.keys(selected.attributes).length}
<div>
<p class="mb-1 font-medium text-muted-foreground">Attributes</p>
<dl class="flex flex-col gap-1">
{#each Object.entries(selected.attributes).slice(0, 6) as [key, value]}
<div class="flex items-start justify-between gap-3 border-b pb-1 last:border-0">
<dt class="shrink-0 font-mono text-muted-foreground">{key}</dt>
<dd class="min-w-0 flex-1 truncate text-right">{typeof value === 'object' ? JSON.stringify(value) : String(value)}</dd>
</div>
{/each}
</dl>
</div>
{/if}
{#if selectedRelations.length}
<div>
<p class="mb-1 font-medium text-muted-foreground">Relations ({selectedRelations.length})</p>
<div class="flex flex-col gap-1">
{#each selectedRelations as rel}
<div class="flex items-center gap-1 font-mono">
<span class="text-muted-foreground">{rel.dir} {rel.type}</span>
<button type="button" class="truncate hover:underline" onclick={() => { const n = nodes.find((x) => x.slug === rel.other); if (n) selected = n }}>
{rel.other}
</button>
</div>
{/each}
</div>
</div>
{/if}
<Button variant="outline" size="sm" class="w-full" onclick={openFull}>
<ExternalLinkIcon class="mr-1 size-3.5" />
Full detail
</Button>
</div>
{/if}
</aside>
<EntitySheet slug={sheetSlug} bind:open={sheetOpen} />