/** * DI Deps panel — the persistent dependency DAG (`IDebugGraphService.graph`) * as Finder-style Miller columns with path-scoped relation markers. Edges * point consumer → dependency, so "X depends on Y" ⟺ an edge * `from: X → to: Y`: * * - column 0 lists the roots: services that never appear as an edge's `to` * (nothing consumes them, isolated nodes included), token-sorted, * constrained by the scope filter; each root row carries the level-0 * color bar; * - clicking a row in column N truncates the expansion path there and * opens column N+1 with that service's direct dependencies (along `to`, * unfiltered — cross-scope rows get a scopePath subtitle); * - relation bars are path-scoped: a row's left edge carries one 3px bar * per path ancestor that depends on it DIRECTLY (edge ancestor → node), * ordered by ancestor depth — the direct parent always contributes one, * and a grandparent with a skip-level edge adds another. Every color is * the fixed per-LEVEL identity color (`levelColor(depth)`), so a bar * reads directly as "depended on by the service N levels up", identical * in every path; column headers carry the parent level's color chip so * bars map back to ancestors; * - path highlight: rows covered by the closure (reachable along `to`) of * the path's root get a faint background in the level-0 color; * - rows without dependencies are dimmed and carry no chevron; a row * already in the current path is marked ↩ (cycle guard — columns only * ever open on click, so there is no auto-recursion). * * Pure React + Tailwind, no graph or layout library. */ import type { UnitState } from '@moonshot-ai/agent-core-v2/_base/di/cascadeEngine'; import type { DebugGraph, DebugGraphNode } from '@moonshot-ai/agent-core-v2/debug/debugGraph'; import { useEffect, useMemo, useRef, useState } from 'react'; const STATE_COLORS: Record = { Pending: '#a3a3a3', Activating: '#38bdf8', Active: '#34d399', Unloading: '#fbbf24', Failed: '#f87171', none: '#525252', }; /** * Fixed identity color per path depth (level): `levelColor(d)` is the same * for every service at depth d in every path, so a row's color bars read * directly as "depended on by the service at level 0 / 1 / 2 …". */ const LEVEL_PALETTE = [ '#f87171', '#fb923c', '#fbbf24', '#a3e635', '#34d399', '#22d3ee', '#38bdf8', '#818cf8', '#c084fc', '#f472b6', ]; function levelColor(depth: number): string { return LEVEL_PALETTE[depth % LEVEL_PALETTE.length] ?? '#525252'; } function scopeDepth(path: string): number { return path.split('/').length; } function compareScopePaths(a: string, b: string): number { return scopeDepth(a) - scopeDepth(b) || a.localeCompare(b); } function byToken(a: DebugGraphNode, b: DebugGraphNode): number { return a.token.localeCompare(b.token); } function fallbackNode(id: string): DebugGraphNode { return { id, token: id, scopePath: '' }; } interface DepsColumn { readonly title: string; /** * The scope the column's rows belong to by default ('' = unfiltered root * column): rows from any other scope get a scopePath subtitle, as do all * rows when the column spans more than one scope. */ readonly homeScope: string; readonly items: readonly DebugGraphNode[]; } export function DiGraphPanel({ graph }: { graph: DebugGraph }) { const [scopeFilter, setScopeFilter] = useState(''); /** Selected row id per column — path[i] is the row expanded into column i+1. */ const [path, setPath] = useState([]); const scrollRef = useRef(null); const allScopes = useMemo( () => [...new Set(graph.nodes.map((n) => n.scopePath))].toSorted(compareScopePaths), [graph], ); const nodesById = useMemo(() => new Map(graph.nodes.map((n) => [n.id, n])), [graph]); /** Direct dependencies per consumer id (edge `to` endpoints), deduped. */ const depsOf = useMemo(() => { const acc = new Map>(); for (const e of graph.edges) { const set = acc.get(e.from) ?? new Set(); set.add(e.to); acc.set(e.from, set); } return acc; }, [graph]); /** Services nothing consumes (never an edge's `to`), scope-filtered. */ const roots = useMemo(() => { const consumed = new Set(graph.edges.map((e) => e.to)); const pool = scopeFilter === '' ? graph.nodes : graph.nodes.filter((n) => n.scopePath === scopeFilter); return pool.filter((n) => !consumed.has(n.id)).toSorted(byToken); }, [graph, scopeFilter]); // Walk the path, truncating it at the first entry that is no longer // selectable (scope filter change or data refresh), so rendering and // follow-up clicks always work from a valid prefix. const { columns, usedPath } = useMemo(() => { const columns: DepsColumn[] = []; const usedPath: string[] = []; columns.push({ title: `roots · ${scopeFilter === '' ? 'all scopes' : scopeFilter}`, homeScope: scopeFilter, items: roots, }); let items = roots; for (const id of path) { if (!items.some((n) => n.id === id)) break; usedPath.push(id); const deps = [...(depsOf.get(id) ?? [])] .map((depId) => nodesById.get(depId) ?? fallbackNode(depId)) .toSorted(byToken); if (deps.length === 0) break; const parent = nodesById.get(id); columns.push({ title: parent?.token ?? id, homeScope: parent?.scopePath ?? '', items: deps, }); items = deps; } return { columns, usedPath }; }, [scopeFilter, path, roots, depsOf, nodesById]); // Keep the deepest column in view as the path grows. useEffect(() => { const el = scrollRef.current; if (el !== null) el.scrollLeft = el.scrollWidth; }, [columns.length]); const select = (columnIndex: number, id: string) => { setPath([...usedPath.slice(0, columnIndex), id]); }; // Path context: the root the current path starts at (column 0 only holds // roots, so this is always one — guarded anyway). const pathRootId = usedPath[0]; // Path-highlight: nodes reachable (along `to`) from the path's root, // tinted in the fixed level-0 color. Only the path root's closure is // needed — computed lazily per selection. const pathTint = pathRootId === undefined ? undefined : `${levelColor(0)}1a`; const pathClosure = useMemo(() => { if (pathRootId === undefined) return undefined; const seen = new Set(); const stack = [pathRootId]; while (stack.length > 0) { const id = stack.pop() ?? ''; if (seen.has(id)) continue; seen.add(id); for (const dep of depsOf.get(id) ?? []) stack.push(dep); } return seen; }, [pathRootId, depsOf]); return (
{(Object.keys(STATE_COLORS) as (UnitState | 'none')[]).map((state) => ( {state === 'none' ? 'unseen' : state} ))}
{graph.nodes.length === 0 ? (
no nodes
) : (
{columns.map((column, columnIndex) => ( ))}
)}
); } function DepsColumnView({ column, columnIndex, usedPath, depsOf, nodesById, pathTint, pathClosure, onSelect, }: { column: DepsColumn; columnIndex: number; usedPath: readonly string[]; depsOf: ReadonlyMap>; nodesById: ReadonlyMap; pathTint?: string; pathClosure?: ReadonlySet; onSelect: (columnIndex: number, id: string) => void; }) { const mixedScopes = new Set(column.items.map((n) => n.scopePath)).size > 1; const selectedId = usedPath[columnIndex]; return (
{columnIndex > 0 ? ( ) : null} {column.title} {column.items.length}
{column.items.length === 0 ? (
{columnIndex === 0 ? 'no roots' : 'no nodes'}
) : ( column.items.map((node) => { const hasDeps = (depsOf.get(node.id)?.size ?? 0) > 0; const selected = node.id === selectedId; const inPath = usedPath.slice(0, columnIndex).includes(node.id); const showScope = mixedScopes || (column.homeScope !== '' && node.scopePath !== column.homeScope); // Path-scoped relation bars: column 0 rows carry the level-0 // color; expanded columns carry one bar per path ancestor with a // DIRECT edge to this node, each in that ancestor's level color. const bars: { color: string; label: string }[] = []; if (columnIndex === 0) { bars.push({ color: levelColor(0), label: node.token }); } else { usedPath.slice(0, columnIndex).forEach((ancestorId, depth) => { if (depsOf.get(ancestorId)?.has(node.id) === true) { bars.push({ color: levelColor(depth), label: nodesById.get(ancestorId)?.token ?? ancestorId, }); } }); } const inPathTree = !selected && pathClosure?.has(node.id) === true; const details = [ `state: ${node.state ?? 'unseen'}`, node.uid !== undefined ? `#${node.uid}` : undefined, inPath ? 'already in path' : undefined, columnIndex > 0 && bars.length > 0 ? `direct dependency of: ${bars.map((b) => b.label).join(', ')}` : undefined, ] .filter((s) => s !== undefined) .join('\n'); return ( ); }) )}
); }