Merge pull request #114 from Lum1104/fix/dashboard-tour-cycle

fix(dashboard): tour navigation glitches across layers
This commit is contained in:
Yuxiang Lin
2026-05-04 19:28:11 +08:00
committed by GitHub
7 changed files with 226 additions and 36 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "understand-anything",
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
"version": "2.5.0",
"version": "2.5.1",
"author": {
"name": "Lum1104"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "understand-anything",
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
"version": "2.5.0",
"version": "2.5.1",
"author": {
"name": "Lum1104"
},
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "understand-anything",
"displayName": "Understand Anything",
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
"version": "2.5.0",
"version": "2.5.1",
"author": {
"name": "Lum1104"
},
@@ -1,7 +1,7 @@
{
"name": "understand-anything",
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
"version": "2.5.0",
"version": "2.5.1",
"author": {
"name": "Lum1104"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@understand-anything/skill",
"version": "2.5.0",
"version": "2.5.1",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ReactFlow,
ReactFlowProvider,
useNodes,
useNodesState,
useEdgesState,
useReactFlow,
@@ -78,32 +79,101 @@ const NODE_TYPE_TO_CATEGORY: Record<NodeType, NodeCategory> = {
// ── Helper components that must live inside <ReactFlow> ────────────────
/** Pans/zooms to tour-highlighted nodes. */
/**
* Pans/zooms to tour-highlighted nodes. Highlighted nodes are usually
* children of collapsed containers — auto-expand fires synchronously on
* the same `tourHighlightedNodeIds` change, but their child entries don't
* appear in React Flow's node list until Stage 2 layout writes the
* `containerLayoutCache` (async ELK call, hundreds of ms on big layers).
*
* We subscribe to React Flow's reactive node list via `useNodes()` so the
* effect re-runs every time the node set actually changes (Stage 1, Stage
* 2, expand/collapse). When every highlighted id is present we fit; until
* then we wait. A 2s fallback timer covers the case where a highlighted
* id is filtered out and never materialises.
*/
function TourFitView() {
const tourHighlightedNodeIds = useDashboardStore((s) => s.tourHighlightedNodeIds);
const { fitView } = useReactFlow();
const prevRef = useRef<string[]>([]);
const setTourFitPending = useDashboardStore((s) => s.setTourFitPending);
const { fitView, getInternalNode } = useReactFlow();
// Subscribe to React Flow's user-node array so this effect re-fires when
// the node set changes (e.g. Stage 2 finally lands the highlighted ids
// after the per-step RAF window already gave up). The RAF poll inside
// covers the common fast path; the `nodes` dep covers slow Stage 2.
const nodes = useNodes();
const fittedKeyRef = useRef<string>("");
const fallbackKeyRef = useRef<string>("");
useEffect(() => {
const prev = prevRef.current;
const changed =
tourHighlightedNodeIds.length > 0 &&
(tourHighlightedNodeIds.length !== prev.length ||
tourHighlightedNodeIds.some((id, i) => id !== prev[i]));
prevRef.current = tourHighlightedNodeIds;
const targetKey = tourHighlightedNodeIds.join("\n");
if (targetKey === "") {
fittedKeyRef.current = "";
fallbackKeyRef.current = "";
setTourFitPending(false);
return;
}
if (targetKey === fittedKeyRef.current) return;
if (changed) {
requestAnimationFrame(() => {
// Poll React Flow's internal lookup directly — `useNodes()` reflects
// user-supplied nodes and may not fire on measure completion. Once
// every highlighted id has measured dimensions, `fitView({ nodes })`
// handles the child→absolute coordinate transform itself, which is
// more reliable than recomputing bbox manually.
const MAX_FRAMES = 240; // ~4s at 60fps
let frame = 0;
let cancelled = false;
let rafId = 0;
// After we've already shown the fallback for this step, suppress the
// "Locating tour highlight…" overlay on subsequent re-fires (each
// `nodes` change re-enters the effect, but the user has already given
// up waiting). The retry still runs silently in case Stage 2 lands.
if (fallbackKeyRef.current !== targetKey) setTourFitPending(true);
const tick = () => {
if (cancelled) return;
let ready = true;
for (const id of tourHighlightedNodeIds) {
const internal = getInternalNode(id);
if (!internal || !internal.measured?.width || !internal.measured?.height) {
ready = false;
break;
}
}
if (ready) {
fitView({
nodes: tourHighlightedNodeIds.map((id) => ({ id })),
duration: 500,
padding: 0.3,
maxZoom: 1.2,
minZoom: 0.01,
minZoom: 0.4,
});
});
}
}, [tourHighlightedNodeIds, fitView]);
fittedKeyRef.current = targetKey;
fallbackKeyRef.current = "";
setTourFitPending(false);
return;
}
if (++frame < MAX_FRAMES) {
rafId = requestAnimationFrame(tick);
return;
}
// Highlights still not ready after the poll window. Pan into the
// layer so the user isn't stranded, but DON'T set fittedKeyRef —
// if Stage 2 lands later, a `nodes` change will re-fire this effect
// and we'll get another shot at the proper highlight fit.
// `fallbackKeyRef` prevents the fallback fitView from re-firing on
// every subsequent nodes update for the same step.
if (fallbackKeyRef.current !== targetKey) {
fitView({ duration: 500, padding: 0.3 });
fallbackKeyRef.current = targetKey;
}
setTourFitPending(false);
};
rafId = requestAnimationFrame(tick);
return () => {
cancelled = true;
cancelAnimationFrame(rafId);
};
}, [tourHighlightedNodeIds, nodes, fitView, getInternalNode, setTourFitPending]);
return null;
}
@@ -1219,6 +1289,10 @@ function GraphViewInner() {
const setReactFlowInstance = useDashboardStore((s) => s.setReactFlowInstance);
const tourHighlightedNodeIds = useDashboardStore((s) => s.tourHighlightedNodeIds);
const expandContainer = useDashboardStore((s) => s.expandContainer);
const collapseContainer = useDashboardStore((s) => s.collapseContainer);
const pendingFocusContainer = useDashboardStore((s) => s.pendingFocusContainer);
const setPendingFocusContainer = useDashboardStore((s) => s.setPendingFocusContainer);
const tourFitPending = useDashboardStore((s) => s.tourFitPending);
const { preset } = useTheme();
const overviewGraph = useOverviewGraph();
@@ -1237,7 +1311,7 @@ function GraphViewInner() {
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const { fitView, getViewport } = useReactFlow();
const { fitView, getViewport, setCenter } = useReactFlow();
useEffect(() => {
setNodes(initialNodes);
@@ -1267,6 +1341,33 @@ function GraphViewInner() {
return () => cancelAnimationFrame(raf);
}, [nodes, fitView]);
// Lock viewport onto a container the user just manually expanded so it
// appears to expand in place rather than getting yanked off-screen by
// the surrounding ELK reflow. Re-runs as nodes update (Stage 2 may
// shift positions a few times) and clears itself after a short window
// so subsequent layout shifts stop hijacking the viewport.
useEffect(() => {
if (!pendingFocusContainer) return;
const node = nodes.find((n) => n.id === pendingFocusContainer);
if (!node) return;
const w =
(node.width as number | undefined) ??
((node.style?.width as number | undefined) ?? 0);
const h =
(node.height as number | undefined) ??
((node.style?.height as number | undefined) ?? 0);
const cx = node.position.x + w / 2;
const cy = node.position.y + h / 2;
const { zoom } = getViewport();
setCenter(cx, cy, { zoom, duration: 0 });
}, [pendingFocusContainer, nodes, getViewport, setCenter]);
useEffect(() => {
if (!pendingFocusContainer) return;
const t = window.setTimeout(() => setPendingFocusContainer(null), 1200);
return () => window.clearTimeout(t);
}, [pendingFocusContainer, setPendingFocusContainer]);
// ── Auto-expand triggers (Task 13) ─────────────────────────────────────
// Only meaningful in layer-detail; in overview mode there are no
// containers so all three effects no-op.
@@ -1281,15 +1382,40 @@ function GraphViewInner() {
if (cid && cid !== focusNodeId) expandContainer(cid);
}, [focusNodeId, nodeToContainer, expandContainer]);
// Tour: expand containers for every tour-highlighted node so the tour
// can fitView onto real nodes rather than collapsed atoms.
// Tour: expand containers needed for the current step, and release any
// containers we expanded for the previous step that aren't needed now.
// Containers the user expanded manually aren't tracked here, so they're
// never auto-collapsed. stopTour resets tourHighlightedNodeIds to [],
// which falls through to the "release all" branch.
const tourBorrowedContainersRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (tourHighlightedNodeIds.length === 0 || !nodeToContainer) return;
if (!nodeToContainer) return;
const needed = new Set<string>();
for (const nid of tourHighlightedNodeIds) {
const cid = nodeToContainer.get(nid);
if (cid && cid !== nid) expandContainer(cid);
if (cid && cid !== nid) needed.add(cid);
}
}, [tourHighlightedNodeIds, nodeToContainer, expandContainer]);
const stillBorrowed = new Set<string>();
for (const cid of tourBorrowedContainersRef.current) {
if (needed.has(cid)) {
stillBorrowed.add(cid);
} else {
collapseContainer(cid);
}
}
const expandedNow = useDashboardStore.getState().expandedContainers;
for (const cid of needed) {
if (!expandedNow.has(cid)) {
expandContainer(cid);
stillBorrowed.add(cid);
}
}
tourBorrowedContainersRef.current = stillBorrowed;
}, [tourHighlightedNodeIds, nodeToContainer, expandContainer, collapseContainer]);
// Zoom: debounced auto-expand when the user has zoomed in past 1.0.
// Hysteresis: zoom < 0.6 = no auto-expand AND no auto-collapse (v1, the
@@ -1406,7 +1532,7 @@ function GraphViewInner() {
<TourFitView />
<SelectedNodeFitView />
</ReactFlow>
{layoutStatus === "computing" && (
{(layoutStatus === "computing" || tourFitPending) && (
<div
style={{
position: "absolute",
@@ -1419,7 +1545,9 @@ function GraphViewInner() {
zIndex: 10,
}}
>
<span style={{ color: "#d4a574", fontSize: 14 }}>Computing layout</span>
<span style={{ color: "#d4a574", fontSize: 14 }}>
{tourFitPending ? "Locating tour highlight…" : "Computing layout…"}
</span>
</div>
)}
</div>
@@ -196,7 +196,14 @@ interface DashboardStore {
expandedContainers: Set<string>;
toggleContainer: (containerId: string) => void;
expandContainer: (containerId: string) => void;
collapseContainer: (containerId: string) => void;
collapseAllContainers: () => void;
/** Container the user just manually expanded; viewport should lock onto it. Cleared by GraphView once the lock is applied. */
pendingFocusContainer: string | null;
setPendingFocusContainer: (containerId: string | null) => void;
/** True while TourFitView is waiting for highlighted nodes to materialise (Stage 2 layout in progress). Drives the "Computing layout…" overlay. */
tourFitPending: boolean;
setTourFitPending: (pending: boolean) => void;
containerLayoutCache: Map<
string,
@@ -245,6 +252,32 @@ function navigateTourToLayer(
return {};
}
/**
* Container ids derive from per-layer state — folder names in folder-strategy
* layers, community indices (`container:cluster-N`) in community-strategy
* layers — and collide across layers (e.g. API Contracts and Load Testing
* both produce `container:cluster-0`). When a tour step crosses layers we
* must drop the previous layer's container caches so Stage 2 actually re-
* runs for the new layer's children. Mirrors the reset block in
* `drillIntoLayer`.
*/
function layerResetIfChanged(
layerNav: Partial<DashboardStore>,
prevLayerId: string | null,
): Partial<DashboardStore> {
const next = layerNav.activeLayerId;
if (!next || next === prevLayerId) return {};
return {
containerLayoutCache: new Map(),
containerSizeMemory: new Map(),
expandedContainers: new Set(),
// Drop any pending focus too — its id was scoped to the previous
// layer and would otherwise re-collide with a same-id container in
// the new layer for the duration of the 1.2s timer.
pendingFocusContainer: null,
};
}
export const useDashboardStore = create<DashboardStore>()((set, get) => ({
graph: null,
nodesById: new Map<string, GraphNode>(),
@@ -295,6 +328,7 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
containerLayoutCache: new Map(),
containerSizeMemory: new Map(),
expandedContainers: new Set(),
pendingFocusContainer: null,
})),
setGraph: (graph) => {
@@ -321,6 +355,7 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
activeDomainId: keepDomainView ? activeDomainId : null,
containerLayoutCache: new Map(),
expandedContainers: new Set(),
pendingFocusContainer: null,
containerSizeMemory: new Map(),
stage1Tick: 0,
layoutIssues: [],
@@ -420,6 +455,7 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
containerLayoutCache: new Map(),
containerSizeMemory: new Map(),
expandedContainers: new Set(),
pendingFocusContainer: null,
}),
navigateToOverview: () =>
@@ -434,6 +470,7 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
containerLayoutCache: new Map(),
containerSizeMemory: new Map(),
expandedContainers: new Set(),
pendingFocusContainer: null,
}),
setFocusNode: (nodeId) =>
@@ -446,6 +483,7 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
containerLayoutCache: new Map(),
containerSizeMemory: new Map(),
expandedContainers: new Set(),
pendingFocusContainer: null,
}),
setSearchMode: (mode) => set({ searchMode: mode }),
setSearchQuery: (query) => {
@@ -469,6 +507,7 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
containerLayoutCache: new Map(),
containerSizeMemory: new Map(),
expandedContainers: new Set(),
pendingFocusContainer: null,
}),
openCodeViewer: (nodeId) =>
@@ -532,7 +571,7 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
},
startTour: () => {
const { graph, nodeIdToLayerId } = get();
const { graph, nodeIdToLayerId, activeLayerId } = get();
if (!graph || !graph.tour || graph.tour.length === 0) return;
const sorted = getSortedTour(graph);
const layerNav = navigateTourToLayer(nodeIdToLayerId, sorted[0].nodeIds);
@@ -542,6 +581,7 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
tourHighlightedNodeIds: sorted[0].nodeIds,
selectedNodeId: null,
...layerNav,
...layerResetIfChanged(layerNav, activeLayerId),
});
},
@@ -553,7 +593,7 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
}),
setTourStep: (step) => {
const { graph, nodeIdToLayerId } = get();
const { graph, nodeIdToLayerId, activeLayerId } = get();
if (!graph || !graph.tour || graph.tour.length === 0) return;
const sorted = getSortedTour(graph);
if (step < 0 || step >= sorted.length) return;
@@ -562,11 +602,12 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
currentTourStep: step,
tourHighlightedNodeIds: sorted[step].nodeIds,
...layerNav,
...layerResetIfChanged(layerNav, activeLayerId),
});
},
nextTourStep: () => {
const { graph, currentTourStep, nodeIdToLayerId } = get();
const { graph, currentTourStep, nodeIdToLayerId, activeLayerId } = get();
if (!graph || !graph.tour || graph.tour.length === 0) return;
const sorted = getSortedTour(graph);
if (currentTourStep < sorted.length - 1) {
@@ -576,12 +617,13 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
currentTourStep: next,
tourHighlightedNodeIds: sorted[next].nodeIds,
...layerNav,
...layerResetIfChanged(layerNav, activeLayerId),
});
}
},
prevTourStep: () => {
const { graph, currentTourStep, nodeIdToLayerId } = get();
const { graph, currentTourStep, nodeIdToLayerId, activeLayerId } = get();
if (!graph || !graph.tour || graph.tour.length === 0) return;
if (currentTourStep > 0) {
const sorted = getSortedTour(graph);
@@ -591,6 +633,7 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
currentTourStep: prev,
tourHighlightedNodeIds: sorted[prev].nodeIds,
...layerNav,
...layerResetIfChanged(layerNav, activeLayerId),
});
}
},
@@ -641,12 +684,23 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
},
expandedContainers: new Set<string>(),
pendingFocusContainer: null,
setPendingFocusContainer: (containerId) =>
set({ pendingFocusContainer: containerId }),
tourFitPending: false,
setTourFitPending: (pending) => set({ tourFitPending: pending }),
toggleContainer: (containerId) =>
set((state) => {
const next = new Set(state.expandedContainers);
if (next.has(containerId)) next.delete(containerId);
else next.add(containerId);
return { expandedContainers: next };
const willExpand = !next.has(containerId);
if (willExpand) next.add(containerId);
else next.delete(containerId);
return {
expandedContainers: next,
pendingFocusContainer: willExpand
? containerId
: state.pendingFocusContainer,
};
}),
expandContainer: (containerId) =>
set((state) => {
@@ -655,6 +709,13 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
next.add(containerId);
return { expandedContainers: next };
}),
collapseContainer: (containerId) =>
set((state) => {
if (!state.expandedContainers.has(containerId)) return {};
const next = new Set(state.expandedContainers);
next.delete(containerId);
return { expandedContainers: next };
}),
collapseAllContainers: () => set({ expandedContainers: new Set() }),
containerLayoutCache: new Map(),
@@ -667,7 +728,7 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
return { containerLayoutCache: next, containerSizeMemory: sizeNext };
}),
clearContainerLayouts: () =>
set({ containerLayoutCache: new Map(), expandedContainers: new Set() }),
set({ containerLayoutCache: new Map(), expandedContainers: new Set(), pendingFocusContainer: null }),
containerSizeMemory: new Map(),
@@ -689,3 +750,4 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
}),
clearLayoutIssues: () => set({ layoutIssues: [] }),
}));