feat(FN-3307): add pinch-to-zoom, auto-fit, mobile layout, and empty state

Merges five branches: adds pinch-to-zoom, mobile layout, auto-fit, and empty state to the dependency graph plugin with full test coverage; implements API key fallback resolution for models.json; restores Claude usage tracking with Fusion Anthropic auth storage; and boosts dashboard test performance

Fusion-Task-Id: FN-3307
This commit is contained in:
Fusion
2026-05-03 11:33:58 -07:00
committed by gsxdsm
parent 7149630dca
commit cb86966f50
7 changed files with 507 additions and 52 deletions

View File

@@ -23,6 +23,7 @@
background: var(--surface);
min-height: var(--dependency-graph-canvas-min-height);
cursor: grab;
touch-action: none;
}
.dependency-graph-canvas:active {
@@ -86,16 +87,46 @@
filter: saturate(0.8);
}
.dependency-graph-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: var(--dependency-graph-canvas-min-height);
padding: var(--space-xl);
color: var(--text-muted);
text-align: center;
}
@media (max-width: 768px) {
.dependency-graph-view {
padding: var(--space-md);
}
.dependency-graph-controls {
position: sticky;
top: 0;
z-index: 1;
background: var(--bg);
padding: var(--space-sm) 0;
}
.dependency-graph-controls .btn {
min-height: 44px;
min-width: 44px;
}
.dependency-graph-canvas {
flex: 1;
min-height: 0;
min-height: var(--dependency-graph-canvas-min-height-mobile);
}
.dependency-graph-node {
width: min(100%, var(--dependency-graph-node-max-width-mobile)) !important;
}
.dependency-graph-empty {
min-height: var(--dependency-graph-canvas-min-height-mobile);
}
}

View File

