@runfusion/fusion 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +3 -19
  2. package/dist/bin.js +7005 -2992
  3. package/dist/client/assets/AgentDetailView-DGqT1oDt.js +18 -0
  4. package/dist/client/assets/AgentDetailView-yu8Xltqk.css +1 -0
  5. package/dist/client/assets/AgentsView-BmemrfrO.js +517 -0
  6. package/dist/client/assets/AgentsView-Bs03ptrd.css +1 -0
  7. package/dist/client/assets/ChatView-CZQUBFlV.js +1 -0
  8. package/dist/client/assets/{DevServerView-CV_PpbnZ.js → DevServerView-C3Q0XqDA.js} +1 -1
  9. package/dist/client/assets/{DirectoryPicker-DPfkGnj5.js → DirectoryPicker-BZWVA9ND.js} +1 -1
  10. package/dist/client/assets/{DocumentsView-CESb6RI7.js → DocumentsView-DO48ivSq.js} +1 -1
  11. package/dist/client/assets/InsightsView-CAngTfMf.js +11 -0
  12. package/dist/client/assets/MemoryView-B3rNcAOW.js +2 -0
  13. package/dist/client/assets/NodesView-BnV1LWa8.js +14 -0
  14. package/dist/client/assets/NodesView-DuAXX_0j.css +1 -0
  15. package/dist/client/assets/{PiExtensionsManager-C4fTzemh.js → PiExtensionsManager-C3_Lw4sa.js} +3 -3
  16. package/dist/client/assets/{PluginManager-C2-dExUL.js → PluginManager-Vv3nzrJ1.js} +1 -1
  17. package/dist/client/assets/ResearchView-BzCcDAS4.css +1 -0
  18. package/dist/client/assets/ResearchView-Dfdsuc21.js +1 -0
  19. package/dist/client/assets/RoadmapsView-BiIpE-b8.js +6 -0
  20. package/dist/client/assets/RoadmapsView-DdGlfuu-.css +1 -0
  21. package/dist/client/assets/SettingsModal-BN00HYJ2.js +31 -0
  22. package/dist/client/assets/{SettingsModal-BGnSAeqa.js → SettingsModal-CK4w8Ztb.js} +1 -1
  23. package/dist/client/assets/SettingsModal-Dq4a5KSX.css +1 -0
  24. package/dist/client/assets/{SetupWizardModal-C_d9clJp.js → SetupWizardModal-Dw6N4UvY.js} +1 -1
  25. package/dist/client/assets/{SkillsView-C096TB7i.js → SkillsView-C1196wgA.js} +1 -1
  26. package/dist/client/assets/{folder-open-CKivQd8c.js → folder-open-WVtgE4k3.js} +1 -1
  27. package/dist/client/assets/index-BIJgrHEn.css +1 -0
  28. package/dist/client/assets/index-Bv0TGzDH.js +682 -0
  29. package/dist/client/assets/{star-damu_EYz.js → star-MSImEC8V.js} +1 -1
  30. package/dist/client/assets/{upload-uH6CHlEw.js → upload-Dmvy3xXd.js} +1 -1
  31. package/dist/client/assets/{users-CUySbfji.js → users-CncYvHNf.js} +1 -1
  32. package/dist/client/index.html +2 -2
  33. package/dist/client/version.json +1 -1
  34. package/dist/extension.js +6220 -3829
  35. package/dist/pi-claude-cli/package.json +1 -1
  36. package/dist/pi-claude-cli/src/__tests__/process-manager.test.ts +11 -0
  37. package/dist/pi-claude-cli/src/__tests__/provider.test.ts +25 -0
  38. package/dist/plugins/fusion-plugin-dependency-graph/manifest.json +16 -0
  39. package/dist/plugins/fusion-plugin-dependency-graph/package.json +34 -0
  40. package/dist/plugins/fusion-plugin-dependency-graph/src/DependencyGraphView.css +132 -0
  41. package/dist/plugins/fusion-plugin-dependency-graph/src/DependencyGraphView.tsx +428 -0
  42. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraphView.test.tsx +261 -0
  43. package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/storage.test.ts +31 -0
  44. package/dist/plugins/fusion-plugin-dependency-graph/src/index.ts +25 -0
  45. package/dist/plugins/fusion-plugin-dependency-graph/src/storage.ts +23 -0
  46. package/package.json +8 -4
  47. package/skill/fusion/SKILL.md +5 -5
  48. package/skill/fusion/references/engine-tools.md +4 -4
  49. package/skill/fusion/references/extension-tools.md +3 -3
  50. package/skill/fusion/references/fusion-capabilities.md +1 -1
  51. package/skill/fusion/references/skill-patterns.md +1 -1
  52. package/skill/fusion/workflows/dashboard-cli.md +3 -3
  53. package/skill/fusion/workflows/task-management.md +1 -1
  54. package/dist/client/assets/AgentDetailView-B1zViykq.js +0 -18
  55. package/dist/client/assets/AgentDetailView-B5tq9ius.css +0 -1
  56. package/dist/client/assets/AgentsView-Bl9JH5C8.js +0 -522
  57. package/dist/client/assets/AgentsView-V5GhlBYu.css +0 -1
  58. package/dist/client/assets/ChatView-liNErE53.js +0 -1
  59. package/dist/client/assets/InsightsView-BKhvyEyQ.js +0 -11
  60. package/dist/client/assets/MemoryView-DB-l2miV.js +0 -2
  61. package/dist/client/assets/NodesView-DCoS6iYh.css +0 -1
  62. package/dist/client/assets/NodesView-DgTXO8mm.js +0 -14
  63. package/dist/client/assets/ResearchView-BzRdUzNq.css +0 -1
  64. package/dist/client/assets/ResearchView-CkVwRDVA.js +0 -1
  65. package/dist/client/assets/RoadmapsView-BOYnyMCh.css +0 -1
  66. package/dist/client/assets/RoadmapsView-Cu85_XrQ.js +0 -6
  67. package/dist/client/assets/SettingsModal-C0DokcId.js +0 -31
  68. package/dist/client/assets/SettingsModal-DcGFm6NR.css +0 -1
  69. package/dist/client/assets/SkillMultiselect-DDHJnrkn.css +0 -1
  70. package/dist/client/assets/SkillMultiselect-DwGWYZi6.js +0 -1
  71. package/dist/client/assets/TodoView-CUiAt2mR.js +0 -6
  72. package/dist/client/assets/TodoView-SeO9o7km.css +0 -1
  73. package/dist/client/assets/index-B4StE1qN.js +0 -662
  74. package/dist/client/assets/index-DYJk0WDc.css +0 -1
  75. package/dist/client/assets/list-checks-B3oufblU.js +0 -6
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fusion/pi-claude-cli",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "Fusion vendored fork: pi coding-agent extension that routes LLM calls through the Claude Code CLI. Forked from rchern/pi-claude-cli (MIT). See UPSTREAM.md.",
5
5
  "license": "MIT",
