import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useKnowledgeGraph } from "../hooks/useKnowledgeGraph.js";
import type { KnowledgeGraphEdge } from "../api/system/knowledge-graph.js";
import "./KnowledgeGraphPanel.css";
const NODE_KINDS = ["file", "module", "symbol", "doc-concept", "rationale"] as const;
/* FNXC:KnowledgeGraphDashboard 2026-08-13-23:21: Symbol filter values must exactly match the core SymbolKind union because the API rejects unknown enum values rather than silently widening a query. */
const SYMBOL_KINDS = ["function", "class", "interface", "type-alias", "enum", "variable", "namespace", "alias"] as const;
const EDGE_KINDS = ["contains", "imports", "re-exports", "relates-to", "rationale-supports"] as const;
/** FNXC:KnowledgeGraphDashboard 2026-08-13-23:30: Each result is a bounded graph slice; node-to-node buttons are the navigation model instead of a whole-graph canvas. */
function EndpointButton({ id, onSelect }: { id: string; onSelect: (id: string) => void }) {
return onSelect(id)}>{id} ;
}
function EdgeList({ title, edges, total, endpoint, onSelect }: { title: string; edges: KnowledgeGraphEdge[]; total: number; endpoint: "from" | "to"; onSelect: (id: string) => void }) {
const { t } = useTranslation("app");
return {title} {t("knowledgeGraph.showingOf", "showing {{shown}} of {{total}}", { shown: edges.length, total })} {edges.length === 0 ? {t("knowledgeGraph.none", "None")}
: {edges.map((edge) => {edge.kind} {edge.provenance} )} } ;
}
export function KnowledgeGraphPanel({ projectId, addToast }: { projectId?: string; addToast: (message: string, type: "success" | "error" | "info") => void }) {
const { t } = useTranslation("app");
const graph = useKnowledgeGraph({ projectId, enabled: true });
const [force, setForce] = useState(false);
useEffect(() => { if (graph.error) addToast(graph.error, "error"); }, [addToast, graph.error]);
/* FNXC:KnowledgeGraphDashboard 2026-08-13-23:21: A failed status request is an error state, not an indefinite loading state, so operators can distinguish an unavailable graph artifact from a failed dashboard request. */
if (!graph.status && graph.error) return
{graph.error}
;
if (!graph.status) return {t("memory.graphLoading", "Loading knowledge graph…")}
;
if (!graph.status.available) return {t("memory.graphUnavailable", "Graph unavailable")}: {graph.status.recoveryReason}
void graph.rebuild(false)}>{t("memory.graphBuild", "Build graph")} ;
const setFilter = (patch: Partial) => graph.setFilters({ ...graph.filters, ...patch, offset: 0 });
const updateKinds = (kind: string) => {
const kinds = graph.filters.kinds ?? [];
setFilter({ kinds: kinds.includes(kind) ? kinds.filter((item) => item !== kind) : [...kinds, kind] });
};
const updateEdgeKinds = (kind: string) => {
const edgeKinds = graph.neighborOptions.edgeKinds ?? [];
graph.setNeighborOptions({ ...graph.neighborOptions, edgeKinds: edgeKinds.includes(kind) ? edgeKinds.filter((item) => item !== kind) : [...edgeKinds, kind] });
};
const selected = graph.selectedId;
const limits = graph.status.pathLimits;
const foundPath = graph.pathResult?.outcome === "found" ? graph.pathResult.path : null;
/* FNXC:KnowledgeGraphDashboard 2026-08-18-22:31: The typed i18n interpolation treats absent hop counts as optional; keep successful zero-hop paths numeric while normalizing non-found results to undefined. */
const foundHops = graph.pathResult?.outcome === "found" ? graph.pathResult.hops : undefined;
return
{graph.status.nodeCount} {t("memory.graphNodes", "nodes")} · {graph.status.edgeCount} {t("memory.graphEdges", "edges")} {graph.status.graphDir}
{t("knowledgeGraph.nodeKinds", "Node kinds")} {Object.entries(graph.status.nodeKindCounts ?? {}).map(([kind, count]) => `${kind}: ${count}`).join(" · ") || "—"}
{t("knowledgeGraph.edgeKinds", "Edge kinds")} {Object.entries(graph.status.edgeKindCounts ?? {}).map(([kind, count]) => `${kind}: ${count}`).join(" · ") || "—"}
{t("knowledgeGraph.provenance", "Provenance")} {Object.entries(graph.status.provenanceCounts ?? {}).map(([kind, count]) => `${kind}: ${count}`).join(" · ") || "—"} setForce(event.target.checked)} /> {t("memory.graphForce", "Force rebuild")} void graph.rebuild(force)}>{graph.rebuilding ? t("memory.graphRebuilding", "Rebuilding…") : t("memory.graphRebuild", "Rebuild")}
{NODE_KINDS.map((kind) => updateKinds(kind)} /> {kind} )}
setFilter({ pathPrefix: event.target.value })} />
setFilter({ idPrefix: event.target.value })} />
setFilter({ namePattern: event.target.value })} />
setFilter({ fnxcArea: event.target.value || undefined })}>{t("knowledgeGraph.allFnxcAreas", "All FNXC areas")} {graph.status.fnxcAreas?.map((area) => {area} )}
setFilter({ symbolKind: event.target.value || undefined })}>{t("knowledgeGraph.allSymbolKinds", "All symbol kinds")} {SYMBOL_KINDS.map((kind) => {kind} )}
setFilter({ owner: event.target.value || undefined })}>{t("knowledgeGraph.allOwners", "All owners")} {t("knowledgeGraph.ownerFile", "file")} {t("knowledgeGraph.ownerDerived", "derived")}
setFilter({ limit: Number(event.target.value) })}>{[25, 50, 100, 200].map((limit) => {t("knowledgeGraph.limitResults", "{{limit}} results", { limit })} )}
{t("memory.graphClearFilters", "Clear filters")}
{graph.nodes?.total ?? 0} {t("memory.graphResults", "results")} {graph.loading ? {t("knowledgeGraph.searching", "Searching…")}
: graph.nodes?.nodes.length ? <>{graph.nodes.nodes.map((node) => void graph.selectNode(node.id)}>{node.kind}: {node.name} {node.id} · {node.ownerPath}:{node.source.line} )} setFilter({ offset: Math.max(0, (graph.filters.offset ?? 0) - (graph.filters.limit ?? 50)) })}>{t("knowledgeGraph.previous", "Previous")} = (graph.nodes?.total ?? 0)} onClick={() => setFilter({ offset: (graph.filters.offset ?? 0) + (graph.filters.limit ?? 50) })}>{t("knowledgeGraph.next", "Next")}
> : {t("memory.graphNoResults", "No matching nodes.")}
}
{graph.detail ? <>{graph.detail.node.name} {graph.detail.node.id}
{graph.detail.node.ownerPath}:{graph.detail.node.source.line}
void graph.selectNode(id)} /> void graph.selectNode(id)} />> : {t("knowledgeGraph.selectNodeHint", "Select a node to inspect its edges.")}
}
{graph.detail && {t("knowledgeGraph.neighbors", "Neighbors")} graph.setNeighborOptions({ ...graph.neighborOptions, direction: event.target.value as "out" | "in" | "both" })}>{t("knowledgeGraph.outgoing", "Outgoing")} {t("knowledgeGraph.incoming", "Incoming")} {t("knowledgeGraph.bothDirections", "Both directions")} graph.setNeighborOptions({ ...graph.neighborOptions, depth: Number(event.target.value) })}>{[1, 2, 3].map((depth) => {t("knowledgeGraph.depth", "Depth {{depth}}", { depth })} )} {EDGE_KINDS.map((kind) => updateEdgeKinds(kind)} /> {kind} )}
void graph.refreshNeighbors()}>{t("knowledgeGraph.refreshNeighbors", "Refresh neighbors")} {graph.neighborResults?.neighbors.length ? {graph.neighborResults.neighbors.map((neighbor) => {t("knowledgeGraph.distance", "distance {{distance}}", { distance: neighbor.distance })} void graph.selectNode(id)} /> )} : {t("knowledgeGraph.noNeighbors", "No neighbors found.")}
} }
{t("knowledgeGraph.shortestPath", "Shortest path")} graph.setPathInput({ ...graph.pathInput, fromId: event.target.value })} /> graph.setPathInput({ ...graph.pathInput, toId: event.target.value })} /> graph.setPathInput({ ...graph.pathInput, maxHops: Number(event.target.value) })} />{selected && <> graph.setPathInput({ ...graph.pathInput, fromId: selected })}>{t("knowledgeGraph.useSelectedFrom", "Use selected as from")} graph.setPathInput({ ...graph.pathInput, toId: selected })}>{t("knowledgeGraph.useSelectedTo", "Use selected as to")} >} void graph.findPath()}>{t("knowledgeGraph.findPath", "Find path")} {foundPath && {t("knowledgeGraph.hops", "{{count}} hops", { count: foundHops })} {foundPath.nodes.map((node, index) => void graph.selectNode(id)} />{index < foundPath.edges.length && {foundPath.edges[index].kind} } )}
}{graph.pathResult?.outcome === "not-found" && {t("knowledgeGraph.noPath", "No path exists between these nodes.")}
}{graph.pathResult?.outcome === "limit-reached" && {t("knowledgeGraph.pathLimitReached", "No path found within {{maxHops}} hops; {{limit}} was reached.", { maxHops: graph.pathResult.maxHops, limit: graph.pathResult.limit })}
{graph.pathInput.maxHops < limits.maxHops &&
graph.setPathInput({ ...graph.pathInput, maxHops: Math.min(limits.maxHops, graph.pathInput.maxHops + 1) })}>{t("knowledgeGraph.raiseHopLimit", "Raise hop limit")} }
}{graph.error && {graph.error}
}
;
}