diff --git a/src/components/GraphCanvas.tsx b/src/components/GraphCanvas.tsx index c53b00ce1..da28d9514 100644 --- a/src/components/GraphCanvas.tsx +++ b/src/components/GraphCanvas.tsx @@ -1,5 +1,5 @@ -import { useEffect, useCallback, useState, forwardRef, useImperativeHandle } from 'react'; -import { ZoomIn, ZoomOut, Maximize2, Focus, RotateCcw, Play, Pause } from 'lucide-react'; +import { useEffect, useCallback, useMemo, useState, forwardRef, useImperativeHandle } from 'react'; +import { ZoomIn, ZoomOut, Maximize2, Focus, RotateCcw, Play, Pause, Lightbulb, LightbulbOff } from 'lucide-react'; import { useSigma } from '../hooks/useSigma'; import { useAppState } from '../hooks/useAppState'; import { knowledgeGraphToGraphology, filterGraphByDepth, SigmaNodeAttributes, SigmaEdgeAttributes } from '../lib/graph-adapter'; @@ -11,8 +11,28 @@ export interface GraphCanvasHandle { } export const GraphCanvas = forwardRef((_, ref) => { - const { graph, setSelectedNode, selectedNode: appSelectedNode, visibleLabels, openCodePanel, depthFilter, highlightedNodeIds } = useAppState(); + const { + graph, + setSelectedNode, + selectedNode: appSelectedNode, + visibleLabels, + openCodePanel, + depthFilter, + highlightedNodeIds, + aiCitationHighlightedNodeIds, + aiToolHighlightedNodeIds, + isAIHighlightsEnabled, + toggleAIHighlights, + } = useAppState(); const [hoveredNodeName, setHoveredNodeName] = useState(null); + + const effectiveHighlightedNodeIds = useMemo(() => { + if (!isAIHighlightsEnabled) return highlightedNodeIds; + const next = new Set(highlightedNodeIds); + for (const id of aiCitationHighlightedNodeIds) next.add(id); + for (const id of aiToolHighlightedNodeIds) next.add(id); + return next; + }, [highlightedNodeIds, aiCitationHighlightedNodeIds, aiToolHighlightedNodeIds, isAIHighlightsEnabled]); const handleNodeClick = useCallback((nodeId: string) => { if (!graph) return; @@ -55,7 +75,7 @@ export const GraphCanvas = forwardRef((_, ref) => { onNodeClick: handleNodeClick, onNodeHover: handleNodeHover, onStageClick: handleStageClick, - highlightedNodeIds, + highlightedNodeIds: effectiveHighlightedNodeIds, }); // Expose focusNode to parent via ref @@ -244,6 +264,21 @@ export const GraphCanvas = forwardRef((_, ref) => { {/* Query FAB */} + + {/* AI Highlights toggle - Top Right */} +
+ +
); }); diff --git a/src/hooks/useAppState.tsx b/src/hooks/useAppState.tsx index a7d9f5a4e..3f034a749 100644 --- a/src/hooks/useAppState.tsx +++ b/src/hooks/useAppState.tsx @@ -67,6 +67,12 @@ interface AppState { // Query state highlightedNodeIds: Set; setHighlightedNodeIds: (ids: Set) => void; + // AI highlights (toggable) + aiCitationHighlightedNodeIds: Set; + aiToolHighlightedNodeIds: Set; + isAIHighlightsEnabled: boolean; + toggleAIHighlights: () => void; + clearAIToolHighlights: () => void; queryResult: QueryResult | null; setQueryResult: (result: QueryResult | null) => void; clearQueryHighlights: () => void; @@ -165,6 +171,19 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { // Query state const [highlightedNodeIds, setHighlightedNodeIds] = useState>(new Set()); const [queryResult, setQueryResult] = useState(null); + + // AI highlights (separate from user/query highlights) + const [aiCitationHighlightedNodeIds, setAICitationHighlightedNodeIds] = useState>(new Set()); + const [aiToolHighlightedNodeIds, setAIToolHighlightedNodeIds] = useState>(new Set()); + const [isAIHighlightsEnabled, setAIHighlightsEnabled] = useState(true); + + const toggleAIHighlights = useCallback(() => { + setAIHighlightsEnabled(prev => !prev); + }, []); + + const clearAIToolHighlights = useCallback(() => { + setAIToolHighlightedNodeIds(new Set()); + }, []); const clearQueryHighlights = useCallback(() => { setHighlightedNodeIds(new Set()); @@ -265,9 +284,9 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { // Auto-open panel when references are added setCodePanelOpen(true); - // Also highlight in graph if nodeId provided - if (ref.nodeId) { - setHighlightedNodeIds(prev => new Set([...prev, ref.nodeId!])); + // Track AI highlights separately so they can be toggled off in the UI + if (ref.nodeId && ref.source === 'ai') { + setAICitationHighlightedNodeIds(prev => new Set([...prev, ref.nodeId!])); } }, []); @@ -277,18 +296,12 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { const removed = prev.filter(r => r.source === 'ai'); const kept = prev.filter(r => r.source !== 'ai'); - // Update graph highlights: remove nodeIds that were ONLY highlighted via AI refs + // Remove citation-based AI highlights for removed refs const removedNodeIds = new Set(removed.map(r => r.nodeId).filter(Boolean) as string[]); if (removedNodeIds.size > 0) { - setHighlightedNodeIds(prevHighlights => { - const next = new Set(prevHighlights); - for (const nodeId of removedNodeIds) { - const stillReferenced = kept.some(r => r.nodeId === nodeId); - const stillInQuery = queryResult?.nodeIds?.includes(nodeId) ?? false; - if (!stillReferenced && !stillInQuery) { - next.delete(nodeId); - } - } + setAICitationHighlightedNodeIds(prevIds => { + const next = new Set(prevIds); + for (const id of removedNodeIds) next.delete(id); return next; }); } @@ -490,6 +503,8 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { // Refresh Code panel for the new question: keep user-pinned refs, clear old AI citations clearAICodeReferences(); + // Also clear previous tool-driven AI highlights (highlight_in_graph) + clearAIToolHighlights(); if (!isAgentReady) { // Try to initialize first @@ -734,10 +749,10 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { } if (matchedIds.size > 0) { - setHighlightedNodeIds(matchedIds); + setAIToolHighlightedNodeIds(matchedIds); } } else if (rawIds.length > 0) { - setHighlightedNodeIds(new Set(rawIds)); + setAIToolHighlightedNodeIds(new Set(rawIds)); } } } @@ -763,7 +778,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { setIsChatLoading(false); setCurrentToolCalls([]); } - }, [chatMessages, isAgentReady, initializeAgent, resolveFilePath, findFileNodeId, addCodeReference, clearAICodeReferences]); + }, [chatMessages, isAgentReady, initializeAgent, resolveFilePath, findFileNodeId, addCodeReference, clearAICodeReferences, clearAIToolHighlights, graph]); const clearChat = useCallback(() => { setChatMessages([]); @@ -776,11 +791,11 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { const ref = prev.find(r => r.id === id); const newRefs = prev.filter(r => r.id !== id); - // Remove highlight if this was the only reference to that node - if (ref?.nodeId) { - const stillReferenced = newRefs.some(r => r.nodeId === ref.nodeId); + // Remove AI citation highlight if this was the only AI reference to that node + if (ref?.nodeId && ref.source === 'ai') { + const stillReferenced = newRefs.some(r => r.nodeId === ref.nodeId && r.source === 'ai'); if (!stillReferenced) { - setHighlightedNodeIds(prev => { + setAICitationHighlightedNodeIds(prev => { const next = new Set(prev); next.delete(ref.nodeId!); return next; @@ -833,6 +848,11 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { setDepthFilter, highlightedNodeIds, setHighlightedNodeIds, + aiCitationHighlightedNodeIds, + aiToolHighlightedNodeIds, + isAIHighlightsEnabled, + toggleAIHighlights, + clearAIToolHighlights, queryResult, setQueryResult, clearQueryHighlights,