6
6
  "private": true,
@@ -713,6 +713,17 @@ describe("resume session flag", () => {
713
713
  expect(args).not.toContain("--resume");
714
714
  });
715
715
 
716
+ it("does not include --session-id when --resume is provided", () => {
717
+ spawnClaude("claude-sonnet-4-5-20250929", undefined, {
718
+ resumeSessionId: "session-abc",
719
+ newSessionId: "session-new",
720
+ });
721
+ const args = (spawn as any).mock.calls[0][1] as string[];
722
+
723
+ expect(args).toContain("--resume");
724
+ expect(args).not.toContain("--session-id");
725
+ });
726
+
716
727
  it("includes both --resume and --effort when both are provided", () => {
717
728
  spawnClaude("claude-sonnet-4-5-20250929", undefined, {
718
729
  resumeSessionId: "session-abc",
@@ -1512,6 +1512,31 @@ describe("streamViaCli", { timeout: 90_000 }, () => {
1512
1512
  });
1513
1513
  });
1514
1514
 
1515
+ describe("resume behavior", () => {
1516
+ it("uses --resume for follow-up quick-chat turns when sessionId is present", async () => {
1517
+ const model = mockModels[0] as any;
1518
+ const context = {
1519
+ messages: [
1520
+ { role: "user", content: "Turn 1" },
1521
+ { role: "assistant", content: "Reply 1" },
1522
+ { role: "user", content: "Follow-up" },
1523
+ ],
1524
+ };
1525
+
1526
+ streamViaCli(model, context, { sessionId: "session-follow-up" } as any);
1527
+ await vi.advanceTimersByTimeAsync(0);
1528
+
1529
+ const args = (spawn as any).mock.calls[0][1] as string[];
1530
+ expect(args).toContain("--resume");
1531
+ expect(args).toContain("session-follow-up");
1532
+ expect(args).not.toContain("--session-id");
1533
+
1534
+ const proc = (spawn as any).mock.results[0].value;
1535
+ proc.stdout.end();
1536
+ await vi.advanceTimersByTimeAsync(100);
1537
+ });
1538
+ });
1539
+
1515
1540
  describe("MCP config with custom tool results", () => {
1516
1541
  it("keeps MCP config even when conversation ends with custom tool result", async () => {
1517
1542
  const model = mockModels[0] as any;
@@ -0,0 +1,16 @@
1
+ {
2
+ "id": "fusion-plugin-dependency-graph",
3
+ "name": "Dependency Graph",
4
+ "version": "0.1.0",
5
+ "description": "Top-level dependency graph dashboard view",
6
+ "dashboardViews": [
7
+ {
8
+ "viewId": "graph",
9
+ "label": "Graph",
10
+ "componentPath": "./src/DependencyGraphView.tsx",
11
+ "icon": "Network",
12
+ "placement": "more",
13
+ "order": 40
14
+ }
15
+ ]
16
+ }
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@fusion-plugin-examples/dependency-graph",
3
+ "version": "0.1.3",
4
+ "type": "module",
5
+ "description": "Dependency graph dashboard view plugin for Fusion",
6
+ "private": true,
7
+ "exports": {
8
+ ".": {
9
+ "types": "./src/index.ts",
10
+ "import": "./src/index.ts"
11
+ },
12
+ "./dashboard-view": {
13
+ "types": "./src/DependencyGraphView.tsx",
14
+ "import": "./src/DependencyGraphView.tsx"
15
+ }
16
+ },
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "test": "vitest run --silent=passed-only --reporter=dot"
20
+ },
21
+ "dependencies": {
22
+ "@fusion/core": "workspace:*",
23
+ "@fusion/plugin-sdk": "workspace:*"
24
+ },
25
+ "devDependencies": {
26
+ "@testing-library/react": "^16.3.2",
27
+ "@types/node": "^25.5.2",
28
+ "@types/react": "^19.0.0",
29
+ "react": "^19.0.0",
30
+ "react-dom": "^19.2.4",
31
+ "typescript": "^5.7.0",
32
+ "vitest": "^3.2.4"
33
+ }
34
+ }
@@ -0,0 +1,132 @@
1
+ .dependency-graph-view {
2
+ --dependency-graph-canvas-min-height: calc(var(--space-2xl) * 10);
3
+ --dependency-graph-canvas-min-height-mobile: calc(var(--space-2xl) * 8);
4
+ --dependency-graph-edge-width: var(--btn-border-width);
5
+ --dependency-graph-node-max-width-mobile: calc(var(--space-2xl) * 10);
6
+
7
+ display: flex;
8
+ flex-direction: column;
9
+ gap: var(--space-md);
10
+ padding: var(--space-lg);
11
+ }
12
+
13
+ .dependency-graph-controls {
14
+ display: flex;
15
+ flex-wrap: wrap;
16
+ gap: var(--space-sm);
17
+ }
18
+
19
+ .dependency-graph-canvas {
20
+ overflow: auto;
21
+ border: var(--btn-border-width) solid var(--border);
22
+ border-radius: var(--radius-md);
23
+ background: var(--surface);
24
+ min-height: var(--dependency-graph-canvas-min-height);
25
+ cursor: grab;
26
+ touch-action: none;
27
+ }
28
+
29
+ .dependency-graph-canvas:active {
30
+ cursor: grabbing;
31
+ }
32
+
33
+ .dependency-graph-scene {
34
+ position: relative;
35
+ transition: transform var(--transition-fast);
36
+ }
37
+
38
+ .dependency-graph-edges {
39
+ position: absolute;
40
+ inset: 0;
41
+ width: 100%;
42
+ height: 100%;
43
+ pointer-events: none;
44
+ }
45
+
46
+ .dependency-graph-edge {
47
+ stroke: var(--border);
48
+ stroke-width: var(--dependency-graph-edge-width);
49
+ transition: stroke var(--transition-fast), opacity var(--transition-fast);
50
+ }
51
+
52
+ .dependency-graph-edge.is-related {
53
+ stroke: var(--todo);
54
+ }
55
+
56
+ .dependency-graph-edge.is-dimmed {
57
+ opacity: 0.4;
58
+ }
59
+
60
+ .dependency-graph-node {
61
+ position: absolute;
62
+ top: 0;
63
+ left: 0;
64
+ cursor: grab;
65
+ transition: opacity var(--transition-fast), filter var(--transition-fast), box-shadow var(--transition-fast);
66
+ }
67
+
68
+ .dependency-graph-node:active {
69
+ cursor: grabbing;
70
+ }
71
+
72
+ .dependency-graph-node .card {
73
+ height: 100%;
74
+ }
75
+
76
+ .dependency-graph-node.is-selected .card {
77
+ box-shadow: var(--focus-ring-strong);
78
+ border-color: var(--todo);
79
+ }
80
+
81
+ .dependency-graph-node.is-related:not(.is-selected) .card {
82
+ border-color: var(--in-progress);
83
+ }
84
+
85
+ .dependency-graph-node.is-dimmed {
86
+ opacity: 0.5;
87
+ filter: saturate(0.8);
88
+ }
89
+
90
+ .dependency-graph-empty {
91
+ display: flex;
92
+ align-items: center;
93
+ justify-content: center;
94
+ height: 100%;
95
+ min-height: var(--dependency-graph-canvas-min-height);
96
+ padding: var(--space-xl);
97
+ color: var(--text-muted);
98
+ text-align: center;
99
+ }
100
+
101
+ @media (max-width: 768px) {
102
+ .dependency-graph-view {
103
+ padding: var(--space-md);
104
+ }
105
+
106
+ .dependency-graph-controls {
107
+ position: sticky;
108
+ top: 0;
109
+ z-index: 1;
110
+ background: var(--bg);
111
+ padding: var(--space-sm) 0;
112
+ }
113
+
114
+ .dependency-graph-controls .btn {
115
+ min-height: 44px;
116
+ min-width: 44px;
117
+ }
118
+
119
+ .dependency-graph-canvas {
120
+ flex: 1;
121
+ min-height: 0;
122
+ min-height: var(--dependency-graph-canvas-min-height-mobile);
123
+ }
124
+
125
+ .dependency-graph-node {
126
+ width: min(100%, var(--dependency-graph-node-max-width-mobile)) !important;
127
+ }
128
+
129
+ .dependency-graph-empty {
130
+ min-height: var(--dependency-graph-canvas-min-height-mobile);
131
+ }
132
+ }
@@ -0,0 +1,428 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import type { PointerEvent as ReactPointerEvent, ReactNode, WheelEvent as ReactWheelEvent } from "react";
3
+ import type { Task } from "@fusion/core";
4
+ import { loadPositions, savePositions } from "./storage";
5
+ import "./DependencyGraphView.css";
6
+
7
+ const ACTIVE_COLUMNS = new Set(["triage", "todo", "in-progress", "in-review"]);
8
+ const NODE_WIDTH_REM = 18;
9
+ const NODE_HEIGHT_REM = 9;
10
+ const GRID_GAP_X_REM = 3;
11
+ const GRID_GAP_Y_REM = 4;
12
+ const DRAG_THRESHOLD_REM = 0.5;
13
+ const SCENE_PADDING_REM = 2;
14
+ const FIT_PADDING_REM = 2;
15
+ const MIN_SCALE = 0.4;
16
+ const MAX_SCALE = 2;
17
+ const WHEEL_ZOOM_FACTOR = 0.002;
18
+ const MOBILE_BREAKPOINT = 768;
19
+
20
+ export interface DependencyGraphHostContext {
21
+ projectId?: string;
22
+ tasks: Task[];
23
+ openTaskDetail: (task: Task) => void;
24
+ renderTaskCard: (task: Task) => ReactNode;
25
+ }
26
+
27
+ export interface PluginDashboardViewComponentProps {
28
+ context: DependencyGraphHostContext;
29
+ }
30
+
31
+ type Position = { x: number; y: number };
32
+
33
+ function getDistance(a: Position, b: Position): number {
34
+ return Math.hypot(a.x - b.x, a.y - b.y);
35
+ }
36
+
37
+ function isMobileViewport(): boolean {
38
+ if (typeof window === "undefined") return false;
39
+ return window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches;
40
+ }
41
+
42
+ export function DependencyGraphView({ context }: PluginDashboardViewComponentProps) {
43
+ const [scale, setScale] = useState(1);
44
+ const [pan, setPan] = useState<Position>({ x: 0, y: 0 });
45
+ const [nodeOverrides, setNodeOverrides] = useState<Record<string, Position>>({});
46
+ const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
47
+ const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
48
+ const persisted = useMemo(() => loadPositions(context.projectId), [context.projectId]);
49
+ const canvasRef = useRef<HTMLDivElement | null>(null);
50
+
51
+ // Multi-pointer tracking (no setPointerCapture — it breaks two-finger gestures)
52
+ const pointersRef = useRef<Map<number, Position>>(new Map());
53
+ const interactionRef = useRef<
54
+ | { kind: "node"; taskId: string; startPointer: Position; startNode: Position; moved: boolean }
55
+ | { kind: "pan"; startPointer: Position; startPan: Position; moved: boolean }
56
+ | null
57
+ >(null);
58
+ const pinchRef = useRef<{ startDistance: number; startScale: number } | null>(null);
59
+ const autoFitDoneRef = useRef(false);
60
+
61
+ const tasks = useMemo(
62
+ () => context.tasks.filter((task) => ACTIVE_COLUMNS.has(task.column)),
63
+ [context.tasks],
64
+ );
65
+
66
+ const positioned = useMemo(() => {
67
+ return tasks.map((task, index) => {
68
+ const saved = nodeOverrides[task.id] ?? persisted[task.id];
69
+ return {
70
+ task,
71
+ x: saved?.x ?? (index % 4) * (NODE_WIDTH_REM + GRID_GAP_X_REM),
72
+ y: saved?.y ?? Math.floor(index / 4) * (NODE_HEIGHT_REM + GRID_GAP_Y_REM),
73
+ };
74
+ });
75
+ }, [nodeOverrides, persisted, tasks]);
76
+
77
+ const map = useMemo(() => new Map(positioned.map((node) => [node.task.id, node])), [positioned]);
78
+
79
+ const edges = useMemo(() => {
80
+ const lines: Array<{ from: string; to: string; x1: number; y1: number; x2: number; y2: number }> = [];
81
+ positioned.forEach((node) => {
82
+ (node.task.dependencies ?? []).forEach((dependencyId) => {
83
+ const dependency = map.get(dependencyId);
84
+ if (!dependency) return;
85
+ lines.push({
86
+ from: dependencyId,
87
+ to: node.task.id,
88
+ x1: dependency.x + NODE_WIDTH_REM,
89
+ y1: dependency.y + NODE_HEIGHT_REM / 2,
90
+ x2: node.x,
91
+ y2: node.y + NODE_HEIGHT_REM / 2,
92
+ });
93
+ });
94
+ });
95
+ return lines;
96
+ }, [map, positioned]);
97
+
98
+ const bounds = useMemo(() => {
99
+ if (positioned.length === 0) {
100
+ return { minX: 0, minY: 0, width: NODE_WIDTH_REM * 2, height: NODE_HEIGHT_REM * 2 };
101
+ }
102
+
103
+ const minX = Math.min(...positioned.map((node) => node.x)) - SCENE_PADDING_REM;
104
+ const minY = Math.min(...positioned.map((node) => node.y)) - SCENE_PADDING_REM;
105
+ const maxX = Math.max(...positioned.map((node) => node.x + NODE_WIDTH_REM)) + SCENE_PADDING_REM;
106
+ const maxY = Math.max(...positioned.map((node) => node.y + NODE_HEIGHT_REM)) + SCENE_PADDING_REM;
107
+
108
+ return {
109
+ minX,
110
+ minY,
111
+ width: Math.max(NODE_WIDTH_REM * 2, maxX - minX),
112
+ height: Math.max(NODE_HEIGHT_REM * 2, maxY - minY),
113
+ };
114
+ }, [positioned]);
115
+
116
+ const positionedForRender = useMemo(
117
+ () =>
118
+ positioned.map((node) => ({
119
+ ...node,
120
+ renderX: node.x - bounds.minX,
121
+ renderY: node.y - bounds.minY,
122
+ })),
123
+ [bounds.minX, bounds.minY, positioned],
124
+ );
125
+
126
+ const edgesForRender = useMemo(
127
+ () =>
128
+ edges.map((edge) => ({
129
+ ...edge,
130
+ renderX1: edge.x1 - bounds.minX,
131
+ renderY1: edge.y1 - bounds.minY,
132
+ renderX2: edge.x2 - bounds.minX,
133
+ renderY2: edge.y2 - bounds.minY,
134
+ })),
135
+ [bounds.minX, bounds.minY, edges],
136
+ );
137
+
138
+ const dependencyGraph = useMemo(() => {
139
+ const downstream = new Map<string, Set<string>>();
140
+ const upstream = new Map<string, Set<string>>();
141
+
142
+ edges.forEach((edge) => {
143
+ downstream.set(edge.from, (downstream.get(edge.from) ?? new Set<string>()).add(edge.to));
144
+ upstream.set(edge.to, (upstream.get(edge.to) ?? new Set<string>()).add(edge.from));
145
+ });
146
+
147
+ return { downstream, upstream };
148
+ }, [edges]);
149
+
150
+ const focusTaskId = hoveredTaskId ?? selectedTaskId;
151
+
152
+ const relatedTaskIds = useMemo(() => {
153
+ if (!focusTaskId) return null;
154
+
155
+ const related = new Set<string>([focusTaskId]);
156
+ const walk = (seed: string, map: Map<string, Set<string>>) => {
157
+ const queue = [seed];
158
+ while (queue.length > 0) {
159
+ const current = queue.shift();
160
+ if (!current) continue;
161
+ (map.get(current) ?? new Set<string>()).forEach((next) => {
162
+ if (related.has(next)) return;
163
+ related.add(next);
164
+ queue.push(next);
165
+ });
166
+ }
167
+ };
168
+
169
+ walk(focusTaskId, dependencyGraph.downstream);
170
+ walk(focusTaskId, dependencyGraph.upstream);
171
+
172
+ return related;
173
+ }, [dependencyGraph.downstream, dependencyGraph.upstream, focusTaskId]);
174
+
175
+ const fitToGraph = useCallback(() => {
176
+ const canvas = canvasRef.current;
177
+ if (!canvas) return;
178
+
179
+ const rootFontSize = Number.parseFloat(globalThis.getComputedStyle(document.documentElement).fontSize) || 16;
180
+ const widthPx = bounds.width * rootFontSize;
181
+ const heightPx = bounds.height * rootFontSize;
182
+ const paddingPx = FIT_PADDING_REM * rootFontSize;
183
+ const availableWidth = Math.max(1, canvas.clientWidth - paddingPx * 2);
184
+ const availableHeight = Math.max(1, canvas.clientHeight - paddingPx * 2);
185
+
186
+ const nextScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, Math.min(availableWidth / widthPx, availableHeight / heightPx)));
187
+ const centeredPanX = (canvas.clientWidth - widthPx * nextScale) / (2 * rootFontSize * nextScale);
188
+ const centeredPanY = (canvas.clientHeight - heightPx * nextScale) / (2 * rootFontSize * nextScale);
189
+
190
+ setScale(nextScale);
191
+ setPan({ x: centeredPanX, y: centeredPanY });
192
+ }, [bounds.width, bounds.height]);
193
+
194
+ // Auto-fit on initial mobile load
195
+ useEffect(() => {
196
+ if (autoFitDoneRef.current) return;
197
+ if (!isMobileViewport()) return;
198
+ if (positioned.length === 0) return;
199
+
200
+ const canvas = canvasRef.current;
201
+ if (!canvas) return;
202
+
203
+ // Ensure the canvas has non-zero dimensions before fitting
204
+ if (canvas.clientWidth === 0 || canvas.clientHeight === 0) return;
205
+
206
+ autoFitDoneRef.current = true;
207
+ // Use rAF to ensure layout is settled
208
+ requestAnimationFrame(() => {
209
+ fitToGraph();
210
+ });
211
+ }, [fitToGraph, positioned.length]);
212
+
213
+ const persistPosition = (taskId: string, next: Position) => {
214
+ setNodeOverrides((current) => ({ ...current, [taskId]: next }));
215
+ savePositions(context.projectId, { ...persisted, ...nodeOverrides, [taskId]: next });
216
+ };
217
+
218
+ const handlePointerDownOnNode = (taskId: string, event: ReactPointerEvent<HTMLDivElement>) => {
219
+ setSelectedTaskId((current) => (current === taskId ? null : taskId));
220
+ if (event.button !== 0) return;
221
+ event.preventDefault();
222
+ event.stopPropagation();
223
+ const hit = map.get(taskId);
224
+ if (!hit) return;
225
+
226
+ // Track this pointer in the global map
227
+ pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
228
+
229
+ interactionRef.current = {
230
+ kind: "node",
231
+ taskId,
232
+ startPointer: { x: event.clientX, y: event.clientY },
233
+ startNode: { x: hit.x, y: hit.y },
234
+ moved: false,
235
+ };
236
+ };
237
+
238
+ const handleCanvasPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
239
+ if (event.button !== 0) return;
240
+
241
+ // Track this pointer
242
+ pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
243
+
244
+ // If we already have another pointer, this is the start of a pinch gesture
245
+ if (pointersRef.current.size === 2) {
246
+ // Cancel any ongoing pan interaction
247
+ interactionRef.current = null;
248
+ const [p1, p2] = Array.from(pointersRef.current.values());
249
+ const distance = getDistance(p1, p2);
250
+ pinchRef.current = { startDistance: distance, startScale: scale };
251
+ return;
252
+ }
253
+
254
+ interactionRef.current = {
255
+ kind: "pan",
256
+ startPointer: { x: event.clientX, y: event.clientY },
257
+ startPan: pan,
258
+ moved: false,
259
+ };
260
+ };
261
+
262
+ const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
263
+ // Update tracked pointer position
264
+ if (pointersRef.current.has(event.pointerId)) {
265
+ pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
266
+ }
267
+
268
+ // Handle pinch gesture when two pointers are active
269
+ if (pointersRef.current.size >= 2 && pinchRef.current) {
270
+ const [p1, p2] = Array.from(pointersRef.current.values());
271
+ const currentDistance = getDistance(p1, p2);
272
+ const scaleFactor = currentDistance / pinchRef.current.startDistance;
273
+ const newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, pinchRef.current.startScale * scaleFactor));
274
+ setScale(newScale);
275
+ return;
276
+ }
277
+
278
+ const current = interactionRef.current;
279
+ if (!current) return;
280
+
281
+ const delta = {
282
+ x: (event.clientX - current.startPointer.x) / 16,
283
+ y: (event.clientY - current.startPointer.y) / 16,
284
+ };
285
+
286
+ if (current.kind === "node") {
287
+ const moved = getDistance({ x: 0, y: 0 }, delta) > DRAG_THRESHOLD_REM;
288
+ if (moved && !current.moved) current.moved = true;
289
+ if (!current.moved) return;
290
+ setNodeOverrides((existing) => ({
291
+ ...existing,
292
+ [current.taskId]: { x: current.startNode.x + delta.x / scale, y: current.startNode.y + delta.y / scale },
293
+ }));
294
+ return;
295
+ }
296
+
297
+ const moved = getDistance({ x: 0, y: 0 }, delta) > DRAG_THRESHOLD_REM;
298
+ if (moved && !current.moved) current.moved = true;
299
+ if (!current.moved) return;
300
+ setPan({ x: current.startPan.x + delta.x, y: current.startPan.y + delta.y });
301
+ };
302
+
303
+ const handlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
304
+ pointersRef.current.delete(event.pointerId);
305
+
306
+ // If we had a pinch and one finger remains, end pinch mode
307
+ if (pinchRef.current) {
308
+ if (pointersRef.current.size < 2) {
309
+ pinchRef.current = null;
310
+ }
311
+ return;
312
+ }
313
+
314
+ const current = interactionRef.current;
315
+ interactionRef.current = null;
316
+ if (!current) return;
317
+
318
+ if (current.kind === "node") {
319
+ const hit = map.get(current.taskId);
320
+ if (!hit) return;
321
+
322
+ if (!current.moved) {
323
+ context.openTaskDetail(hit.task);
324
+ return;
325
+ }
326
+
327
+ persistPosition(current.taskId, { x: hit.x, y: hit.y });
328
+ }
329
+ };
330
+
331
+ const handlePointerCancel = (event: ReactPointerEvent<HTMLDivElement>) => {
332
+ pointersRef.current.delete(event.pointerId);
333
+ pinchRef.current = null;
334
+ interactionRef.current = null;
335
+ };
336
+
337
+ const handleWheel = (event: ReactWheelEvent<HTMLDivElement>) => {
338
+ event.preventDefault();
339
+ const canvas = canvasRef.current;
340
+ if (!canvas) return;
341
+
342
+ const rootFontSize = Number.parseFloat(globalThis.getComputedStyle(document.documentElement).fontSize) || 16;
343
+ const delta = -event.deltaY * WHEEL_ZOOM_FACTOR;
344
+ const newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale * (1 + delta)));
345
+
346
+ // Zoom toward the pointer position
347
+ const rect = canvas.getBoundingClientRect();
348
+ const pointerX = event.clientX - rect.left;
349
+ const pointerY = event.clientY - rect.top;
350
+
351
+ // How much the point under the cursor should shift in rem
352
+ const scaleRatio = newScale / scale;
353
+ const panOffsetX = (pointerX / rootFontSize) * (1 - scaleRatio) / scale;
354
+ const panOffsetY = (pointerY / rootFontSize) * (1 - scaleRatio) / scale;
355
+
356
+ setScale(newScale);
357
+ setPan((prev) => ({
358
+ x: prev.x + panOffsetX * newScale / scale,
359
+ y: prev.y + panOffsetY * newScale / scale,
360
+ }));
361
+ };
362
+
363
+ return (
364
+ <section className="dependency-graph-view">
365
+ <div className="dependency-graph-controls">
366
+ <button className="btn btn-sm" onClick={() => setScale((value) => Math.min(value + 0.1, MAX_SCALE))}>Zoom In</button>
367
+ <button className="btn btn-sm" onClick={() => setScale((value) => Math.max(value - 0.1, MIN_SCALE))}>Zoom Out</button>
368
+ <button className="btn btn-sm" onClick={fitToGraph}>Fit</button>
369
+ </div>
370
+
371
+ <div
372
+ className="dependency-graph-canvas"
373
+ ref={canvasRef}
374
+ onPointerDown={handleCanvasPointerDown}
375
+ onPointerMove={handlePointerMove}
376
+ onPointerUp={handlePointerUp}
377
+ onPointerCancel={handlePointerCancel}
378
+ onWheel={handleWheel}
379
+ >
380
+ {tasks.length === 0 ? (
381
+ <div className="dependency-graph-empty">
382
+ <p>No tasks to display. Tasks in Triage, Todo, In Progress, or In Review columns will appear here.</p>
383
+ </div>
384
+ ) : (
385
+ <div
386
+ className="dependency-graph-scene"
387
+ style={{
388
+ width: `${bounds.width}rem`,
389
+ height: `${bounds.height}rem`,
390
+ transform: `translate(${pan.x}rem, ${pan.y}rem) scale(${scale})`,
391
+ transformOrigin: "top left",
392
+ }}
393
+ >
394
+ <svg className="dependency-graph-edges" viewBox={`0 0 ${bounds.width} ${bounds.height}`}>
395
+ {edgesForRender.map((edge) => (
396
+ <line
397
+ key={`${edge.from}-${edge.to}`}
398
+ x1={edge.renderX1}
399
+ y1={edge.renderY1}
400
+ x2={edge.renderX2}
401
+ y2={edge.renderY2}
402
+ className={`dependency-graph-edge${relatedTaskIds ? relatedTaskIds.has(edge.from) && relatedTaskIds.has(edge.to) ? " is-related" : " is-dimmed" : ""}`}
403
+ />
404
+ ))}
405
+ </svg>
406
+
407
+ {positionedForRender.map((node) => (
408
+ <div
409
+ key={node.task.id}
410
+ className={`dependency-graph-node${selectedTaskId === node.task.id ? " is-selected" : ""}${relatedTaskIds ? relatedTaskIds.has(node.task.id) ? " is-related" : " is-dimmed" : ""}`}
411
+ style={{
412
+ width: `${NODE_WIDTH_REM}rem`,
413
+ minHeight: `${NODE_HEIGHT_REM}rem`,
414
+ transform: `translate(${node.renderX}rem, ${node.renderY}rem)`,
415
+ }}
416
+ onPointerDown={(event) => handlePointerDownOnNode(node.task.id, event)}
417
+ onPointerEnter={() => setHoveredTaskId(node.task.id)}
418
+ onPointerLeave={() => setHoveredTaskId((current) => (current === node.task.id ? null : current))}
419
+ >
420
+ {context.renderTaskCard(node.task)}
421
+ </div>
422
+ ))}
423
+ </div>
424
+ )}
425
+ </div>
426
+ </section>
427
+ );
428
+ }