mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 09:29:38 +02:00
feat: chat message actions, explainability graphs, and graph query filters
Add chat UX improvements: message actions toolbar (copy/delete/regenerate) on hover, inline explainability subgraph visualization from RAG/agent queries, and token metadata for all chat modes. Enhance graph page with SPO query filters, configurable triple limit, and type legend overlay. Extract shared graph utilities for reuse across components. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d5dd15be72
commit
87f6e5eb05
7 changed files with 806 additions and 160 deletions
|
|
@ -25,6 +25,8 @@ import { useChat } from "@/hooks/use-chat";
|
|||
import { useSettings } from "@/providers/settings-provider";
|
||||
import { useProgressStore } from "@/hooks/use-progress-store";
|
||||
import { AutoTextarea } from "@/components/ui/textarea";
|
||||
import { MessageActions } from "@/components/chat/message-actions";
|
||||
import { ExplainGraph } from "@/components/chat/explain-graph";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
|
|
@ -112,7 +114,7 @@ function AgentPhaseBlock({
|
|||
// Single message bubble
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MessageBubble({ msg }: { msg: ChatMessage }) {
|
||||
function MessageBubble({ msg, collection }: { msg: ChatMessage; collection: string }) {
|
||||
const isUser = msg.role === "user";
|
||||
const hasAgentPhases = msg.agentPhases != null;
|
||||
const isError = !isUser && msg.content.startsWith("Error:");
|
||||
|
|
@ -185,6 +187,11 @@ function MessageBubble({ msg }: { msg: ChatMessage }) {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Explainability graph */}
|
||||
{!isUser && !isError && !msg.isStreaming && msg.explainEvents && msg.explainEvents.length > 0 && (
|
||||
<ExplainGraph explainEvents={msg.explainEvents} collection={collection} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -200,7 +207,8 @@ export default function ChatPage() {
|
|||
const setInput = useConversation((s) => s.setInput);
|
||||
const setChatMode = useConversation((s) => s.setChatMode);
|
||||
const clearMessages = useConversation((s) => s.clearMessages);
|
||||
const { submitMessage, cancelRequest } = useChat();
|
||||
const { submitMessage, cancelRequest, regenerateLastMessage } = useChat();
|
||||
const deleteMessage = useConversation((s) => s.deleteMessage);
|
||||
const collection = useSettings((s) => s.settings.collection);
|
||||
const isLoading = useProgressStore((s) => s.isLoading);
|
||||
|
||||
|
|
@ -255,6 +263,7 @@ export default function ChatPage() {
|
|||
<div role="group" aria-label="Chat mode" className="flex rounded-lg border border-border bg-surface-100 p-0.5">
|
||||
{MODES.map((mode) => (
|
||||
<button
|
||||
type="button"
|
||||
key={mode.value}
|
||||
onClick={() => setChatMode(mode.value)}
|
||||
aria-pressed={chatMode === mode.value}
|
||||
|
|
@ -282,20 +291,36 @@ export default function ChatPage() {
|
|||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 space-y-4 overflow-y-auto pb-4">
|
||||
<div className="flex-1 space-y-4 overflow-y-auto pb-4 pt-10">
|
||||
{messages.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-fg-subtle">
|
||||
<MessageSquareText className="mb-3 h-10 w-10 opacity-30" />
|
||||
<p>Send a message to start a conversation.</p>
|
||||
<p className="mt-1 text-xs">
|
||||
Mode: <span className="text-fg-muted">{chatMode}</span>
|
||||
Mode: <span className="text-fg-muted">{MODES.find((m) => m.value === chatMode)?.label ?? chatMode}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} msg={msg} />
|
||||
))}
|
||||
{messages.map((msg, idx) => {
|
||||
const isLastAssistant =
|
||||
msg.role === "assistant" &&
|
||||
idx === messages.length - 1;
|
||||
|
||||
return (
|
||||
<div key={msg.id} className="group relative">
|
||||
{!msg.isStreaming && (
|
||||
<MessageActions
|
||||
content={msg.content}
|
||||
isLastAssistant={isLastAssistant}
|
||||
onDelete={() => deleteMessage(msg.id)}
|
||||
onRegenerate={isLastAssistant ? regenerateLastMessage : undefined}
|
||||
/>
|
||||
)}
|
||||
<MessageBubble msg={msg} collection={collection} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={scrollRef} />
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ import {
|
|||
X,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSocket } from "@/providers/socket-provider";
|
||||
|
|
@ -25,6 +28,16 @@ import { useSettings } from "@/providers/settings-provider";
|
|||
import { useProgressStore } from "@/hooks/use-progress-store";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { Triple, Term } from "@trustgraph/client";
|
||||
import {
|
||||
termValue,
|
||||
localName,
|
||||
hashColor,
|
||||
triplesToGraph,
|
||||
RDFS_LABEL,
|
||||
RDF_TYPE,
|
||||
type GraphNode,
|
||||
type GraphLink,
|
||||
} from "@/lib/graph-utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy-load ForceGraph2D to keep bundle size down
|
||||
|
|
@ -32,153 +45,13 @@ import type { Triple, Term } from "@trustgraph/client";
|
|||
|
||||
import type {
|
||||
ForceGraphMethods,
|
||||
NodeObject,
|
||||
LinkObject,
|
||||
ForceGraphProps,
|
||||
} from "react-force-graph-2d";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const ForceGraph2D = lazy(() => import("react-force-graph-2d")) as unknown as React.ComponentType<ForceGraphProps<any, any> & { ref?: React.Ref<any> }>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface GraphNode extends NodeObject {
|
||||
id: string;
|
||||
label: string;
|
||||
color?: string;
|
||||
/** Number of connections (used for sizing) */
|
||||
degree: number;
|
||||
}
|
||||
|
||||
interface GraphLink extends LinkObject {
|
||||
source: string;
|
||||
target: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface GraphData {
|
||||
nodes: GraphNode[];
|
||||
links: GraphLink[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers -- Term value extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RDFS_LABEL = "http://www.w3.org/2000/01/rdf-schema#label";
|
||||
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
||||
|
||||
function termValue(t: Term): string {
|
||||
switch (t.t) {
|
||||
case "i":
|
||||
return t.i;
|
||||
case "l":
|
||||
return t.v;
|
||||
case "b":
|
||||
return t.d;
|
||||
case "t":
|
||||
return "[triple]";
|
||||
}
|
||||
}
|
||||
|
||||
function isIri(t: Term): boolean {
|
||||
return t.t === "i";
|
||||
}
|
||||
|
||||
/** Extract the local name from a URI for display */
|
||||
function localName(uri: string): string {
|
||||
const hash = uri.lastIndexOf("#");
|
||||
const slash = uri.lastIndexOf("/");
|
||||
const idx = Math.max(hash, slash);
|
||||
if (idx >= 0 && idx < uri.length - 1) return uri.substring(idx + 1);
|
||||
return uri;
|
||||
}
|
||||
|
||||
/** Deterministic color from a string (for node types) */
|
||||
function hashColor(s: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
hash = s.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const hue = ((hash % 360) + 360) % 360;
|
||||
return `hsl(${hue}, 60%, 55%)`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build graph data from triples
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function triplesToGraph(triples: Triple[]): {
|
||||
data: GraphData;
|
||||
labelMap: Map<string, string>;
|
||||
typeMap: Map<string, string>;
|
||||
} {
|
||||
const labelMap = new Map<string, string>();
|
||||
const typeMap = new Map<string, string>();
|
||||
|
||||
// First pass: collect labels and types
|
||||
for (const t of triples) {
|
||||
const pred = termValue(t.p);
|
||||
if (pred === RDFS_LABEL && t.o.t === "l") {
|
||||
labelMap.set(termValue(t.s), t.o.v);
|
||||
}
|
||||
if (pred === RDF_TYPE && isIri(t.o)) {
|
||||
typeMap.set(termValue(t.s), termValue(t.o));
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: build nodes and links (skip structural triples)
|
||||
const nodeMap = new Map<string, GraphNode>();
|
||||
const links: GraphLink[] = [];
|
||||
|
||||
const ensureNode = (uri: string): void => {
|
||||
if (!nodeMap.has(uri)) {
|
||||
const type = typeMap.get(uri);
|
||||
nodeMap.set(uri, {
|
||||
id: uri,
|
||||
label: labelMap.get(uri) ?? localName(uri),
|
||||
color: type ? hashColor(localName(type)) : "#5b80ff",
|
||||
degree: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
for (const t of triples) {
|
||||
const sVal = termValue(t.s);
|
||||
const pVal = termValue(t.p);
|
||||
const oVal = termValue(t.o);
|
||||
|
||||
// Skip label and type predicates -- they are metadata, not graph edges
|
||||
if (pVal === RDFS_LABEL) continue;
|
||||
if (pVal === RDF_TYPE) continue;
|
||||
|
||||
// Build edges for entity-to-entity relationships.
|
||||
// Include both IRIs and literals as valid entity nodes — plain-name
|
||||
// knowledge graphs (e.g. seeded demo data) use literals for entities.
|
||||
const sIsEntity = isIri(t.s) || t.s.t === "l";
|
||||
const oIsEntity = isIri(t.o) || t.o.t === "l";
|
||||
if (!sIsEntity || !oIsEntity) continue;
|
||||
|
||||
ensureNode(sVal);
|
||||
ensureNode(oVal);
|
||||
nodeMap.get(sVal)!.degree++;
|
||||
nodeMap.get(oVal)!.degree++;
|
||||
|
||||
links.push({
|
||||
source: sVal,
|
||||
target: oVal,
|
||||
label: labelMap.get(pVal) ?? localName(pVal),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data: { nodes: Array.from(nodeMap.values()), links },
|
||||
labelMap,
|
||||
typeMap,
|
||||
};
|
||||
}
|
||||
// Graph helpers imported from @/lib/graph-utils
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node detail panel
|
||||
|
|
@ -313,6 +186,15 @@ export default function GraphPage() {
|
|||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
||||
|
||||
// Query filters
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [subjectFilter, setSubjectFilter] = useState("");
|
||||
const [predicateFilter, setPredicateFilter] = useState("");
|
||||
const [objectFilter, setObjectFilter] = useState("");
|
||||
const [tripleLimit, setTripleLimit] = useState(2000);
|
||||
const [showLegend, setShowLegend] = useState(false);
|
||||
const hasActiveFilters = subjectFilter || predicateFilter || objectFilter;
|
||||
|
||||
const fgRef = useRef<ForceGraphMethods<GraphNode, GraphLink> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
|
@ -322,6 +204,9 @@ export default function GraphPage() {
|
|||
} | null>(null);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
// Auto-fit tracking — declared early so fetchTriples can reset it
|
||||
const hasAutoFit = useRef(false);
|
||||
|
||||
// Ref callback — attaches ResizeObserver when the container mounts
|
||||
const containerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
// Disconnect previous observer
|
||||
|
|
@ -341,20 +226,25 @@ export default function GraphPage() {
|
|||
roRef.current = ro;
|
||||
}, []);
|
||||
|
||||
// Fetch triples
|
||||
// Fetch triples with optional filters
|
||||
const fetchTriples = useCallback(async () => {
|
||||
const act = "Load graph";
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
addActivity(act);
|
||||
hasAutoFit.current = false;
|
||||
|
||||
const flow = socket.flow(flowId);
|
||||
const s: Term | undefined = subjectFilter ? { t: "i", i: subjectFilter } : undefined;
|
||||
const p: Term | undefined = predicateFilter ? { t: "i", i: predicateFilter } : undefined;
|
||||
const o: Term | undefined = objectFilter ? { t: "i", i: objectFilter } : undefined;
|
||||
|
||||
const result = await flow.triplesQuery(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
2000,
|
||||
s,
|
||||
p,
|
||||
o,
|
||||
tripleLimit,
|
||||
collection,
|
||||
);
|
||||
setTriples(result);
|
||||
|
|
@ -364,18 +254,30 @@ export default function GraphPage() {
|
|||
setLoading(false);
|
||||
removeActivity(act);
|
||||
}
|
||||
}, [socket, flowId, collection, addActivity, removeActivity]);
|
||||
}, [socket, flowId, collection, subjectFilter, predicateFilter, objectFilter, tripleLimit, addActivity, removeActivity]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTriples();
|
||||
}, [fetchTriples]);
|
||||
|
||||
// Build graph
|
||||
const { data: graphData, labelMap } = useMemo(
|
||||
const { data: graphData, labelMap, typeMap } = useMemo(
|
||||
() => triplesToGraph(Array.isArray(triples) ? triples : []),
|
||||
[triples],
|
||||
);
|
||||
|
||||
// Unique types for legend
|
||||
const uniqueTypes = useMemo(() => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const [, typeUri] of typeMap) {
|
||||
const name = localName(typeUri);
|
||||
if (!seen.has(name)) {
|
||||
seen.set(name, typeUri);
|
||||
}
|
||||
}
|
||||
return Array.from(seen.entries());
|
||||
}, [typeMap]);
|
||||
|
||||
// Search filter -- highlight matching nodes
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const matchingIds = useMemo(() => {
|
||||
|
|
@ -396,7 +298,6 @@ export default function GraphPage() {
|
|||
: "";
|
||||
|
||||
// Auto-fit graph to view once data loads
|
||||
const hasAutoFit = useRef(false);
|
||||
useEffect(() => {
|
||||
if (graphData.nodes.length > 0 && fgRef.current && !hasAutoFit.current) {
|
||||
hasAutoFit.current = true;
|
||||
|
|
@ -496,6 +397,7 @@ export default function GraphPage() {
|
|||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-subtle" />
|
||||
<input
|
||||
id="graph-search"
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
|
|
@ -542,6 +444,44 @@ export default function GraphPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<button
|
||||
onClick={() => setShowFilters((p) => !p)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors",
|
||||
showFilters || hasActiveFilters
|
||||
? "border-brand-500/50 bg-brand-600/10 text-brand-400"
|
||||
: "border-border text-fg-muted hover:bg-surface-200",
|
||||
)}
|
||||
title="Query filters"
|
||||
aria-label="Toggle query filters"
|
||||
aria-expanded={showFilters}
|
||||
>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
Filters
|
||||
{hasActiveFilters && !showFilters && (
|
||||
<span className="ml-0.5 h-1.5 w-1.5 rounded-full bg-brand-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Legend toggle */}
|
||||
{uniqueTypes.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowLegend((p) => !p)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors",
|
||||
showLegend
|
||||
? "border-brand-500/50 bg-brand-600/10 text-brand-400"
|
||||
: "border-border text-fg-muted hover:bg-surface-200",
|
||||
)}
|
||||
title="Type legend"
|
||||
aria-label="Toggle type legend"
|
||||
aria-expanded={showLegend}
|
||||
>
|
||||
Legend
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={fetchTriples}
|
||||
disabled={loading}
|
||||
|
|
@ -557,6 +497,96 @@ export default function GraphPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter panel */}
|
||||
{showFilters && (
|
||||
<div className="mb-4 rounded-lg border border-border bg-surface-50 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-2 text-xs font-medium text-fg-muted">
|
||||
<Filter className="h-3 w-3" />
|
||||
Query Filters
|
||||
</h3>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSubjectFilter("");
|
||||
setPredicateFilter("");
|
||||
setObjectFilter("");
|
||||
}}
|
||||
className="text-xs text-brand-400 hover:text-brand-300"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="filter-subject" className="block text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Subject
|
||||
</label>
|
||||
<input
|
||||
id="filter-subject"
|
||||
type="text"
|
||||
value={subjectFilter}
|
||||
onChange={(e) => setSubjectFilter(e.target.value)}
|
||||
placeholder="URI filter..."
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-1.5 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="filter-predicate" className="block text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Predicate
|
||||
</label>
|
||||
<input
|
||||
id="filter-predicate"
|
||||
type="text"
|
||||
value={predicateFilter}
|
||||
onChange={(e) => setPredicateFilter(e.target.value)}
|
||||
placeholder="URI filter..."
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-1.5 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="filter-object" className="block text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Object
|
||||
</label>
|
||||
<input
|
||||
id="filter-object"
|
||||
type="text"
|
||||
value={objectFilter}
|
||||
onChange={(e) => setObjectFilter(e.target.value)}
|
||||
placeholder="URI filter..."
|
||||
className="w-full rounded-lg border border-border bg-surface-100 px-3 py-1.5 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="filter-limit" className="text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Limit
|
||||
</label>
|
||||
<input
|
||||
id="filter-limit"
|
||||
type="range"
|
||||
min={100}
|
||||
max={5000}
|
||||
step={100}
|
||||
value={tripleLimit}
|
||||
onChange={(e) => setTripleLimit(Number(e.target.value))}
|
||||
className="w-24 accent-brand-500"
|
||||
/>
|
||||
<span className="text-xs text-fg-muted">{tripleLimit}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchTriples}
|
||||
disabled={loading}
|
||||
className="ml-auto flex items-center gap-1.5 rounded-lg bg-brand-600 px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-brand-500 disabled:opacity-40"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{error && (
|
||||
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
|
||||
|
|
@ -626,6 +656,26 @@ export default function GraphPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Type legend overlay */}
|
||||
{showLegend && uniqueTypes.length > 0 && (
|
||||
<div className="absolute bottom-3 left-3 z-10 max-h-48 overflow-y-auto rounded-lg border border-border bg-surface-50/95 px-3 py-2 shadow-lg backdrop-blur-sm">
|
||||
<h4 className="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
|
||||
Node Types
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{uniqueTypes.map(([name]) => (
|
||||
<div key={name} className="flex items-center gap-2 text-xs text-fg-muted">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: hashColor(name) }}
|
||||
/>
|
||||
<span className="truncate">{name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail panel -- positioned absolutely so it overlays the graph */}
|
||||
{selectedNode && (
|
||||
<div className="absolute inset-y-0 right-0 z-10">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue