Files
fusion/plugins/fusion-plugin-dependency-graph/src/DependencyGraph.tsx
gsxdsm 29d94e0fa3 fix: drop dependency-graph plugin's use of removed stuck-task tagging
The dependency-graph plugin imported the deleted taskStuck util through its
dashboard interop shim, breaking the dashboard vite build. Remove the isStuck
gate from graph nodes, the taskStuckTimeoutMs prop threading, the taskStuck
module declaration from dashboard-interop.d.ts, and the stalled-card-as-stuck
test coverage. Covered by the existing remove-stuck-task-tagging changeset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 15:58:58 -07:00

557 lines
24 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Task, TraitFlags } from "@fusion/core";
import { GraphTaskNode } from "./GraphTaskNode.js";
import { GraphToolbar } from "./GraphToolbar.js";
import { GraphEdges } from "./edges.js";
import { filterGraphTasks } from "./filters.js";
import { computeAutoLayout, type LayoutOptions } from "./layout.js";
import { useGraphData } from "./useGraphData.js";
import { useGraphInteraction } from "./useGraphInteraction.js";
import { useDependencyChain } from "./hooks/useDependencyChain.js";
import { useGraphPositions } from "./hooks/useGraphPositions.js";
import { mergePositions, type NodePositions } from "./utils/graphPositionStorage.js";
import "./DependencyGraph.css";
const NODE_WIDTH = 280;
const NODE_HEIGHT = 100;
const NARROW_VIEWPORT_WIDTH = 768;
export interface DependencyGraphProps {
tasks: Task[];
projectId?: string;
/* FNXC:WorkflowLifecycleColumns 2026-07-31-15:30: per-task resolved column traits from the host. */
columnFlagsByTaskId?: ReadonlyMap<string, Partial<TraitFlags>>;
onOpenTaskDetail?: (taskId: string) => void;
onOpenDetail?: (task: Task) => void;
addToast?: (message: string, type?: "success" | "error" | "info" | "warning") => void;
globalPaused?: boolean;
onUpdateTask?: (id: string, updates: { title?: string; description?: string; dependencies?: string[] }) => Promise<Task>;
onArchiveTask?: (id: string) => Promise<Task>;
onUnarchiveTask?: (id: string) => Promise<Task>;
onDeleteTask?: (id: string, options?: { removeDependencyReferences?: boolean }) => Promise<Task>;
onRetryTask?: (id: string) => Promise<Task>;
onOpenDetailWithTab?: (task: Task, initialTab: "changes") => void;
onOpenMission?: (missionId: string) => void;
onMoveTask?: (id: string, column: Task["column"], optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
lastFetchTimeMs?: number;
}
const POINTER_MOVE_THRESHOLD = 4;
export function DependencyGraph({
tasks,
projectId,
columnFlagsByTaskId,
onOpenTaskDetail,
onOpenDetail,
addToast,
globalPaused,
onUpdateTask,
onArchiveTask,
onUnarchiveTask,
onDeleteTask,
onRetryTask,
onOpenDetailWithTab,
onOpenMission,
onMoveTask,
lastFetchTimeMs,
}: DependencyGraphProps) {
const viewportRef = useRef<HTMLDivElement | null>(null);
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
const pointerDraggedRef = useRef(false);
const nodeRefs = useRef(new Map<string, HTMLDivElement>());
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 });
const [measuredHeights, setMeasuredHeights] = useState<Map<string, number>>(new Map());
const filteredTasks = useMemo(() => filterGraphTasks(tasks), [tasks]);
const graphData = useGraphData(filteredTasks);
const { getChain } = useDependencyChain(filteredTasks);
const activeTaskId = hoveredTaskId ?? selectedTaskId;
const highlightedTaskIds = useMemo(() => (activeTaskId ? getChain(activeTaskId) : new Set<string>()), [activeTaskId, getChain]);
const orientation = useMemo<NonNullable<LayoutOptions["orientation"]>>(() => {
const width = viewportSize.width || (typeof window !== "undefined" ? window.innerWidth : 0);
const height = viewportSize.height || (typeof window !== "undefined" ? window.innerHeight : 0);
return height > width || width < NARROW_VIEWPORT_WIDTH ? "horizontal" : "vertical";
}, [viewportSize.height, viewportSize.width]);
const layoutOptions = useMemo<LayoutOptions>(
() => ({
nodeWidth: NODE_WIDTH,
nodeHeight: NODE_HEIGHT,
horizontalGap: 40,
verticalGap: 80,
orientation,
measuredHeights,
}),
[measuredHeights, orientation],
);
const autoLayoutPositions = useMemo(() => computeAutoLayout(graphData, layoutOptions), [graphData, layoutOptions]);
const visibleTaskIds = useMemo(() => new Set(filteredTasks.map((task) => task.id)), [filteredTasks]);
const { savedPositions, persistPositions, clearSavedPositions } = useGraphPositions({ projectId, visibleTaskIds });
const [positions, setPositions] = useState<Map<string, { x: number; y: number }>>(autoLayoutPositions);
const [isNodeDragging, setIsNodeDragging] = useState(false);
useEffect(() => {
const autoLayoutRecord: NodePositions = {};
for (const [taskId, position] of autoLayoutPositions.entries()) {
autoLayoutRecord[taskId] = position;
}
const merged = savedPositions ? mergePositions(autoLayoutRecord, savedPositions, visibleTaskIds) : autoLayoutRecord;
setPositions(new Map(Object.entries(merged)));
}, [autoLayoutPositions, savedPositions, visibleTaskIds]);
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
const updateViewportSize = (width: number, height: number) => {
setViewportSize((current) => {
if (current.width === width && current.height === height) return current;
return { width, height };
});
};
if (typeof ResizeObserver === "undefined") {
updateViewportSize(
viewport.clientWidth || (typeof window !== "undefined" ? window.innerWidth : 0),
viewport.clientHeight || (typeof window !== "undefined" ? window.innerHeight : 0),
);
return;
}
updateViewportSize(viewport.clientWidth, viewport.clientHeight);
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
updateViewportSize(entry.contentRect.width, entry.contentRect.height);
});
observer.observe(viewport);
return () => observer.disconnect();
}, []);
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
const relevantTaskIds = new Set(graphData.nodes.map((node) => node.task.id));
const nextRefs = new Map<string, HTMLDivElement>();
for (const taskId of relevantTaskIds) {
const element = viewport.querySelector<HTMLDivElement>(`[data-testid="graph-task-node-${taskId}"]`);
if (element) {
nextRefs.set(taskId, element);
}
}
nodeRefs.current = nextRefs;
setMeasuredHeights((current) => {
let changed = false;
const next = new Map<string, number>();
for (const [taskId, height] of current.entries()) {
if (!relevantTaskIds.has(taskId)) {
changed = true;
continue;
}
next.set(taskId, height);
}
return changed ? next : current;
});
const updateHeightForTask = (taskId: string, height: number) => {
setMeasuredHeights((current) => {
const previous = current.get(taskId);
if (previous !== undefined && Math.abs(previous - height) < 1) {
return current;
}
const next = new Map(current);
next.set(taskId, height);
return next;
});
};
for (const [taskId, element] of nextRefs.entries()) {
updateHeightForTask(taskId, element.offsetHeight || NODE_HEIGHT);
}
if (typeof ResizeObserver === "undefined") {
return;
}
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const element = entry.target as HTMLDivElement;
const taskId = Array.from(nextRefs.entries()).find(([, refElement]) => refElement === element)?.[0];
if (!taskId) continue;
updateHeightForTask(taskId, element.offsetHeight || NODE_HEIGHT);
}
});
for (const element of nextRefs.values()) {
observer.observe(element);
}
return () => observer.disconnect();
}, [graphData.nodes, positions, viewportSize.height, viewportSize.width]);
const {
transform,
zoom,
transitioning,
zoomIn,
zoomOut,
resetView,
fitToGraph,
onPointerDown,
onPointerMove,
onPointerUp,
onWheelPan,
onWheelZoom,
handleKeyDown,
setGraphBounds,
} = useGraphInteraction();
const bounds = useMemo(() => {
const values = Array.from(positions.values());
if (values.length === 0) {
return { minX: 0, minY: 0, maxX: 0, maxY: 0, width: 0, height: 0 };
}
const minX = Math.min(...values.map((pos) => pos.x));
const minY = Math.min(...values.map((pos) => pos.y));
const maxX = Math.max(...values.map((pos) => pos.x + NODE_WIDTH));
const maxY = Math.max(...Array.from(positions.entries()).map(([taskId, pos]) => pos.y + (measuredHeights.get(taskId) ?? NODE_HEIGHT)));
return {
minX,
minY,
maxX,
maxY,
width: Math.max(0, maxX - minX),
height: Math.max(0, maxY - minY),
};
}, [measuredHeights, positions]);
const normalizedPositions = useMemo(() => {
if (positions.size === 0) return positions;
const next = new Map<string, { x: number; y: number }>();
for (const [taskId, position] of positions.entries()) {
next.set(taskId, { x: position.x - bounds.minX, y: position.y - bounds.minY });
}
return next;
}, [bounds.minX, bounds.minY, positions]);
useEffect(() => {
setGraphBounds({ minX: 0, minY: 0, maxX: bounds.width, maxY: bounds.height });
}, [bounds.height, bounds.width, setGraphBounds]);
/*
FNXC:Graph 2026-06-23-00:10:
The Graph view must load CENTERED/fit in the viewport. This is a custom transform-based
canvas (not React Flow), so "fit" = compute zoom/pan from node positions via fitToGraph.
Two failure modes were producing an off-center / not-fit initial load:
1. Async nodes: tasks (and their computed `positions`) arrive after first paint. The old
guard latched `initialFitDoneRef` on the FIRST run, so it fit an empty/half-laid-out
graph (positions still stale, viewport unmeasured) and never re-fit once real nodes existed.
2. Re-entering the view: this component stays mounted when the user navigates away and back,
so a latched ref meant no re-fit on re-activation.
Fix: only fit once the graph is actually fittable — viewport measured (width/height > 0) AND
positions populated — and re-fit whenever the fitted geometry changes so a fresh or newly
settled node set centers. Guard against fitting an empty graph. User-saved positions preserve
relative node placement, but still fit into the visible viewport so the whole graph cannot load
off screen.
FNXC:Graph 2026-06-22-13:25:
Loading Graph view must always hit fit-to-graph after layout settles, including graphs with saved
manual node positions. The fit signature includes viewport size, bounds, and measured node heights
so a late height/ResizeObserver settle re-fits automatically instead of leaving cards off screen.
FNXC:Graph 2026-06-23-02:45:
Remaining "must click+drag once to recenter" bug was a coordinate-space + ordering race, NOT
just a measurement race:
- `fitToGraph` internally calls `clampPan`, which clamps against `graphBoundsRef` populated by
the `setGraphBounds` effect (immediately above) from NORMALIZED bounds (minX/minY shifted to 0).
On the first paint that ref is still its initial `{0,0,0,0}`, so `clampPan` took its degenerate
branch and clamped pan to ±viewport — visibly off-center. The fit never re-ran when bounds
later committed; only a manual drag re-clamped against correct bounds and snapped it centered.
This effect is now placed AFTER the setGraphBounds effect so, within a render commit, the
bounds ref is updated before the fit runs.
- We were also feeding raw `positions` (possibly non-zero minX/minY) to `fitToGraph` while the
canvas renders `normalizedPositions` (origin at 0,0). Fit must run on the SAME space the DOM
and `graphBoundsRef` use, so we fit on `normalizedPositions`.
Robust fix:
1. Fit on `normalizedPositions` so fit-space == render-space == clamp-bounds-space.
2. Defer the fit one paint via double `requestAnimationFrame` so the just-committed normalized
bounds are guaranteed live in `graphBoundsRef` before `clampPan` runs (rAF is allowed in the
plugin runtime; no eslint/no-restricted-globals rule forbids it here). Falls back to a
synchronous fit when rAF is unavailable (test/SSR), where effects in the same commit have
already set the bounds ref.
3. ResizeObserver (above) still drives the hidden→visible 0→N transition and later resizes; this
effect keys on `viewportSize` so a fit is (re)attempted the moment a real non-zero size lands.
First real paint now centers with zero user input, including navigating INTO the graph view.
*/
// Stable signature of the current fitted geometry; changes when nodes, viewport,
// bounds, or measured heights settle so we re-center after the graph has its
// real box instead of latching an early off-screen layout.
const fitNodeKey = useMemo(
() => Array.from(normalizedPositions.keys()).sort().join("|"),
[normalizedPositions],
);
const fitGeometryKey = useMemo(
() => [
fitNodeKey,
viewportSize.width,
viewportSize.height,
bounds.width,
bounds.height,
Array.from(measuredHeights.entries()).sort(([left], [right]) => left.localeCompare(right)).map(([id, height]) => `${id}:${Math.round(height)}`).join("|"),
].join("::"),
[bounds.height, bounds.width, fitNodeKey, measuredHeights, viewportSize.height, viewportSize.width],
);
const lastFittedGeometryKeyRef = useRef<string | null>(null);
useEffect(() => {
if (filteredTasks.length === 0) return;
if (normalizedPositions.size === 0) return;
const viewport = viewportRef.current;
if (!viewport) return;
// Viewport not yet measured: a 0-sized fit would mis-center. Wait for ResizeObserver.
const viewportWidth = viewport.clientWidth || viewportSize.width;
const viewportHeight = viewport.clientHeight || viewportSize.height;
if (viewportWidth === 0 || viewportHeight === 0) return;
// Re-fit on initial load and whenever layout geometry settles. This includes
// saved/manual positions: saved positions should preserve node placement, not
// allow the entire graph to load off screen.
if (lastFittedGeometryKeyRef.current === fitGeometryKey) return;
// FNXC:Graph 2026-06-23-02:45: defer one paint so the committed normalized bounds are live in
// graphBoundsRef before clampPan (inside fitToGraph) runs — otherwise pan mis-clamps to ±viewport.
let frameOne = 0;
let frameTwo = 0;
const runFit = () => {
const liveViewport = viewportRef.current;
if (!liveViewport) return;
const liveWidth = liveViewport.clientWidth || viewportSize.width;
const liveHeight = liveViewport.clientHeight || viewportSize.height;
if (liveWidth === 0 || liveHeight === 0) return;
fitToGraph(normalizedPositions, liveWidth, liveHeight, {
nodeWidth: NODE_WIDTH,
nodeHeight: NODE_HEIGHT,
measuredHeights,
});
lastFittedGeometryKeyRef.current = fitGeometryKey;
};
if (typeof requestAnimationFrame === "function") {
frameOne = requestAnimationFrame(() => {
frameTwo = requestAnimationFrame(runFit);
});
} else {
// Test/SSR environments without rAF: fit synchronously (bounds effect already ran this commit).
runFit();
}
return () => {
if (frameOne) cancelAnimationFrame(frameOne);
if (frameTwo) cancelAnimationFrame(frameTwo);
};
}, [filteredTasks.length, fitGeometryKey, fitToGraph, measuredHeights, normalizedPositions, viewportSize.height, viewportSize.width]);
const handleResetLayout = useCallback(() => {
clearSavedPositions();
const freshLayout = computeAutoLayout(graphData, layoutOptions);
setPositions(freshLayout);
}, [clearSavedPositions, graphData, layoutOptions]);
const handleNodeDragEnd = useCallback(() => {
const positionRecord: NodePositions = {};
for (const [taskId, position] of positions.entries()) {
positionRecord[taskId] = position;
}
persistPositions(positionRecord);
}, [persistPositions, positions]);
return (
<section className="dependency-graph" data-testid="dependency-graph">
<div
ref={viewportRef}
className="dependency-graph__viewport"
onPointerDown={(event) => {
if (isNodeDragging) return;
pointerDownRef.current = { x: event.clientX, y: event.clientY };
pointerDraggedRef.current = false;
onPointerDown(event.pointerId, { x: event.clientX, y: event.clientY });
}}
onPointerMove={(event) => {
if (isNodeDragging) return;
const viewport = viewportRef.current;
if (!viewport) return;
const pointerDown = pointerDownRef.current;
if (pointerDown) {
const deltaX = Math.abs(event.clientX - pointerDown.x);
const deltaY = Math.abs(event.clientY - pointerDown.y);
if (deltaX > POINTER_MOVE_THRESHOLD || deltaY > POINTER_MOVE_THRESHOLD) {
pointerDraggedRef.current = true;
}
}
onPointerMove(event.pointerId, { x: event.clientX, y: event.clientY }, viewport.clientWidth, viewport.clientHeight);
}}
onPointerUp={(event) => {
if (!isNodeDragging) {
onPointerUp(event.pointerId);
}
pointerDownRef.current = null;
}}
onPointerCancel={(event) => {
if (!isNodeDragging) {
onPointerUp(event.pointerId);
}
pointerDownRef.current = null;
pointerDraggedRef.current = false;
}}
onWheel={(event) => {
event.preventDefault();
const viewport = viewportRef.current;
if (!viewport) return;
if (event.ctrlKey || event.metaKey) {
const rect = viewport.getBoundingClientRect();
onWheelZoom(event.deltaY, { x: event.clientX - rect.left, y: event.clientY - rect.top }, viewport.clientWidth, viewport.clientHeight);
return;
}
onWheelPan(event.deltaX, event.deltaY, viewport.clientWidth, viewport.clientHeight);
}}
onKeyDown={(event) => {
const viewport = viewportRef.current;
if (!viewport) return;
handleKeyDown(event, viewport.clientWidth, viewport.clientHeight, normalizedPositions, {
nodeWidth: NODE_WIDTH,
nodeHeight: NODE_HEIGHT,
measuredHeights,
});
}}
tabIndex={0}
onClick={() => {
if (pointerDraggedRef.current || isNodeDragging) return;
setSelectedTaskId(null);
}}
>
{filteredTasks.length === 0 ? (
<div className="dependency-graph__empty">No active tasks to display in graph view.</div>
) : (
<div className={`graph-canvas-transform${transitioning ? " graph-canvas-transform--animate" : ""}`} style={{ transform, width: `${bounds.width}px`, height: `${bounds.height}px` }}>
<GraphEdges
edges={graphData.edges}
positions={normalizedPositions}
nodeWidth={NODE_WIDTH}
nodeHeight={NODE_HEIGHT}
nodeHeights={measuredHeights}
highlightedEdgeIds={
highlightedTaskIds.size > 0
? new Set(
graphData.edges
.filter((edge) => highlightedTaskIds.has(edge.source) && highlightedTaskIds.has(edge.target))
.map((edge) => `${edge.source}->${edge.target}`),
)
: undefined
}
/>
<div className="dependency-graph__nodes-layer">
{graphData.nodes.map((node) => {
const position = normalizedPositions.get(node.task.id);
if (!position) return null;
return (
<GraphTaskNode
key={node.task.id}
task={node.task}
projectId={projectId}
taskColumnFlags={columnFlagsByTaskId?.get(node.task.id)}
isSelected={selectedTaskId === node.task.id}
style={{ minHeight: `${NODE_HEIGHT}px`, left: `${position.x}px`, top: `${position.y}px` }}
position={position}
scale={zoom}
onNodePositionChange={(taskId, nextPosition) => {
setPositions((current) => {
const denormalizedPosition = { x: nextPosition.x + bounds.minX, y: nextPosition.y + bounds.minY };
const existing = current.get(taskId);
if (existing && existing.x === denormalizedPosition.x && existing.y === denormalizedPosition.y) return current;
const next = new Map(current);
next.set(taskId, denormalizedPosition);
return next;
});
}}
onNodeDragStateChange={setIsNodeDragging}
onNodeDragEnd={handleNodeDragEnd}
isHighlighted={highlightedTaskIds.size > 0 && highlightedTaskIds.has(node.task.id)}
isDimmed={highlightedTaskIds.size > 0 && !highlightedTaskIds.has(node.task.id)}
onOpenDetail={onOpenDetail ?? ((task) => onOpenTaskDetail?.(task.id))}
addToast={addToast ?? (() => {})}
globalPaused={globalPaused}
onUpdateTask={onUpdateTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onDeleteTask={onDeleteTask}
onRetryTask={onRetryTask}
onOpenDetailWithTab={onOpenDetailWithTab}
onOpenMission={onOpenMission}
onMoveTask={onMoveTask}
lastFetchTimeMs={lastFetchTimeMs}
onMouseEnter={() => setHoveredTaskId(node.task.id)}
onMouseLeave={() => setHoveredTaskId(null)}
onClick={(event) => {
event.stopPropagation();
pointerDraggedRef.current = false;
setSelectedTaskId((current) => (current === node.task.id ? null : node.task.id));
}}
/>
);
})}
</div>
</div>
)}
</div>
<GraphToolbar
zoom={zoom}
onZoomIn={() => {
const viewport = viewportRef.current;
if (!viewport) return;
zoomIn(viewport.clientWidth, viewport.clientHeight);
}}
onZoomOut={() => {
const viewport = viewportRef.current;
if (!viewport) return;
zoomOut(viewport.clientWidth, viewport.clientHeight);
}}
onFitToGraph={() => {
handleResetLayout();
const viewport = viewportRef.current;
if (!viewport) return;
const freshLayout = computeAutoLayout(graphData, layoutOptions);
const normalizedFreshLayout = new Map<string, { x: number; y: number }>();
const values = Array.from(freshLayout.values());
const minX = values.length > 0 ? Math.min(...values.map((position) => position.x)) : 0;
const minY = values.length > 0 ? Math.min(...values.map((position) => position.y)) : 0;
for (const [taskId, position] of freshLayout.entries()) {
normalizedFreshLayout.set(taskId, { x: position.x - minX, y: position.y - minY });
}
fitToGraph(normalizedFreshLayout, viewport.clientWidth, viewport.clientHeight, {
nodeWidth: NODE_WIDTH,
nodeHeight: NODE_HEIGHT,
measuredHeights,
});
}}
onResetView={() => {
handleResetLayout();
resetView();
}}
/>
</section>
);
}