feat(FN-3088): add dependency graph highlighting with hover and selection s

The merge adds visual highlighting to the dependency graph plugin. A new `useDependencyChain` hook tracks dependency relationships, while `GraphTaskNode` and `DependencyGraph` gained hover, selection, and highlight visual states backed by new CSS. Tests cover the hook logic, highlighting behavior, a

Fusion-Task-Id: FN-3088
This commit is contained in:
Fusion
2026-05-07 04:41:15 -07:00
committed by gsxdsm
parent f859108779
commit 6f63ee3fcd
9 changed files with 350 additions and 16 deletions

View File

@@ -19,9 +19,19 @@ Plugin-provided top-level **Graph** dashboard view for Fusion.
- **Current-step highlighting**: active nodes set `data-current-step` for valid native step indices so CSS selectors highlight the currently executing `.card-step-item` and pulse its step dot
- **Zoom-out differentiation**: `.graph-task-node--active` adds amplified glow and subtle scale/border tint so active nodes remain distinguishable at reduced zoom levels
- **In-review visual treatment**: `in-review` nodes get a static `.graph-task-node--in-review` left accent in `--in-review` to distinguish waiting-review work from active execution nodes
- **Graph node classes**: `.graph-task-node`, `.graph-task-node--active`, `.graph-task-node--in-review`, `.graph-task-node--highlighted`, and `.graph-task-node--dimmed` are available for graph-specific layering/highlight states while card internals remain owned by `TaskCard.css`
- **Graph node classes**: `.graph-task-node`, `.graph-task-node--active`, `.graph-task-node--in-review`, `.graph-task-node--highlighted`, `.graph-task-node--dimmed`, `.graph-node--highlighted`, and `.graph-node--dimmed` are available for graph-specific layering/highlight states while card internals remain owned by `TaskCard.css`
- **Graph edge classes**: `.graph-edge--highlighted` and `.graph-edge--dimmed` are applied during dependency-chain emphasis states
- **Drag behavior**: graph nodes pass `disableDrag={true}` to `TaskCard` so card-level HTML5 drag does not conflict with canvas pan/zoom
## Dependency chain highlighting
- **Hover** a node to highlight the full transitive upstream + downstream chain for that task.
- **Click** a node to persist selection highlighting until the same node is clicked again or the canvas pane is clicked.
- **Priority**: hover state overrides selected state; when hover leaves, selected highlighting reappears.
- **Dimming**: when a chain is active, unrelated nodes and edges are dimmed.
- **Neutral state**: when nothing is hovered/selected, no highlight/dim classes are applied.
- **Edge rule**: an edge is highlighted only when both its source and target nodes are in the active chain.
## Controls
### Toolbar (bottom-right)

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { Task } from "@fusion/core";
import { GraphTaskNode } from "./GraphTaskNode";
import { GraphToolbar } from "./GraphToolbar";
@@ -7,6 +7,7 @@ import { filterGraphTasks } from "./filters";
import { computeAutoLayout } from "./layout";
import { useGraphData } from "./useGraphData";
import { useGraphInteraction } from "./useGraphInteraction";
import { useDependencyChain } from "./hooks/useDependencyChain";
import "./DependencyGraph.css";
const NODE_WIDTH = 280;
@@ -40,6 +41,8 @@ export interface DependencyGraphProps {
workflowStepNameLookup?: ReadonlyMap<string, string>;
}
const POINTER_MOVE_THRESHOLD = 4;
export function DependencyGraph({
tasks,
projectId,
@@ -61,8 +64,16 @@ export function DependencyGraph({
}: DependencyGraphProps) {
const viewportRef = useRef<HTMLDivElement | null>(null);
const initialFitDoneRef = useRef(false);
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
const pointerDraggedRef = useRef(false);
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
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 positions = useMemo(
() => computeAutoLayout(graphData, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT, horizontalGap: 40, verticalGap: 80 }),
[graphData],
@@ -113,14 +124,33 @@ export function DependencyGraph({
<div
ref={viewportRef}
className="dependency-graph__viewport"
onPointerDown={(event) => onPointerDown(event.pointerId, { x: event.clientX, y: event.clientY })}
onPointerDown={(event) => {
pointerDownRef.current = { x: event.clientX, y: event.clientY };
pointerDraggedRef.current = false;
onPointerDown(event.pointerId, { x: event.clientX, y: event.clientY });
}}
onPointerMove={(event) => {
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) => onPointerUp(event.pointerId)}
onPointerCancel={(event) => onPointerUp(event.pointerId)}
onPointerUp={(event) => {
onPointerUp(event.pointerId);
pointerDownRef.current = null;
}}
onPointerCancel={(event) => {
onPointerUp(event.pointerId);
pointerDownRef.current = null;
pointerDraggedRef.current = false;
}}
onWheel={(event) => {
event.preventDefault();
const viewport = viewportRef.current;
@@ -135,12 +165,30 @@ export function DependencyGraph({
}}
tabIndex={0}
style={{ outline: "none" }}
onClick={() => {
if (pointerDraggedRef.current) 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={positions} nodeWidth={NODE_WIDTH} nodeHeight={NODE_HEIGHT} />
<GraphEdges
edges={graphData.edges}
positions={positions}
nodeWidth={NODE_WIDTH}
nodeHeight={NODE_HEIGHT}
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 = positions.get(node.task.id);
@@ -152,6 +200,8 @@ export function DependencyGraph({
task={node.task}
projectId={projectId}
style={{ minHeight: `${NODE_HEIGHT}px`, left: `${position.x}px`, top: `${position.y}px` }}
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}
@@ -166,6 +216,13 @@ export function DependencyGraph({
onMoveTask={onMoveTask}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
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));
}}
/>
);
})}

View File

@@ -0,0 +1,27 @@
.graph-node--highlighted {
opacity: 1;
box-shadow: var(--shadow-glow);
}
.graph-node--dimmed {
opacity: 0.25;
transition: opacity var(--transition-fast);
}
.graph-edge--highlighted {
opacity: 1;
stroke: var(--todo);
stroke-width: var(--space-xs);
}
.graph-edge--dimmed {
opacity: 0.15;
transition: opacity var(--transition-fast);
}
@media (max-width: 768px) {
.graph-node--dimmed,
.graph-edge--dimmed {
transition: opacity var(--transition-fast);
}
}

View File

@@ -1,7 +1,8 @@
import type { CSSProperties, ComponentProps } from "react";
import type { CSSProperties, ComponentProps, HTMLAttributes } from "react";
import { TaskCard } from "@fusion/dashboard/app/components/TaskCard";
import { isTaskStuck } from "@fusion/dashboard/app/utils/taskStuck";
import "./GraphTaskNode.css";
import "./GraphHighlight.css";
type TaskCardComponentProps = ComponentProps<typeof TaskCard>;
@@ -25,9 +26,10 @@ type TaskCardBridgeProps = Pick<
| "workflowStepNameLookup"
>;
export interface GraphTaskNodeProps extends TaskCardBridgeProps {
export interface GraphTaskNodeProps extends TaskCardBridgeProps, Pick<HTMLAttributes<HTMLDivElement>, "onMouseEnter" | "onMouseLeave" | "onClick"> {
style?: CSSProperties;
isHighlighted?: boolean;
isDimmed?: boolean;
}
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]);
@@ -40,7 +42,15 @@ function getStatusLabel(status?: string): string {
return status.charAt(0).toUpperCase() + status.slice(1);
}
export function GraphTaskNode({ style, isHighlighted = false, ...taskCardProps }: GraphTaskNodeProps) {
export function GraphTaskNode({
style,
isHighlighted = false,
isDimmed = false,
onMouseEnter,
onMouseLeave,
onClick,
...taskCardProps
}: GraphTaskNodeProps) {
const { task, globalPaused, taskStuckTimeoutMs, lastFetchTimeMs } = taskCardProps;
const isFailed = task.status === "failed";
const isPaused = task.paused === true;
@@ -63,11 +73,14 @@ export function GraphTaskNode({ style, isHighlighted = false, ...taskCardProps }
return (
<div
className={`graph-task-node${isHighlighted ? " graph-task-node--highlighted" : ""}${isActive ? " graph-task-node--active" : ""}${isInReview ? " graph-task-node--in-review" : ""}`}
className={`graph-task-node${isHighlighted ? " graph-task-node--highlighted graph-node--highlighted" : ""}${isDimmed ? " graph-task-node--dimmed graph-node--dimmed" : ""}${isActive ? " graph-task-node--active" : ""}${isInReview ? " graph-task-node--in-review" : ""}`}
style={style}
draggable={false}
data-testid={`graph-task-node-${task.id}`}
data-current-step={isActive && hasValidCurrentStep ? String(task.currentStep) : undefined}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={onClick}
>
{isActive ? (
<div className="graph-task-active-indicator">

View File

@@ -0,0 +1,111 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { Task } from "@fusion/core";
import { DependencyGraph } from "../DependencyGraph";
vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
TaskCard: ({ task, onOpenDetail }: { task: Task; onOpenDetail: (task: Task) => void }) => (
<button data-testid={`task-${task.id}`} onClick={() => onOpenDetail(task)}>{task.id}</button>
),
}));
vi.mock("../useGraphInteraction", () => ({
useGraphInteraction: () => ({
transform: "translate(0px, 0px) scale(1)",
zoom: 1,
transitioning: false,
zoomIn: vi.fn(),
zoomOut: vi.fn(),
resetView: vi.fn(),
fitToGraph: vi.fn(),
onPointerDown: vi.fn(),
onPointerMove: vi.fn(),
onPointerUp: vi.fn(),
onWheelZoom: vi.fn(),
handleKeyDown: vi.fn(),
}),
}));
function createTask(id: string, dependencies: string[] = []): Task {
return {
id,
description: id,
column: "todo",
dependencies,
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as Task;
}
afterEach(() => {
cleanup();
});
describe("DependencyGraph highlighting", () => {
const tasks = [createTask("A"), createTask("B", ["A"]), createTask("C", ["B"]), createTask("D")];
it("highlights chain on hover and returns to neutral on mouse leave", () => {
render(<DependencyGraph tasks={tasks} onOpenDetail={vi.fn()} />);
fireEvent.mouseEnter(screen.getByTestId("graph-task-node-C"));
expect(screen.getByTestId("graph-task-node-A").className).toContain("graph-task-node--highlighted");
expect(screen.getByTestId("graph-task-node-B").className).toContain("graph-task-node--highlighted");
expect(screen.getByTestId("graph-task-node-C").className).toContain("graph-task-node--highlighted");
expect(screen.getByTestId("graph-task-node-D").className).toContain("graph-task-node--dimmed");
fireEvent.mouseLeave(screen.getByTestId("graph-task-node-C"));
expect(screen.getByTestId("graph-task-node-A").className).not.toContain("graph-task-node--highlighted");
expect(screen.getByTestId("graph-task-node-D").className).not.toContain("graph-task-node--dimmed");
});
it("keeps selection until toggled or pane clicked", () => {
render(<DependencyGraph tasks={tasks} onOpenDetail={vi.fn()} />);
fireEvent.click(screen.getByTestId("graph-task-node-B"));
expect(screen.getByTestId("graph-task-node-A").className).toContain("graph-task-node--highlighted");
fireEvent.click(screen.getByTestId("graph-task-node-B"));
expect(screen.getByTestId("graph-task-node-A").className).not.toContain("graph-task-node--highlighted");
fireEvent.click(screen.getByTestId("graph-task-node-B"));
fireEvent.click(document.querySelector(".dependency-graph__viewport") as Element);
expect(screen.getByTestId("graph-task-node-B").className).not.toContain("graph-task-node--highlighted");
});
it("hover overrides selection and reverts when hover leaves", () => {
render(<DependencyGraph tasks={tasks} onOpenDetail={vi.fn()} />);
fireEvent.click(screen.getByTestId("graph-task-node-B"));
fireEvent.mouseEnter(screen.getByTestId("graph-task-node-D"));
expect(screen.getByTestId("graph-task-node-D").className).toContain("graph-task-node--highlighted");
expect(screen.getByTestId("graph-task-node-A").className).toContain("graph-task-node--dimmed");
fireEvent.mouseLeave(screen.getByTestId("graph-task-node-D"));
expect(screen.getByTestId("graph-task-node-A").className).toContain("graph-task-node--highlighted");
});
it("applies edge dimming/highlighting and preserves click-to-detail", () => {
const onOpenDetail = vi.fn();
render(<DependencyGraph tasks={tasks} onOpenDetail={onOpenDetail} />);
fireEvent.mouseEnter(screen.getByTestId("graph-task-node-C"));
const edges = screen.getAllByTestId("dependency-edge");
const edgeAB = edges.find((edge) => edge.getAttribute("data-edge-id") === "B->A");
const edgeCB = edges.find((edge) => edge.getAttribute("data-edge-id") === "C->B");
expect(edgeAB?.className.baseVal || edgeAB?.className).toContain("graph-edge--highlighted");
expect(edgeCB?.className.baseVal || edgeCB?.className).toContain("graph-edge--highlighted");
fireEvent.click(screen.getByTestId("task-C"));
expect(onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "C" }));
});
it("highlights only isolated node with no dependencies", () => {
render(<DependencyGraph tasks={[createTask("X")]} onOpenDetail={vi.fn()} />);
fireEvent.mouseEnter(screen.getByTestId("graph-task-node-X"));
expect(screen.getByTestId("graph-task-node-X").className).toContain("graph-task-node--highlighted");
});
});

View File

@@ -68,6 +68,8 @@ describe("GraphEdges", () => {
const dimmed = all.find((edge) => edge.getAttribute("data-edge-id") === "A->C");
expect(highlighted?.getAttribute("opacity")).toBe("1");
expect(dimmed?.getAttribute("opacity")).toBe("0.2");
expect(highlighted?.getAttribute("class") ?? "").toContain("graph-edge--highlighted");
expect(dimmed?.getAttribute("opacity")).toBe("0.15");
expect(dimmed?.getAttribute("class") ?? "").toContain("graph-edge--dimmed");
});
});

View File

@@ -1,4 +1,5 @@
import type { GraphEdge, GraphPosition } from "./types";
import "./GraphHighlight.css";
interface GraphEdgesProps {
edges: GraphEdge[];
@@ -53,14 +54,14 @@ export function GraphEdges({
key={edgeId}
data-testid="dependency-edge"
data-edge-id={edgeId}
className={`dependency-graph-edge${isActiveHighlight ? " is-related" : ""}${hasHighlights && !isActiveHighlight ? " is-dimmed" : ""}`}
className={`dependency-graph-edge${isActiveHighlight ? " graph-edge--highlighted" : ""}${hasHighlights && !isActiveHighlight ? " graph-edge--dimmed" : ""}`}
d={`M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`}
fill="none"
stroke={isActiveHighlight ? "var(--text-muted)" : "var(--border)"}
strokeWidth="1"
opacity={hasHighlights && !isActiveHighlight ? 0.2 : 1}
stroke={isActiveHighlight ? "var(--todo)" : "var(--border)"}
strokeWidth={isActiveHighlight ? "var(--space-xs)" : "var(--btn-border-width)"}
opacity={hasHighlights && !isActiveHighlight ? 0.15 : 1}
markerEnd="url(#dependency-graph-arrowhead)"
style={{ transition: "opacity var(--transition-fast), stroke var(--transition-fast)" }}
style={{ transition: "opacity var(--transition-fast), stroke var(--transition-fast), stroke-width var(--transition-fast)" }}
/>
);
})}

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { renderHook } from "@testing-library/react";
import type { Task } from "@fusion/core";
import { useDependencyChain } from "../useDependencyChain";
function createTask(id: string, dependencies: string[] = []): Task {
return {
id,
description: id,
column: "todo",
dependencies,
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as Task;
}
describe("useDependencyChain", () => {
it("returns empty set for unknown task in empty list", () => {
const { result } = renderHook(() => useDependencyChain([]));
expect(result.current.getChain("A").size).toBe(0);
});
it("returns single task when no dependencies", () => {
const { result } = renderHook(() => useDependencyChain([createTask("A")]));
expect(result.current.getChain("A")).toEqual(new Set(["A"]));
});
it("returns full linear chain", () => {
const tasks = [createTask("A"), createTask("B", ["A"]), createTask("C", ["B"]), createTask("D")];
const { result } = renderHook(() => useDependencyChain(tasks));
expect(result.current.getChain("C")).toEqual(new Set(["A", "B", "C"]));
});
it("returns full diamond chain", () => {
const tasks = [createTask("A"), createTask("B", ["A"]), createTask("C", ["A"]), createTask("D", ["B", "C"]), createTask("E")];
const { result } = renderHook(() => useDependencyChain(tasks));
expect(result.current.getChain("D")).toEqual(new Set(["A", "B", "C", "D"]));
});
it("does not include disconnected tasks", () => {
const { result } = renderHook(() => useDependencyChain([createTask("A"), createTask("B")]));
expect(result.current.getChain("A")).toEqual(new Set(["A"]));
});
it("handles circular dependencies safely", () => {
const tasks = [createTask("A", ["B"]), createTask("B", ["A"]), createTask("C")];
const { result } = renderHook(() => useDependencyChain(tasks));
expect(result.current.getChain("A")).toEqual(new Set(["A", "B"]));
});
});

View File

@@ -0,0 +1,60 @@
import { useCallback, useMemo } from "react";
import type { Task } from "@fusion/core";
export function useDependencyChain(tasks: Task[]) {
const { upstreamMap, downstreamMap } = useMemo(() => {
const upstream = new Map<string, Set<string>>();
const downstream = new Map<string, Set<string>>();
for (const task of tasks) {
upstream.set(task.id, new Set(task.dependencies ?? []));
if (!downstream.has(task.id)) downstream.set(task.id, new Set());
}
for (const task of tasks) {
for (const dependencyId of task.dependencies ?? []) {
if (!downstream.has(dependencyId)) downstream.set(dependencyId, new Set());
downstream.get(dependencyId)?.add(task.id);
}
}
return { upstreamMap: upstream, downstreamMap: downstream };
}, [tasks]);
const getChain = useCallback(
(taskId: string): Set<string> => {
if (!upstreamMap.has(taskId) && !downstreamMap.has(taskId)) {
return new Set();
}
const chain = new Set<string>([taskId]);
const visit = (origin: string, adjacency: Map<string, Set<string>>) => {
const queue = [origin];
const visited = new Set<string>([origin]);
while (queue.length > 0) {
const current = queue.shift();
if (!current) continue;
const neighbors = adjacency.get(current);
if (!neighbors) continue;
for (const neighbor of neighbors) {
if (visited.has(neighbor)) continue;
visited.add(neighbor);
chain.add(neighbor);
queue.push(neighbor);
}
}
};
visit(taskId, upstreamMap);
visit(taskId, downstreamMap);
return chain;
},
[downstreamMap, upstreamMap],
);
return { getChain };
}