import { useEffect, useState, useCallback } from 'react' import { useParams } from 'react-router-dom' import { ReactFlow, Background, Controls, MiniMap, useNodesState, useEdgesState, addEdge, type Node, type Edge, type Connection, MarkerType, Panel, } from '@xyflow/react' import '@xyflow/react/dist/style.css' import dagre from 'dagre' import { apiFetch } from '@core/lib/api' import { useAppPath } from '@core/hooks/useAppPath' import { Button } from '@core/components/ui/button' import { Badge } from '@core/components/ui/badge' import { Skeleton } from '@core/components/ui/skeleton' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@core/components/ui/select' import { RefreshCw, ChevronRight, ZoomIn, Layers } from 'lucide-react' // ─── LAYOUT ─────────────────────────────────────────────────────────────────── const NODE_WIDTH = 180 const NODE_HEIGHT = 60 const FILE_WIDTH = 200 const FILE_HEIGHT = 40 type ViewMode = 'files' | 'chunks' | 'mixed' function layoutGraph(nodes: Node[], edges: Edge[]): { nodes: Node[]; edges: Edge[] } { const g = new dagre.graphlib.Graph() g.setDefaultEdgeLabel(() => ({})) g.setGraph({ rankdir: 'LR', nodesep: 40, ranksep: 80 }) for (const node of nodes) { g.setNode(node.id, { width: node.data?.isFile ? FILE_WIDTH : NODE_WIDTH, height: node.data?.isFile ? FILE_HEIGHT : NODE_HEIGHT }) } for (const edge of edges) { g.setEdge(edge.source, edge.target) } dagre.layout(g) const positionedNodes = nodes.map(node => { const pos = g.node(node.id) const w = node.data?.isFile ? FILE_WIDTH : NODE_WIDTH const h = node.data?.isFile ? FILE_HEIGHT : NODE_HEIGHT return { ...node, position: { x: pos.x - w / 2, y: pos.y - h / 2 } } }) return { nodes: positionedNodes, edges } } // ─── CUSTOM NODE COLOURS ────────────────────────────────────────────────────── const CHUNK_TYPE_COLORS: Record = { function: '#6366F1', // indigo class: '#8B5CF6', // violet interface: '#10B981', // emerald type: '#06B6D4', // cyan component: '#F59E0B', // amber hook: '#EC4899', // pink route: '#EF4444', // red config: '#64748B', // slate } function nodeColor(node: Node): string { if (node.data?.isFile) return '#1E293B' if (node.data?.is_dead_code) return '#94A3B8' return CHUNK_TYPE_COLORS[node.data?.chunk_type as string] || '#6366F1' } // ─── PROJECT DATA → FLOW NODES + EDGES ─────────────────────────────────────── interface ProjectGraph { nodes: { files: any[] chunks: any[] } edges: { imports: { source_id: string; target_id: string; metadata?: any }[] calls: { source_id: string; target_id: string; metadata?: any }[] } } function buildFlowGraph(graph: ProjectGraph, viewMode: ViewMode): { nodes: Node[]; edges: Edge[] } { const nodes: Node[] = [] const edges: Edge[] = [] if (viewMode === 'files' || viewMode === 'mixed') { for (const file of graph.nodes.files) { nodes.push({ id: file.id, type: 'default', position: { x: 0, y: 0 }, data: { label: file.file_path.split('/').pop() || file.file_path, isFile: true, file_path: file.file_path, language: file.language, chunk_count: file.chunk_count, is_dead_code: file.is_dead_code, }, style: { background: file.is_dead_code ? '#64748B' : '#0F172A', color: '#F8FAFC', border: `2px solid ${file.is_dead_code ? '#475569' : '#334155'}`, borderRadius: '6px', fontSize: '11px', fontFamily: 'monospace', padding: '6px 10px', width: FILE_WIDTH, }, }) } for (const imp of graph.edges.imports) { edges.push({ id: `imp-${imp.source_id}-${imp.target_id}`, source: imp.source_id, target: imp.target_id, type: 'smoothstep', style: { stroke: '#334155', strokeWidth: 1.5, strokeDasharray: '4,3' }, markerEnd: { type: MarkerType.ArrowClosed, width: 12, height: 12, color: '#334155' }, label: imp.metadata?.symbols?.slice(0, 2).join(', ') || '', labelStyle: { fontSize: 9, fill: '#94A3B8' }, }) } } if (viewMode === 'chunks' || viewMode === 'mixed') { for (const chunk of graph.nodes.chunks) { const color = chunk.is_dead_code ? '#94A3B8' : (CHUNK_TYPE_COLORS[chunk.chunk_type] || '#6366F1') nodes.push({ id: chunk.id, type: 'default', position: { x: 0, y: 0 }, data: { label: chunk.chunk_name, isFile: false, chunk_type: chunk.chunk_type, file_path: chunk.file_path, doc_confidence: chunk.doc_confidence, boundary_confidence: chunk.boundary_confidence, is_dead_code: chunk.is_dead_code, stored_hash: chunk.stored_hash, }, style: { background: color + '22', color: '#1E293B', border: `2px solid ${color}`, borderRadius: '8px', fontSize: '11px', fontFamily: 'monospace', padding: '8px 12px', width: NODE_WIDTH, opacity: chunk.is_dead_code ? 0.5 : 1, }, }) } for (const call of graph.edges.calls) { edges.push({ id: `call-${call.source_id}-${call.target_id}`, source: call.source_id, target: call.target_id, type: 'smoothstep', style: { stroke: '#6366F1', strokeWidth: 1.5 }, markerEnd: { type: MarkerType.ArrowClosed, width: 12, height: 12, color: '#6366F1' }, label: call.metadata?.callee_name || '', labelStyle: { fontSize: 9, fill: '#6366F1' }, }) } } return layoutGraph(nodes, edges) } // ─── DETAIL PANEL ───────────────────────────────────────────────────────────── function NodeDetailPanel({ node, projectId, }: { node: Node | null projectId: string }) { const appPath = useAppPath() if (!node) { return (
Click a node to inspect it
) } const d = node.data as any if (d.isFile) { return (
{d.file_path}
{d.language} · {d.chunk_count ?? '?'} chunks
{d.is_dead_code && Dead Code}
) } return (
{d.label}
{d.file_path}
{d.chunk_type} {d.is_dead_code && Dead} {d.doc_confidence === 'human_verified' && Verified} {d.doc_confidence === 'ai_generated' && AI Docs} {!d.stored_hash && No Hash}
Open Contract →
) } // ─── MAIN PAGE ──────────────────────────────────────────────────────────────── export default function CILGraphPage() { const { id: projectId } = useParams<{ id: string }>() const appPath = useAppPath() const [nodes, setNodes, onNodesChange] = useNodesState([]) const [edges, setEdges, onEdgesChange] = useEdgesState([]) const [loading, setLoading] = useState(true) const [viewMode, setViewMode] = useState('chunks') const [selectedNode, setSelectedNode] = useState(null) const [stats, setStats] = useState({ files: 0, chunks: 0, imports: 0, calls: 0 }) const [projectTitle, setProjectTitle] = useState('') const loadGraph = useCallback(async () => { if (!projectId) return setLoading(true) try { const [graphRes, projectRes] = await Promise.all([ apiFetch(`/api/custom_cil-graph?action=get_project_graph&project_id=${projectId}`), apiFetch(`/api/custom_cil-project?action=get&id=${projectId}`), ]) const graphData = await graphRes.json() const projectData = await projectRes.json() const graph: ProjectGraph = graphData?.data || graphData const project = projectData?.project || projectData?.data?.project if (project?.title) setProjectTitle(project.title) const { nodes: flowNodes, edges: flowEdges } = buildFlowGraph(graph, viewMode) setNodes(flowNodes) setEdges(flowEdges) setStats({ files: graph.nodes.files.length, chunks: graph.nodes.chunks.length, imports: graph.edges.imports.length, calls: graph.edges.calls.length, }) } catch { } finally { setLoading(false) } }, [projectId, viewMode]) useEffect(() => { loadGraph() }, [loadGraph]) const onConnect = useCallback( (params: Connection) => setEdges(eds => addEdge(params, eds)), [setEdges] ) if (loading) { return (
) } return (
{/* Header bar */}
{stats.files} files · {stats.chunks} chunks · {stats.calls} calls · {stats.imports} imports
{/* Graph canvas + side panel */}
{nodes.length === 0 ? (

No {viewMode === 'files' ? 'files' : 'chunks'} found.

Run Pass 1 + Pass 2 ingestion first.

) : ( setSelectedNode(node)} fitView fitViewOptions={{ padding: 0.15 }} minZoom={0.1} maxZoom={2} proOptions={{ hideAttribution: true }} > {/* Legend */}
{Object.entries(CHUNK_TYPE_COLORS).map(([type, color]) => (
{type}
))} {viewMode !== 'chunks' && (
file
)}
)}
{/* Node detail side panel */}

Inspector

) }