From 92205962fb87c47cb14be426d355c2b66ac3a74e Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Tue, 3 Feb 2026 13:25:50 -0500 Subject: [PATCH 1/5] fix: prevent graph from going blank when clicking non-visible nodes - Added safety check: only apply selection highlighting if selected node is visible - Fixed handlePanelFileClick to auto-enable 'show all' when clicking non-visible dependents - Applied fix to both clustered and non-clustered graph modes - Prevents edge dimming when selected node isn't in visible set --- .../src/components/DependencyGraph/index.tsx | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/DependencyGraph/index.tsx b/frontend/src/components/DependencyGraph/index.tsx index ccdf03a..f0f97b4 100644 --- a/frontend/src/components/DependencyGraph/index.tsx +++ b/frontend/src/components/DependencyGraph/index.tsx @@ -230,6 +230,11 @@ function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps) let flowEdges: Edge[] = [] if (clusterByDir && clusteredData) { + // Safety: only apply selection highlighting if the selected node is actually visible + const effectiveSelectedIdCluster = selectedNodeId && + (clusteredData.visibleFiles.has(selectedNodeId) || selectedNodeId.startsWith('dir:')) + ? selectedNodeId : null + // Add directory nodes clusteredData.dirNodes.forEach(dir => { flowNodes.push({ @@ -248,10 +253,10 @@ function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps) const metrics = impact.getFileMetrics(node.id) let state: GraphNodeData['state'] = 'default' - if (selectedNodeId === node.id) state = 'selected' + if (effectiveSelectedIdCluster === node.id) state = 'selected' else if (selectedImpact?.directDependents.includes(node.id)) state = 'direct' else if (selectedImpact?.transitiveDependents.includes(node.id)) state = 'transitive' - else if (selectedNodeId && !selectedNodeId.startsWith('dir:')) state = 'dimmed' + else if (effectiveSelectedIdCluster && !effectiveSelectedIdCluster.startsWith('dir:')) state = 'dimmed' // Hover highlighting in clustered mode if (hoveredFileId === node.id && state === 'dimmed') state = 'direct' @@ -284,6 +289,9 @@ function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps) }) } else { // Non-clustered mode (original logic) + // Safety: only apply selection highlighting if the selected node is actually visible + const effectiveSelectedId = selectedNodeId && visibleNodeIds.has(selectedNodeId) ? selectedNodeId : null + flowNodes = rawGraphData.nodes .filter((node: any) => visibleNodeIds.has(node.id)) .map((node: any) => { @@ -291,8 +299,8 @@ function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps) const metrics = impact.getFileMetrics(node.id) let state: GraphNodeData['state'] = 'default' - if (selectedNodeId) { - if (node.id === selectedNodeId) state = 'selected' + if (effectiveSelectedId) { + if (node.id === effectiveSelectedId) state = 'selected' else if (selectedImpact?.directDependents.includes(node.id)) state = 'direct' else if (selectedImpact?.transitiveDependents.includes(node.id)) state = 'transitive' else state = 'dimmed' @@ -321,9 +329,9 @@ function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps) .filter((edge: any) => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target)) .map((edge: any) => { let edgeState: 'default' | 'highlighted' | 'dimmed' | 'incoming' | 'outgoing' = 'default' - if (selectedNodeId) { - if (edge.source === selectedNodeId) edgeState = 'outgoing' - else if (edge.target === selectedNodeId) edgeState = 'incoming' + if (effectiveSelectedId) { + if (edge.source === effectiveSelectedId) edgeState = 'outgoing' + else if (edge.target === effectiveSelectedId) edgeState = 'incoming' else edgeState = 'dimmed' } @@ -369,12 +377,26 @@ function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps) }, []) const handlePanelFileClick = useCallback((fileId: string) => { + // Only select if the file is currently visible in the graph + // Otherwise clicking on a non-visible dependent breaks the view + if (!visibleNodeIds.has(fileId)) { + // If not visible, enable "show all" to make it visible first + if (!showAll) { + setShowAll(true) + // Small delay to let the nodes render, then select + setTimeout(() => { + setSelectedNodeId(fileId) + }, 100) + return + } + } + setSelectedNodeId(fileId) const node = nodes.find(n => n.id === fileId) if (node) { fitView({ nodes: [node], padding: 0.5, duration: 300 }) } - }, [nodes, fitView]) + }, [nodes, fitView, visibleNodeIds, showAll]) const handleResetView = useCallback(() => { setSelectedNodeId(null) From d83871de65ddd7161dda0843667b55497fa9fc1c Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Tue, 3 Feb 2026 13:56:27 -0500 Subject: [PATCH 2/5] fix: make dimmed nodes visible in dark mode Root cause: dimmed state had border-zinc-800 (same as background) + opacity-40 making nodes essentially invisible when selecting a file. Changes: - GraphNode: dark:border-zinc-600 dark:bg-zinc-800/80 opacity-50 - DirectoryNode: same fix for cluster mode Now dimmed nodes are visible but clearly de-emphasized. --- frontend/src/components/DependencyGraph/DirectoryNode.tsx | 2 +- frontend/src/components/DependencyGraph/GraphNode.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/DependencyGraph/DirectoryNode.tsx b/frontend/src/components/DependencyGraph/DirectoryNode.tsx index a224afa..c05b0a0 100644 --- a/frontend/src/components/DependencyGraph/DirectoryNode.tsx +++ b/frontend/src/components/DependencyGraph/DirectoryNode.tsx @@ -21,7 +21,7 @@ const STATE_STYLES: Record = { selected: 'border-indigo-500 bg-indigo-50 dark:bg-indigo-950 ring-2 ring-indigo-500/50 shadow-lg shadow-indigo-500/20', direct: 'border-rose-500 bg-rose-50 dark:bg-rose-950 ring-1 ring-rose-500/30', transitive: 'border-amber-500 bg-amber-50 dark:bg-amber-950 ring-1 ring-amber-500/30', - dimmed: 'border-zinc-200 bg-zinc-100/50 opacity-40 dark:border-zinc-800 dark:bg-zinc-900/50', + dimmed: 'border-zinc-300 bg-zinc-100 opacity-50 dark:border-zinc-600 dark:bg-zinc-800/80', } const RISK_STYLES: Record = { diff --git a/frontend/src/components/DependencyGraph/GraphNode.tsx b/frontend/src/components/DependencyGraph/GraphNode.tsx index f62b1af..003a4ab 100644 --- a/frontend/src/components/DependencyGraph/GraphNode.tsx +++ b/frontend/src/components/DependencyGraph/GraphNode.tsx @@ -54,7 +54,7 @@ const STATE_STYLES: Record = { selected: 'border-indigo-500 bg-white dark:bg-zinc-900 ring-2 ring-indigo-500/50 shadow-lg shadow-indigo-500/20', direct: 'border-rose-500 bg-white dark:bg-zinc-900 ring-1 ring-rose-500/30', transitive: 'border-amber-500 bg-white dark:bg-zinc-900 ring-1 ring-amber-500/30', - dimmed: 'border-zinc-200 bg-zinc-50/50 opacity-40 dark:border-zinc-800 dark:bg-zinc-900/50', + dimmed: 'border-zinc-300 bg-zinc-100 opacity-50 dark:border-zinc-600 dark:bg-zinc-800/80', } const RISK_CONFIG: Record = { From 0100d5e450a506cee41b77fd6bbc73e1c64d5106 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Tue, 3 Feb 2026 14:08:14 -0500 Subject: [PATCH 3/5] fix: remove dimming to prevent graph disappearing Quick fix for demo - instead of dimming non-selected nodes (which was causing them to become invisible), we now just highlight the selected node and its dependents while keeping others at default visibility. Changes: - Removed 'dimmed' state assignment in both normal and cluster modes - Removed edge dimming - Added guards to dagre layout function for edge cases - Kept selected/direct/transitive highlighting working This is a simpler UX that works reliably for the demo. --- .../src/components/DependencyGraph/index.tsx | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/DependencyGraph/index.tsx b/frontend/src/components/DependencyGraph/index.tsx index f0f97b4..daa88df 100644 --- a/frontend/src/components/DependencyGraph/index.tsx +++ b/frontend/src/components/DependencyGraph/index.tsx @@ -44,6 +44,11 @@ const LAYOUT_CONFIG = { const DEFAULT_VISIBLE_COUNT = 15 function getLayoutedElements(nodes: Node[], edges: Edge[]) { + // Guard: if no nodes, return empty + if (nodes.length === 0) { + return { nodes: [], edges: [] } + } + const dagreGraph = new dagre.graphlib.Graph() dagreGraph.setDefaultEdgeLabel(() => ({})) dagreGraph.setGraph({ @@ -59,19 +64,26 @@ function getLayoutedElements(nodes: Node[], edges: Edge[]) { }) }) + // Only add edges where both source and target exist in nodes + const nodeIds = new Set(nodes.map(n => n.id)) edges.forEach((edge) => { - dagreGraph.setEdge(edge.source, edge.target) + if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) { + dagreGraph.setEdge(edge.source, edge.target) + } }) dagre.layout(dagreGraph) const layoutedNodes = nodes.map((node) => { const nodeWithPosition = dagreGraph.node(node.id) + // Guard: if dagre failed to position node, use fallback + const x = nodeWithPosition?.x ?? 0 + const y = nodeWithPosition?.y ?? 0 return { ...node, position: { - x: nodeWithPosition.x - LAYOUT_CONFIG.nodeWidth / 2, - y: nodeWithPosition.y - LAYOUT_CONFIG.nodeHeight / 2, + x: x - LAYOUT_CONFIG.nodeWidth / 2, + y: y - LAYOUT_CONFIG.nodeHeight / 2, }, } }) @@ -252,14 +264,12 @@ function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps) const fileName = node.label || node.id.split('/').pop() const metrics = impact.getFileMetrics(node.id) + // Simplified state - only highlight, don't dim let state: GraphNodeData['state'] = 'default' if (effectiveSelectedIdCluster === node.id) state = 'selected' else if (selectedImpact?.directDependents.includes(node.id)) state = 'direct' else if (selectedImpact?.transitiveDependents.includes(node.id)) state = 'transitive' - else if (effectiveSelectedIdCluster && !effectiveSelectedIdCluster.startsWith('dir:')) state = 'dimmed' - - // Hover highlighting in clustered mode - if (hoveredFileId === node.id && state === 'dimmed') state = 'direct' + // Don't dim - keep as default flowNodes.push({ id: node.id, @@ -298,16 +308,15 @@ function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps) const fileName = node.label || node.id.split('/').pop() const metrics = impact.getFileMetrics(node.id) + // Simplified state - only highlight selected and dependents, don't dim others let state: GraphNodeData['state'] = 'default' if (effectiveSelectedId) { if (node.id === effectiveSelectedId) state = 'selected' else if (selectedImpact?.directDependents.includes(node.id)) state = 'direct' else if (selectedImpact?.transitiveDependents.includes(node.id)) state = 'transitive' - else state = 'dimmed' + // Don't dim - keep as default for visibility } - if (hoveredFileId === node.id && state === 'dimmed') state = 'direct' - return { id: node.id, type: 'custom', @@ -328,11 +337,12 @@ function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps) flowEdges = rawGraphData.edges .filter((edge: any) => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target)) .map((edge: any) => { + // Simplified - only highlight connected edges, don't dim others let edgeState: 'default' | 'highlighted' | 'dimmed' | 'incoming' | 'outgoing' = 'default' if (effectiveSelectedId) { if (edge.source === effectiveSelectedId) edgeState = 'outgoing' else if (edge.target === effectiveSelectedId) edgeState = 'incoming' - else edgeState = 'dimmed' + // Don't dim - keep as default } return { From 9a08c5fb5cf4b404abd06b81c20639abcad77110 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Tue, 3 Feb 2026 16:04:58 -0500 Subject: [PATCH 4/5] fix: comprehensive graph visibility fixes Multiple issues addressed: 1. Tab visibility - graph now re-renders when tab regains focus - Added visibilitychange event listener - Uses renderKey to force ReactFlow refresh - Calls fitView after tab becomes visible 2. Cluster mode timing - fixed race condition - Added check for clusteredData.dirNodes.length > 0 - Falls back to non-clustered mode if data not ready 3. FitView on panel open/close - Graph now refits when ImpactPanel opens/closes - Added selectedNodeId to fitView dependencies 4. Dagre layout guards - Added empty nodes guard - Added fallback positions for failed layouts - Only adds edges where both endpoints exist --- .../src/components/DependencyGraph/index.tsx | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/DependencyGraph/index.tsx b/frontend/src/components/DependencyGraph/index.tsx index daa88df..6eb0044 100644 --- a/frontend/src/components/DependencyGraph/index.tsx +++ b/frontend/src/components/DependencyGraph/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback, useMemo } from 'react' +import { useEffect, useState, useCallback, useMemo, useRef } from 'react' import ReactFlow, { Controls, Background, @@ -125,6 +125,7 @@ function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps) const [clusterByDir, setClusterByDir] = useState(false) const [expandedDirs, setExpandedDirs] = useState>(new Set()) const [rawGraphData, setRawGraphData] = useState(null) + const [renderKey, setRenderKey] = useState(0) // Force re-render key const { fitView } = useReactFlow() const { resolvedTheme } = useTheme() @@ -137,6 +138,23 @@ function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps) if (data) setRawGraphData(data) }, [data]) + // Handle tab visibility changes - force re-render when tab becomes visible + useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + // Force re-render by updating key + setRenderKey(k => k + 1) + // Also trigger fitView after a short delay + setTimeout(() => { + fitView({ padding: 0.2, duration: 200 }) + }, 100) + } + } + + document.addEventListener('visibilitychange', handleVisibilityChange) + return () => document.removeEventListener('visibilitychange', handleVisibilityChange) + }, [fitView]) + const visibleNodeIds = useMemo(() => { if (!rawGraphData || !impact.isReady) return new Set() @@ -241,14 +259,17 @@ function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps) let flowNodes: Node[] = [] let flowEdges: Edge[] = [] - if (clusterByDir && clusteredData) { + // Only use cluster mode if we have clustered data ready + const useClusterMode = clusterByDir && clusteredData && clusteredData.dirNodes.length > 0 + + if (useClusterMode) { // Safety: only apply selection highlighting if the selected node is actually visible const effectiveSelectedIdCluster = selectedNodeId && - (clusteredData.visibleFiles.has(selectedNodeId) || selectedNodeId.startsWith('dir:')) + (clusteredData!.visibleFiles.has(selectedNodeId) || selectedNodeId.startsWith('dir:')) ? selectedNodeId : null // Add directory nodes - clusteredData.dirNodes.forEach(dir => { + clusteredData!.dirNodes.forEach(dir => { flowNodes.push({ id: dir.id, type: 'directory', @@ -259,7 +280,7 @@ function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps) // Add visible file nodes rawGraphData.nodes - .filter((node: any) => clusteredData.visibleFiles.has(node.id)) + .filter((node: any) => clusteredData!.visibleFiles.has(node.id)) .forEach((node: any) => { const fileName = node.label || node.id.split('/').pop() const metrics = impact.getFileMetrics(node.id) @@ -289,7 +310,7 @@ function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps) }) // Add edges - clusteredData.edges.forEach(([source, target]) => { + clusteredData!.edges.forEach(([source, target]) => { flowEdges.push({ id: `${source}-${target}`, source, @@ -360,12 +381,14 @@ function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps) setEdges(layoutedEdges) }, [rawGraphData, impact.isReady, visibleNodeIds, selectedNodeId, selectedImpact, hoveredFileId, isDark, clusterByDir, clusteredData]) + // Fit view when nodes change or panel opens/closes useEffect(() => { if (nodes.length > 0) { const minZoom = nodes.length > 20 ? 0.5 : 0.3 - setTimeout(() => fitView({ padding: 0.2, duration: 300, minZoom }), 100) + // Delay to allow container resize when panel opens/closes + setTimeout(() => fitView({ padding: 0.2, duration: 300, minZoom }), 150) } - }, [showAll, showTests, clusterByDir, expandedDirs]) + }, [nodes.length, selectedNodeId, showAll, showTests, clusterByDir, expandedDirs, fitView]) const handleNodeClick = useCallback((_: any, node: Node) => { // Toggle directory expansion @@ -492,6 +515,7 @@ function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps)
Date: Tue, 3 Feb 2026 16:23:40 -0500 Subject: [PATCH 5/5] fix: remove useEffect that was resetting collapsible section state The useEffect was resetting isOpen to defaultOpen whenever files changed, which caused the section to immediately close after clicking to open it. --- frontend/src/components/DependencyGraph/ImpactPanel.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/frontend/src/components/DependencyGraph/ImpactPanel.tsx b/frontend/src/components/DependencyGraph/ImpactPanel.tsx index ff0cb9c..4ffcd7a 100644 --- a/frontend/src/components/DependencyGraph/ImpactPanel.tsx +++ b/frontend/src/components/DependencyGraph/ImpactPanel.tsx @@ -115,11 +115,6 @@ function CollapsibleSection({ }) { const [isOpen, setIsOpen] = useState(defaultOpen) - // Reset collapse state when file selection changes - useEffect(() => { - setIsOpen(defaultOpen) - }, [defaultOpen, files]) - const variantStyles = { direct: 'text-rose-600 dark:text-rose-400', transitive: 'text-amber-600 dark:text-amber-400',