@@ -1,5 +1,5 @@
import { useMemo, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent, ReactNode, WheelEvent as ReactWheelEvent } from "react";
import type { Task } from "@fusion/core";
import { loadPositions, savePositions } from "./storage";
import "./DependencyGraphView.css";
@@ -14,6 +14,8 @@ const SCENE_PADDING_REM = 2;
const FIT_PADDING_REM = 2;
const MIN_SCALE = 0.4;
const MAX_SCALE = 2;
const WHEEL_ZOOM_FACTOR = 0.002;
const MOBILE_BREAKPOINT = 768;
export interface DependencyGraphHostContext {
projectId?: string;
@@ -29,9 +31,12 @@ export interface PluginDashboardViewComponentProps {
type Position = { x: number; y: number };
function getDistance(a: Position, b: Position): number {
const deltaX = a.x - b.x;
const deltaY = a.y - b.y;
return Math.hypot(deltaX, deltaY);
return Math.hypot(a.x - b.x, a.y - b.y);
}
function isMobileViewport(): boolean {
if (typeof window === "undefined") return false;
return window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches;
}
export function DependencyGraphView({ context }: PluginDashboardViewComponentProps) {
@@ -42,11 +47,16 @@ export function DependencyGraphView({ context }: PluginDashboardViewComponentPro
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
const persisted = useMemo(() => loadPositions(context.projectId), [context.projectId]);
const canvasRef = useRef<HTMLDivElement | null>(null);
// Multi-pointer tracking (no setPointerCapture — it breaks two-finger gestures)
const pointersRef = useRef<Map<number, Position>>(new Map());
const interactionRef = useRef<
| { kind: "node"; taskId: string; startPointer: Position; startNode: Position; moved: boolean }
| { kind: "pan"; startPointer: Position; startPan: Position; moved: boolean }
| null
>(null);
const pinchRef = useRef<{ startDistance: number; startScale: number } | null>(null);
const autoFitDoneRef = useRef(false);
const tasks = useMemo(
() => context.tasks.filter((task) => ACTIVE_COLUMNS.has(task.column)),
@@ -162,7 +172,7 @@ export function DependencyGraphView({ context }: PluginDashboardViewComponentPro
return related;
}, [dependencyGraph.downstream, dependencyGraph.upstream, focusTaskId]);
const fitToGraph = () => {
const fitToGraph = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
@@ -179,7 +189,26 @@ export function DependencyGraphView({ context }: PluginDashboardViewComponentPro
setScale(nextScale);
setPan({ x: centeredPanX, y: centeredPanY });
};
}, [bounds.width, bounds.height]);
// Auto-fit on initial mobile load
useEffect(() => {
if (autoFitDoneRef.current) return;
if (!isMobileViewport()) return;
if (positioned.length === 0) return;
const canvas = canvasRef.current;
if (!canvas) return;
// Ensure the canvas has non-zero dimensions before fitting
if (canvas.clientWidth === 0 || canvas.clientHeight === 0) return;
autoFitDoneRef.current = true;
// Use rAF to ensure layout is settled
requestAnimationFrame(() => {
fitToGraph();
});
}, [fitToGraph, positioned.length]);
const persistPosition = (taskId: string, next: Position) => {
setNodeOverrides((current) => ({ ...current, [taskId]: next }));
@@ -193,6 +222,10 @@ export function DependencyGraphView({ context }: PluginDashboardViewComponentPro
event.stopPropagation();
const hit = map.get(taskId);
if (!hit) return;
// Track this pointer in the global map
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
interactionRef.current = {
kind: "node",
taskId,
@@ -200,21 +233,48 @@ export function DependencyGraphView({ context }: PluginDashboardViewComponentPro
startNode: { x: hit.x, y: hit.y },
moved: false,
};
event.currentTarget.setPointerCapture(event.pointerId);
};
const handleCanvasPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
// Track this pointer
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
// If we already have another pointer, this is the start of a pinch gesture
if (pointersRef.current.size === 2) {
// Cancel any ongoing pan interaction
interactionRef.current = null;
const [p1, p2] = Array.from(pointersRef.current.values());
const distance = getDistance(p1, p2);
pinchRef.current = { startDistance: distance, startScale: scale };
return;
}
interactionRef.current = {
kind: "pan",
startPointer: { x: event.clientX, y: event.clientY },
startPan: pan,
moved: false,
};
event.currentTarget.setPointerCapture(event.pointerId);
};
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
// Update tracked pointer position
if (pointersRef.current.has(event.pointerId)) {
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
}
// Handle pinch gesture when two pointers are active
if (pointersRef.current.size >= 2 && pinchRef.current) {
const [p1, p2] = Array.from(pointersRef.current.values());
const currentDistance = getDistance(p1, p2);
const scaleFactor = currentDistance / pinchRef.current.startDistance;
const newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, pinchRef.current.startScale * scaleFactor));
setScale(newScale);
return;
}
const current = interactionRef.current;
if (!current) return;
@@ -241,6 +301,16 @@ export function DependencyGraphView({ context }: PluginDashboardViewComponentPro
};
const handlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
pointersRef.current.delete(event.pointerId);
// If we had a pinch and one finger remains, end pinch mode
if (pinchRef.current) {
if (pointersRef.current.size < 2) {
pinchRef.current = null;
}
return;
}
const current = interactionRef.current;
interactionRef.current = null;
if (!current) return;
@@ -256,8 +326,38 @@ export function DependencyGraphView({ context }: PluginDashboardViewComponentPro
persistPosition(current.taskId, { x: hit.x, y: hit.y });
}
};
event.currentTarget.releasePointerCapture(event.pointerId);
const handlePointerCancel = (event: ReactPointerEvent<HTMLDivElement>) => {
pointersRef.current.delete(event.pointerId);
pinchRef.current = null;
interactionRef.current = null;
};
const handleWheel = (event: ReactWheelEvent<HTMLDivElement>) => {
event.preventDefault();
const canvas = canvasRef.current;
if (!canvas) return;
const rootFontSize = Number.parseFloat(globalThis.getComputedStyle(document.documentElement).fontSize) || 16;
const delta = -event.deltaY * WHEEL_ZOOM_FACTOR;
const newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale * (1 + delta)));
// Zoom toward the pointer position
const rect = canvas.getBoundingClientRect();
const pointerX = event.clientX - rect.left;
const pointerY = event.clientY - rect.top;
// How much the point under the cursor should shift in rem
const scaleRatio = newScale / scale;
const panOffsetX = (pointerX / rootFontSize) * (1 - scaleRatio) / scale;
const panOffsetY = (pointerY / rootFontSize) * (1 - scaleRatio) / scale;
setScale(newScale);
setPan((prev) => ({
x: prev.x + panOffsetX * newScale / scale,
y: prev.y + panOffsetY * newScale / scale,
}));
};
return (
@@ -274,46 +374,54 @@ export function DependencyGraphView({ context }: PluginDashboardViewComponentPro
onPointerDown={handleCanvasPointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
onWheel={handleWheel}
>
<div
className="dependency-graph-scene"
style={{
width: `${bounds.width}rem`,
height: `${bounds.height}rem`,
transform: `translate(${pan.x}rem, ${pan.y}rem) scale(${scale})`,
transformOrigin: "top left",
}}
>
<svg className="dependency-graph-edges" viewBox={`0 0 ${bounds.width} ${bounds.height}`}>
{edgesForRender.map((edge) => (
<line
key={`${edge.from}-${edge.to}`}
x1={edge.renderX1}
y1={edge.renderY1}
x2={edge.renderX2}
y2={edge.renderY2}
className={`dependency-graph-edge${relatedTaskIds ? relatedTaskIds.has(edge.from) && relatedTaskIds.has(edge.to) ? " is-related" : " is-dimmed" : ""}`}
/>
))}
</svg>
{tasks.length === 0 ? (
<div className="dependency-graph-empty">
<p>No tasks to display. Tasks in Triage, Todo, In Progress, or In Review columns will appear here.</p>
</div>
) : (
<div
className="dependency-graph-scene"
style={{
width: `${bounds.width}rem`,
height: `${bounds.height}rem`,
transform: `translate(${pan.x}rem, ${pan.y}rem) scale(${scale})`,
transformOrigin: "top left",
}}
>
<svg className="dependency-graph-edges" viewBox={`0 0 ${bounds.width} ${bounds.height}`}>
{edgesForRender.map((edge) => (
<line
key={`${edge.from}-${edge.to}`}
x1={edge.renderX1}
y1={edge.renderY1}
x2={edge.renderX2}
y2={edge.renderY2}
className={`dependency-graph-edge${relatedTaskIds ? relatedTaskIds.has(edge.from) && relatedTaskIds.has(edge.to) ? " is-related" : " is-dimmed" : ""}`}
/>
))}
</svg>
{positionedForRender.map((node) => (
<div
key={node.task.id}
className={`dependency-graph-node${selectedTaskId === node.task.id ? " is-selected" : ""}${relatedTaskIds ? relatedTaskIds.has(node.task.id) ? " is-related" : " is-dimmed" : ""}`}
style={{
width: `${NODE_WIDTH_REM}rem`,
minHeight: `${NODE_HEIGHT_REM}rem`,
transform: `translate(${node.renderX}rem, ${node.renderY}rem)`,
}}
onPointerDown={(event) => handlePointerDownOnNode(node.task.id, event)}
onPointerEnter={() => setHoveredTaskId(node.task.id)}
onPointerLeave={() => setHoveredTaskId((current) => (current === node.task.id ? null : current))}
>
{context.renderTaskCard(node.task)}
</div>
))}
</div>
{positionedForRender.map((node) => (
<div
key={node.task.id}
className={`dependency-graph-node${selectedTaskId === node.task.id ? " is-selected" : ""}${relatedTaskIds ? relatedTaskIds.has(node.task.id) ? " is-related" : " is-dimmed" : ""}`}
style={{
width: `${NODE_WIDTH_REM}rem`,
minHeight: `${NODE_HEIGHT_REM}rem`,
transform: `translate(${node.renderX}rem, ${node.renderY}rem)`,
}}
onPointerDown={(event) => handlePointerDownOnNode(node.task.id, event)}
onPointerEnter={() => setHoveredTaskId(node.task.id)}
onPointerLeave={() => setHoveredTaskId((current) => (current === node.task.id ? null : current))}
>
{context.renderTaskCard(node.task)}
</div>
))}
</div>
)}
</div>
</section>
);

View File

@@ -0,0 +1,261 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { render, fireEvent, screen, act, cleanup } from "@testing-library/react";
import * as React from "react";
import { DependencyGraphView } from "../DependencyGraphView";
import type { DependencyGraphHostContext, PluginDashboardViewComponentProps } from "../DependencyGraphView";
import type { Task } from "@fusion/core";
// Mock storage to avoid localStorage dependency
vi.mock("../storage", () => ({
loadPositions: () => ({}),
savePositions: () => {},
}));
// Helper to create a minimal Task
function createTask(overrides: Partial<Task> & { id: string; column: Task["column"] }): Task {
return {
description: overrides.description ?? `Task ${overrides.id}`,
column: overrides.column,
dependencies: overrides.dependencies ?? [],
steps: overrides.steps ?? [],
currentStep: overrides.currentStep ?? 0,
log: overrides.log ?? [],
...overrides,
} as Task;
}
function createMockContext(tasks: Task[] = []): DependencyGraphHostContext {
return {
projectId: "test-project",
tasks,
openTaskDetail: vi.fn(),
renderTaskCard: (task: Task) => React.createElement("div", { "data-testid": "task-card" }, task.id),
};
}
function renderView(context?: DependencyGraphHostContext) {
const props: PluginDashboardViewComponentProps = {
context: context ?? createMockContext(),
};
return render(React.createElement(DependencyGraphView, props));
}
describe("DependencyGraphView", () => {
beforeEach(() => {
// Mock getComputedStyle for rem calculations
vi.spyOn(window, "getComputedStyle").mockImplementation((() => {
return { fontSize: "16px" } as CSSStyleDeclaration;
}) as typeof window.getComputedStyle);
// Mock matchMedia to default to desktop
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it("renders empty state when no active-column tasks provided", () => {
const { container } = renderView(createMockContext([]));
const empty = container.querySelector(".dependency-graph-empty");
expect(empty).toBeTruthy();
expect(empty?.textContent).toContain("No tasks to display");
});
it("renders nodes for active-column tasks", () => {
const tasks = [
createTask({ id: "FN-001", column: "todo" }),
createTask({ id: "FN-002", column: "in-progress" }),
];
const { container } = renderView(createMockContext(tasks));
const nodes = container.querySelectorAll(".dependency-graph-node");
expect(nodes.length).toBe(2);
});
it("filters out tasks not in active columns", () => {
const tasks = [
createTask({ id: "FN-001", column: "todo" }),
createTask({ id: "FN-002", column: "done" }),
createTask({ id: "FN-003", column: "archived" }),
];
const { container } = renderView(createMockContext(tasks));
const nodes = container.querySelectorAll(".dependency-graph-node");
expect(nodes.length).toBe(1);
});
it("renders edges for tasks with dependencies in the active set", () => {
const tasks = [
createTask({ id: "FN-001", column: "todo" }),
createTask({ id: "FN-002", column: "todo", dependencies: ["FN-001"] }),
];
const { container } = renderView(createMockContext(tasks));
const edges = container.querySelectorAll(".dependency-graph-edge");
expect(edges.length).toBe(1);
});
it("does not render edges for dependencies not in the active set", () => {
const tasks = [
createTask({ id: "FN-002", column: "todo", dependencies: ["FN-999"] }),
];
const { container } = renderView(createMockContext(tasks));
const edges = container.querySelectorAll(".dependency-graph-edge");
expect(edges.length).toBe(0);
});
it("zoom-in button increases scale", () => {
const tasks = [createTask({ id: "FN-001", column: "todo" })];
const { container, unmount } = renderView(createMockContext(tasks));
const zoomInBtn = Array.from(container.querySelectorAll("button")).find((b) => b.textContent === "Zoom In")!;
fireEvent.click(zoomInBtn);
const scene = container.querySelector(".dependency-graph-scene") as HTMLElement;
expect(scene).toBeTruthy();
const transform = scene.style.transform;
expect(transform).toMatch(/scale\([1-9]/);
});
it("zoom-out button decreases scale", () => {
const tasks = [createTask({ id: "FN-001", column: "todo" })];
const { container, unmount } = renderView(createMockContext(tasks));
const zoomOutBtn = Array.from(container.querySelectorAll("button")).find((b) => b.textContent === "Zoom Out")!;
fireEvent.click(zoomOutBtn);
const scene = container.querySelector(".dependency-graph-scene") as HTMLElement;
expect(scene).toBeTruthy();
const transform = scene.style.transform;
expect(transform).toMatch(/scale\(0\./);
});
it("fit-to-graph button produces a valid scale", () => {
const tasks = [createTask({ id: "FN-001", column: "todo" })];
const { container, unmount } = renderView(createMockContext(tasks));
// Mock canvas dimensions for fitToGraph
const canvas = container.querySelector(".dependency-graph-canvas") as HTMLElement;
if (canvas) {
Object.defineProperty(canvas, "clientWidth", { value: 800, configurable: true });
Object.defineProperty(canvas, "clientHeight", { value: 600, configurable: true });
}
const fitBtn = Array.from(container.querySelectorAll("button")).find((b) => b.textContent === "Fit")!;
fireEvent.click(fitBtn);
const scene = container.querySelector(".dependency-graph-scene") as HTMLElement;
expect(scene).toBeTruthy();
const transform = scene.style.transform;
const scaleMatch = transform.match(/scale\(([\d.]+)\)/);
expect(scaleMatch).toBeTruthy();
const scaleValue = parseFloat(scaleMatch![1]);
expect(scaleValue).toBeGreaterThanOrEqual(0.4);
expect(scaleValue).toBeLessThanOrEqual(2);
});
it("wheel event zooms the graph", () => {
const tasks = [createTask({ id: "FN-001", column: "todo" })];
const { container } = renderView(createMockContext(tasks));
const canvas = container.querySelector(".dependency-graph-canvas") as HTMLElement;
expect(canvas).toBeTruthy();
// Mock getBoundingClientRect on the canvas
canvas.getBoundingClientRect = vi.fn().mockReturnValue({
left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600, x: 0, y: 0,
});
const initialScene = container.querySelector(".dependency-graph-scene") as HTMLElement;
const initialTransform = initialScene.style.transform;
// Negative deltaY = zoom in
fireEvent.wheel(canvas, { deltaY: -100, clientX: 400, clientY: 300 });
const updatedScene = container.querySelector(".dependency-graph-scene") as HTMLElement;
const updatedTransform = updatedScene.style.transform;
expect(updatedTransform).not.toBe(initialTransform);
const scaleMatch = updatedTransform.match(/scale\(([\d.]+)\)/);
expect(scaleMatch).toBeTruthy();
expect(parseFloat(scaleMatch![1])).toBeGreaterThan(1);
});
it("pinch gesture changes scale", () => {
const tasks = [createTask({ id: "FN-001", column: "todo" })];
const { container } = renderView(createMockContext(tasks));
const canvas = container.querySelector(".dependency-graph-canvas") as HTMLElement;
expect(canvas).toBeTruthy();
// First pointer down (finger 1)
fireEvent.pointerDown(canvas, {
pointerId: 1,
pointerType: "touch",
button: 0,
clientX: 200,
clientY: 300,
});
// Second pointer down (finger 2) — starts pinch
fireEvent.pointerDown(canvas, {
pointerId: 2,
pointerType: "touch",
button: 0,
clientX: 600,
clientY: 300,
});
// Move fingers apart (increasing distance)
fireEvent.pointerMove(canvas, {
pointerId: 1,
pointerType: "touch",
clientX: 100,
clientY: 300,
});
fireEvent.pointerMove(canvas, {
pointerId: 2,
pointerType: "touch",
clientX: 700,
clientY: 300,
});
const scene = container.querySelector(".dependency-graph-scene") as HTMLElement;
const transform = scene.style.transform;
const scaleMatch = transform.match(/scale\(([\d.]+)\)/);
expect(scaleMatch).toBeTruthy();
expect(parseFloat(scaleMatch![1])).toBeGreaterThan(1);
});
it("scale is clamped between MIN_SCALE and MAX_SCALE", () => {
const tasks = [createTask({ id: "FN-001", column: "todo" })];
const { container, unmount } = renderView(createMockContext(tasks));
// Try to zoom in beyond MAX_SCALE (2.0)
const zoomInBtn = Array.from(container.querySelectorAll("button")).find((b) => b.textContent === "Zoom In")!;
for (let i = 0; i < 50; i++) {
fireEvent.click(zoomInBtn);
}
const scene = container.querySelector(".dependency-graph-scene") as HTMLElement;
let scaleMatch = scene.style.transform.match(/scale\(([\d.]+)\)/);
expect(scaleMatch).toBeTruthy();
expect(parseFloat(scaleMatch![1])).toBeLessThanOrEqual(2);
// Try to zoom out beyond MIN_SCALE (0.4)
const zoomOutBtn = Array.from(container.querySelectorAll("button")).find((b) => b.textContent === "Zoom Out")!;
for (let i = 0; i < 100; i++) {
fireEvent.click(zoomOutBtn);
}
const updatedScene = container.querySelector(".dependency-graph-scene") as HTMLElement;
scaleMatch = updatedScene.style.transform.match(/scale\(([\d.]+)\)/);
expect(scaleMatch).toBeTruthy();
expect(parseFloat(scaleMatch![1])).toBeGreaterThanOrEqual(0.4);
});
});