Merge pull request #2 from KyleWMiller/feature/diff-mode

Diff Mode
This commit is contained in:
Yuxiang Lin
2026-03-17 11:55:10 +08:00
committed by GitHub
8 changed files with 222 additions and 10 deletions
@@ -6,6 +6,7 @@ import CodeViewer from "./components/CodeViewer";
import SearchBar from "./components/SearchBar";
import NodeInfo from "./components/NodeInfo";
import LayerLegend from "./components/LayerLegend";
import DiffToggle from "./components/DiffToggle";
import LearnPanel from "./components/LearnPanel";
import PersonaSelector from "./components/PersonaSelector";
import ProjectOverview from "./components/ProjectOverview";
@@ -18,6 +19,7 @@ function App() {
const persona = useDashboardStore((s) => s.persona);
const codeViewerOpen = useDashboardStore((s) => s.codeViewerOpen);
const closeCodeViewer = useDashboardStore((s) => s.closeCodeViewer);
const setDiffOverlay = useDashboardStore((s) => s.setDiffOverlay);
const [loadError, setLoadError] = useState<string | null>(null);
useEffect(() => {
@@ -39,6 +41,32 @@ function App() {
});
}, [setGraph]);
useEffect(() => {
fetch("/diff-overlay.json")
.then((res) => {
if (!res.ok) return null;
return res.json();
})
.then((data: unknown) => {
if (
data &&
typeof data === "object" &&
"changedNodeIds" in data &&
"affectedNodeIds" in data &&
Array.isArray((data as Record<string, unknown>).changedNodeIds) &&
Array.isArray((data as Record<string, unknown>).affectedNodeIds)
) {
const d = data as { changedNodeIds: string[]; affectedNodeIds: string[] };
if (d.changedNodeIds.length > 0) {
setDiffOverlay(d.changedNodeIds, d.affectedNodeIds);
}
}
})
.catch(() => {
// Silently ignore - diff overlay is optional
});
}, [setDiffOverlay]);
// Determine sidebar content
// Learn persona always shows LearnPanel; tour active overrides everything
const sidebarContent = tourActive || persona === "junior" ? (
@@ -61,6 +89,7 @@ function App() {
<PersonaSelector />
</div>
<div className="flex items-center gap-4">
<DiffToggle />
<LayerLegend />
</div>
</header>
@@ -32,6 +32,9 @@ export interface CustomNodeData extends Record<string, unknown> {
searchScore?: number;
isSelected: boolean;
isTourHighlighted: boolean;
isDiffChanged: boolean;
isDiffAffected: boolean;
isDiffFaded: boolean;
onNodeClick?: (nodeId: string) => void;
}
@@ -61,6 +64,15 @@ export default function CustomNode({
}
}
// Diff overlay styling (composes with above)
if (data.isDiffChanged) {
extraClass += " ring-2 ring-[var(--color-diff-changed)] diff-changed-glow";
} else if (data.isDiffAffected) {
extraClass += " ring-1 ring-[var(--color-diff-affected)] diff-affected-glow";
} else if (data.isDiffFaded) {
extraClass += " diff-faded";
}
const name = data.label ?? "unnamed";
const truncatedName =
name.length > 24 ? name.slice(0, 22) + "..." : name;
@@ -0,0 +1,64 @@
import { useDashboardStore } from "../store";
export default function DiffToggle() {
const diffMode = useDashboardStore((s) => s.diffMode);
const toggleDiffMode = useDashboardStore((s) => s.toggleDiffMode);
const changedNodeIds = useDashboardStore((s) => s.changedNodeIds);
const affectedNodeIds = useDashboardStore((s) => s.affectedNodeIds);
const hasDiff = changedNodeIds.size > 0;
return (
<div className="flex items-center gap-2">
<button
onClick={toggleDiffMode}
disabled={!hasDiff}
className={`px-2 py-0.5 rounded text-[11px] font-medium transition-colors ${
diffMode && hasDiff
? "bg-[var(--color-diff-changed-dim)] text-[var(--color-diff-changed)]"
: hasDiff
? "bg-elevated text-text-secondary hover:bg-surface"
: "bg-elevated text-text-muted cursor-not-allowed"
}`}
title={
hasDiff
? diffMode
? "Hide diff overlay"
: "Show diff overlay"
: "No diff data loaded"
}
>
Diff {diffMode && hasDiff ? "ON" : "OFF"}
</button>
{diffMode && hasDiff && (
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<span
className="inline-block w-2 h-2 rounded-full"
style={{ backgroundColor: "var(--color-diff-changed)" }}
/>
<span className="text-text-secondary text-[11px]">
Changed
<span className="text-text-muted ml-0.5">
({changedNodeIds.size})
</span>
</span>
</div>
<div className="flex items-center gap-1">
<span
className="inline-block w-2 h-2 rounded-full"
style={{ backgroundColor: "var(--color-diff-affected)" }}
/>
<span className="text-text-secondary text-[11px]">
Affected
<span className="text-text-muted ml-0.5">
({affectedNodeIds.size})
</span>
</span>
</div>
</div>
)}
</div>
);
}
@@ -30,6 +30,9 @@ export default function GraphView() {
const showLayers = useDashboardStore((s) => s.showLayers);
const tourHighlightedNodeIds = useDashboardStore((s) => s.tourHighlightedNodeIds);
const persona = useDashboardStore((s) => s.persona);
const diffMode = useDashboardStore((s) => s.diffMode);
const changedNodeIds = useDashboardStore((s) => s.changedNodeIds);
const affectedNodeIds = useDashboardStore((s) => s.affectedNodeIds);
const handleNodeSelect = useCallback(
(nodeId: string) => {
@@ -78,20 +81,41 @@ export default function GraphView() {
searchScore: matchResult?.score,
isSelected: selectedNodeId === node.id,
isTourHighlighted: tourHighlightedNodeIds.includes(node.id),
isDiffChanged: diffMode && changedNodeIds.has(node.id),
isDiffAffected: diffMode && affectedNodeIds.has(node.id),
isDiffFaded: diffMode && !changedNodeIds.has(node.id) && !affectedNodeIds.has(node.id),
onNodeClick: handleNodeSelect,
},
};
});
const flowEdges: Edge[] = filteredGraphEdges.map((edge, i) => ({
id: `e-${i}`,
source: edge.source,
target: edge.target,
label: edge.type,
animated: edge.type === "calls",
style: { stroke: "rgba(212,165,116,0.3)", strokeWidth: 1.5 },
labelStyle: { fill: "#a39787", fontSize: 10 },
}));
const diffNodeIds = new Set([...changedNodeIds, ...affectedNodeIds]);
const flowEdges: Edge[] = filteredGraphEdges.map((edge, i) => {
const sourceInDiff = diffNodeIds.has(edge.source);
const targetInDiff = diffNodeIds.has(edge.target);
const isImpacted = diffMode && (sourceInDiff || targetInDiff);
return {
id: `e-${i}`,
source: edge.source,
target: edge.target,
label: edge.type,
animated: edge.type === "calls" || isImpacted,
style: isImpacted
? {
stroke: sourceInDiff && targetInDiff
? "rgba(224, 82, 82, 0.7)"
: "rgba(212, 160, 48, 0.5)",
strokeWidth: 2.5,
}
: diffMode
? { stroke: "rgba(212,165,116,0.08)", strokeWidth: 1 }
: { stroke: "rgba(212,165,116,0.3)", strokeWidth: 1.5 },
labelStyle: diffMode && !isImpacted
? { fill: "rgba(163,151,135,0.3)", fontSize: 10 }
: { fill: "#a39787", fontSize: 10 },
};
});
// Run dagre layout on all nodes (without groups)
const laid = applyDagreLayout(flowNodes, flowEdges);
@@ -190,7 +214,7 @@ export default function GraphView() {
];
return { initialNodes: allNodes, initialEdges: laid.edges };
}, [graph, searchResults, selectedNodeId, showLayers, tourHighlightedNodeIds, persona, handleNodeSelect]);
}, [graph, searchResults, selectedNodeId, showLayers, tourHighlightedNodeIds, persona, handleNodeSelect, diffMode, changedNodeIds, affectedNodeIds]);
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
@@ -28,6 +28,12 @@
--color-node-module: #c9a06c;
--color-node-concept: #b07a8a;
/* Diff overlay colors */
--color-diff-changed: #e05252;
--color-diff-affected: #d4a030;
--color-diff-changed-dim: rgba(224, 82, 82, 0.25);
--color-diff-affected-dim: rgba(212, 160, 48, 0.25);
/* Fonts */
--font-serif: 'DM Serif Display', Georgia, serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
@@ -113,6 +119,22 @@ body {
box-shadow: 0 0 20px rgba(212, 165, 116, 0.15);
}
/* Diff overlay glow effects */
.diff-changed-glow {
box-shadow: 0 0 16px rgba(224, 82, 82, 0.25);
}
.diff-affected-glow {
box-shadow: 0 0 12px rgba(212, 160, 48, 0.2);
}
/* Diff fade for unrelated nodes */
.diff-faded {
opacity: 0.25;
filter: saturate(0.3);
transition: opacity 0.3s ease, filter 0.3s ease;
}
/* Custom scrollbar for dark luxury theme */
::-webkit-scrollbar {
width: 6px;
@@ -28,6 +28,10 @@ interface DashboardStore {
persona: Persona;
diffMode: boolean;
changedNodeIds: Set<string>;
affectedNodeIds: Set<string>;
setGraph: (graph: KnowledgeGraph) => void;
selectNode: (nodeId: string | null) => void;
setSearchQuery: (query: string) => void;
@@ -36,6 +40,10 @@ interface DashboardStore {
openCodeViewer: (nodeId: string) => void;
closeCodeViewer: () => void;
setDiffOverlay: (changed: string[], affected: string[]) => void;
toggleDiffMode: () => void;
clearDiffOverlay: () => void;
startTour: () => void;
stopTour: () => void;
setTourStep: (step: number) => void;
@@ -67,6 +75,10 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
persona: "junior",
diffMode: false,
changedNodeIds: new Set<string>(),
affectedNodeIds: new Set<string>(),
setGraph: (graph) => {
const searchEngine = new SearchEngine(graph.nodes);
const query = get().searchQuery;
@@ -96,6 +108,22 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
openCodeViewer: (nodeId) => set({ codeViewerOpen: true, codeViewerNodeId: nodeId }),
closeCodeViewer: () => set({ codeViewerOpen: false, codeViewerNodeId: null }),
setDiffOverlay: (changed, affected) =>
set({
diffMode: true,
changedNodeIds: new Set(changed),
affectedNodeIds: new Set(affected),
}),
toggleDiffMode: () => set((state) => ({ diffMode: !state.diffMode })),
clearDiffOverlay: () =>
set({
diffMode: false,
changedNodeIds: new Set<string>(),
affectedNodeIds: new Set<string>(),
}),
startTour: () => {
const { graph } = get();
if (!graph || !graph.tour || graph.tour.length === 0) return;
@@ -38,6 +38,26 @@ export default defineConfig({
}
}
}
if (req.url === "/diff-overlay.json") {
const graphDir = process.env.GRAPH_DIR;
const candidates = [
...(graphDir
? [path.resolve(graphDir, ".understand-anything/diff-overlay.json")]
: []),
path.resolve(process.cwd(), ".understand-anything/diff-overlay.json"),
path.resolve(process.cwd(), "../../../.understand-anything/diff-overlay.json"),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
res.setHeader("Content-Type", "application/json");
fs.createReadStream(candidate).pipe(res);
return;
}
}
res.statusCode = 404;
res.end();
return;
}
next();
});
},
@@ -55,3 +55,16 @@ The knowledge graph JSON has this structure:
- **Affected Layers**: Which architectural layers are touched and cross-layer concerns
- **Risk Assessment**: Based on node `complexity` values, number of cross-layer edges, and blast radius (number of affected components)
- Suggest what to review carefully and any potential issues
8. **Write diff overlay for dashboard** — after producing the analysis, write the diff data to `.understand-anything/diff-overlay.json` so the dashboard can visualize changed and affected components. The file contains:
```json
{
"version": "1.0.0",
"baseBranch": "<the base branch used>",
"generatedAt": "<ISO timestamp>",
"changedFiles": ["<list of changed file paths>"],
"changedNodeIds": ["<node IDs from step 4>"],
"affectedNodeIds": ["<node IDs from step 5, excluding changedNodeIds>"]
}
```
After writing, tell the user they can run `/understand-anything:understand-dashboard` to see the diff overlay visually.