Normalize absent path hop counts for the typed graph path interpolation while preserving zero-hop results. - Use undefined for non-found path hop counts to satisfy the typed interpolation contract. - Add regression coverage that renders a found zero-hop path. Files changed: packages/dashboard/app/components/KnowledgeGraphPanel.tsx | 3 ++- .../dashboard/app/components/__tests__/KnowledgeGraphPanel.test.tsx | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-018 Fusion-Task-Lineage: 28254bbd-967b-463e-87de-8dfa847ca42d Co-authored-by: Fusion <noreply@runfusion.ai>
71 lines
14 KiB
TypeScript
71 lines
14 KiB
TypeScript
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 <button type="button" className="btn btn-ghost knowledge-graph-endpoint" onClick={() => onSelect(id)}>{id}</button>;
|
|
}
|
|
|
|
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 <section className="knowledge-graph-edge-list"><h4>{title} <span>{t("knowledgeGraph.showingOf", "showing {{shown}} of {{total}}", { shown: edges.length, total })}</span></h4>{edges.length === 0 ? <p className="knowledge-graph-muted">{t("knowledgeGraph.none", "None")}</p> : <ul>{edges.map((edge) => <li key={edge.id}><span>{edge.kind}</span><span className={`knowledge-graph-provenance knowledge-graph-provenance--${edge.provenance}`}>{edge.provenance}</span><EndpointButton id={edge[endpoint]} onSelect={onSelect} /></li>)}</ul>}</section>;
|
|
}
|
|
|
|
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 <div className="knowledge-graph-panel knowledge-graph-error" data-testid="knowledge-graph-error">{graph.error}</div>;
|
|
if (!graph.status) return <div className="knowledge-graph-panel" data-testid="knowledge-graph-loading">{t("memory.graphLoading", "Loading knowledge graph…")}</div>;
|
|
if (!graph.status.available) return <div className="knowledge-graph-panel knowledge-graph-empty" data-testid="knowledge-graph-no-graph"><p>{t("memory.graphUnavailable", "Graph unavailable")}: {graph.status.recoveryReason}</p><button type="button" className="btn" onClick={() => void graph.rebuild(false)}>{t("memory.graphBuild", "Build graph")}</button></div>;
|
|
|
|
const setFilter = (patch: Partial<typeof graph.filters>) => 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 <section className="knowledge-graph-panel" data-testid="knowledge-graph-panel">
|
|
<header className="knowledge-graph-status card"><div><strong>{graph.status.nodeCount} {t("memory.graphNodes", "nodes")} · {graph.status.edgeCount} {t("memory.graphEdges", "edges")}</strong><span>{graph.status.graphDir}</span></div><dl><div><dt>{t("knowledgeGraph.nodeKinds", "Node kinds")}</dt><dd>{Object.entries(graph.status.nodeKindCounts ?? {}).map(([kind, count]) => `${kind}: ${count}`).join(" · ") || "—"}</dd></div><div><dt>{t("knowledgeGraph.edgeKinds", "Edge kinds")}</dt><dd>{Object.entries(graph.status.edgeKindCounts ?? {}).map(([kind, count]) => `${kind}: ${count}`).join(" · ") || "—"}</dd></div><div><dt>{t("knowledgeGraph.provenance", "Provenance")}</dt><dd>{Object.entries(graph.status.provenanceCounts ?? {}).map(([kind, count]) => `${kind}: ${count}`).join(" · ") || "—"}</dd></div></dl><label className="knowledge-graph-toggle"><input type="checkbox" checked={force} onChange={(event) => setForce(event.target.checked)} /> {t("memory.graphForce", "Force rebuild")}</label><button type="button" className="btn" disabled={graph.rebuilding} onClick={() => void graph.rebuild(force)}>{graph.rebuilding ? t("memory.graphRebuilding", "Rebuilding…") : t("memory.graphRebuild", "Rebuild")}</button></header>
|
|
|
|
<section className="knowledge-graph-filters card" aria-label={t("memory.graphFilters", "Graph filters")}>
|
|
<div className="knowledge-graph-kind-options">{NODE_KINDS.map((kind) => <label key={kind}><input type="checkbox" checked={(graph.filters.kinds ?? []).includes(kind)} onChange={() => updateKinds(kind)} /> {kind}</label>)}</div>
|
|
<input className="input" aria-label="Path prefix" placeholder="Path prefix" value={graph.filters.pathPrefix ?? ""} onChange={(event) => setFilter({ pathPrefix: event.target.value })} />
|
|
<input className="input" aria-label="Node id prefix" placeholder="Node id prefix" value={graph.filters.idPrefix ?? ""} onChange={(event) => setFilter({ idPrefix: event.target.value })} />
|
|
<input className="input" aria-label="Name regex" placeholder="Name regex" value={graph.filters.namePattern ?? ""} onChange={(event) => setFilter({ namePattern: event.target.value })} />
|
|
<select className="select" aria-label="FNXC area" value={graph.filters.fnxcArea ?? ""} onChange={(event) => setFilter({ fnxcArea: event.target.value || undefined })}><option value="">{t("knowledgeGraph.allFnxcAreas", "All FNXC areas")}</option>{graph.status.fnxcAreas?.map((area) => <option key={area} value={area}>{area}</option>)}</select>
|
|
<select className="select" aria-label="Symbol kind" value={graph.filters.symbolKind ?? ""} onChange={(event) => setFilter({ symbolKind: event.target.value || undefined })}><option value="">{t("knowledgeGraph.allSymbolKinds", "All symbol kinds")}</option>{SYMBOL_KINDS.map((kind) => <option key={kind}>{kind}</option>)}</select>
|
|
<select className="select" aria-label="Owner" value={graph.filters.owner ?? ""} onChange={(event) => setFilter({ owner: event.target.value || undefined })}><option value="">{t("knowledgeGraph.allOwners", "All owners")}</option><option value="file">{t("knowledgeGraph.ownerFile", "file")}</option><option value="derived">{t("knowledgeGraph.ownerDerived", "derived")}</option></select>
|
|
<select className="select" aria-label="Result limit" value={graph.filters.limit ?? 50} onChange={(event) => setFilter({ limit: Number(event.target.value) })}>{[25, 50, 100, 200].map((limit) => <option key={limit} value={limit}>{t("knowledgeGraph.limitResults", "{{limit}} results", { limit })}</option>)}</select>
|
|
<button type="button" className="btn btn-ghost" onClick={graph.clearFilters}>{t("memory.graphClearFilters", "Clear filters")}</button>
|
|
</section>
|
|
|
|
<div className="knowledge-graph-layout"><section className="card knowledge-graph-results"><h3>{graph.nodes?.total ?? 0} {t("memory.graphResults", "results")}</h3>{graph.loading ? <p data-testid="knowledge-graph-searching">{t("knowledgeGraph.searching", "Searching…")}</p> : graph.nodes?.nodes.length ? <><ul>{graph.nodes.nodes.map((node) => <li key={node.id}><button type="button" className="btn btn-ghost" onClick={() => void graph.selectNode(node.id)}><strong>{node.kind}: {node.name}</strong><span>{node.id} · {node.ownerPath}:{node.source.line}</span></button></li>)}</ul><div className="knowledge-graph-pagination"><button type="button" className="btn btn-ghost" disabled={!graph.filters.offset} onClick={() => setFilter({ offset: Math.max(0, (graph.filters.offset ?? 0) - (graph.filters.limit ?? 50)) })}>{t("knowledgeGraph.previous", "Previous")}</button><button type="button" className="btn btn-ghost" disabled={(graph.filters.offset ?? 0) + (graph.filters.limit ?? 50) >= (graph.nodes?.total ?? 0)} onClick={() => setFilter({ offset: (graph.filters.offset ?? 0) + (graph.filters.limit ?? 50) })}>{t("knowledgeGraph.next", "Next")}</button></div></> : <p data-testid="knowledge-graph-no-results">{t("memory.graphNoResults", "No matching nodes.")}</p>}</section>
|
|
<section className="card knowledge-graph-detail">{graph.detail ? <><h3>{graph.detail.node.name}</h3><p>{graph.detail.node.id}</p><p>{graph.detail.node.ownerPath}:{graph.detail.node.source.line}</p><EdgeList title={t("knowledgeGraph.outgoing", "Outgoing")} edges={graph.detail.outgoing} total={graph.detail.outgoingTotal} endpoint="to" onSelect={(id) => void graph.selectNode(id)} /><EdgeList title={t("knowledgeGraph.incoming", "Incoming")} edges={graph.detail.incoming} total={graph.detail.incomingTotal} endpoint="from" onSelect={(id) => void graph.selectNode(id)} /></> : <p className="knowledge-graph-muted">{t("knowledgeGraph.selectNodeHint", "Select a node to inspect its edges.")}</p>}</section></div>
|
|
|
|
{graph.detail && <section className="knowledge-graph-neighbors card"><h3>{t("knowledgeGraph.neighbors", "Neighbors")}</h3><select className="select" aria-label="Neighbor direction" value={graph.neighborOptions.direction} onChange={(event) => graph.setNeighborOptions({ ...graph.neighborOptions, direction: event.target.value as "out" | "in" | "both" })}><option value="out">{t("knowledgeGraph.outgoing", "Outgoing")}</option><option value="in">{t("knowledgeGraph.incoming", "Incoming")}</option><option value="both">{t("knowledgeGraph.bothDirections", "Both directions")}</option></select><select className="select" aria-label="Neighbor depth" value={graph.neighborOptions.depth} onChange={(event) => graph.setNeighborOptions({ ...graph.neighborOptions, depth: Number(event.target.value) })}>{[1, 2, 3].map((depth) => <option key={depth} value={depth}>{t("knowledgeGraph.depth", "Depth {{depth}}", { depth })}</option>)}</select><div className="knowledge-graph-kind-options">{EDGE_KINDS.map((kind) => <label key={kind}><input type="checkbox" checked={(graph.neighborOptions.edgeKinds ?? []).includes(kind)} onChange={() => updateEdgeKinds(kind)} /> {kind}</label>)}</div><button type="button" className="btn btn-ghost" onClick={() => void graph.refreshNeighbors()}>{t("knowledgeGraph.refreshNeighbors", "Refresh neighbors")}</button>{graph.neighborResults?.neighbors.length ? <ul>{graph.neighborResults.neighbors.map((neighbor) => <li key={neighbor.node.id}><span>{t("knowledgeGraph.distance", "distance {{distance}}", { distance: neighbor.distance })}</span><EndpointButton id={neighbor.node.id} onSelect={(id) => void graph.selectNode(id)} /></li>)}</ul> : <p className="knowledge-graph-muted">{t("knowledgeGraph.noNeighbors", "No neighbors found.")}</p>}</section>}
|
|
|
|
<section className="knowledge-graph-path card"><h3>{t("knowledgeGraph.shortestPath", "Shortest path")}</h3><input className="input" aria-label="From node id" placeholder="From node id" value={graph.pathInput.fromId} onChange={(event) => graph.setPathInput({ ...graph.pathInput, fromId: event.target.value })} /><input className="input" aria-label="To node id" placeholder="To node id" value={graph.pathInput.toId} onChange={(event) => graph.setPathInput({ ...graph.pathInput, toId: event.target.value })} /><input className="input" aria-label="Maximum hops" type="number" min="1" max={limits.maxHops} value={graph.pathInput.maxHops} onChange={(event) => graph.setPathInput({ ...graph.pathInput, maxHops: Number(event.target.value) })} />{selected && <><button type="button" className="btn btn-ghost" onClick={() => graph.setPathInput({ ...graph.pathInput, fromId: selected })}>{t("knowledgeGraph.useSelectedFrom", "Use selected as from")}</button><button type="button" className="btn btn-ghost" onClick={() => graph.setPathInput({ ...graph.pathInput, toId: selected })}>{t("knowledgeGraph.useSelectedTo", "Use selected as to")}</button></>}<button type="button" className="btn" onClick={() => void graph.findPath()}>{t("knowledgeGraph.findPath", "Find path")}</button>{foundPath && <div data-testid="knowledge-graph-path-found"><strong>{t("knowledgeGraph.hops", "{{count}} hops", { count: foundHops })}</strong><div className="knowledge-graph-path-chain">{foundPath.nodes.map((node, index) => <span key={node.id}><EndpointButton id={node.id} onSelect={(id) => void graph.selectNode(id)} />{index < foundPath.edges.length && <em>{foundPath.edges[index].kind}</em>}</span>)}</div></div>}{graph.pathResult?.outcome === "not-found" && <p data-testid="knowledge-graph-path-not-found">{t("knowledgeGraph.noPath", "No path exists between these nodes.")}</p>}{graph.pathResult?.outcome === "limit-reached" && <div data-testid="knowledge-graph-path-limit-reached"><p>{t("knowledgeGraph.pathLimitReached", "No path found within {{maxHops}} hops; {{limit}} was reached.", { maxHops: graph.pathResult.maxHops, limit: graph.pathResult.limit })}</p>{graph.pathInput.maxHops < limits.maxHops && <button type="button" className="btn btn-ghost" onClick={() => graph.setPathInput({ ...graph.pathInput, maxHops: Math.min(limits.maxHops, graph.pathInput.maxHops + 1) })}>{t("knowledgeGraph.raiseHopLimit", "Raise hop limit")}</button>}</div>}{graph.error && <p data-testid="knowledge-graph-path-error" className="knowledge-graph-error">{graph.error}</p>}</section>
|
|
</section>;
|
|
}
|