@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.
- package/README.md +3 -19
- package/dist/bin.js +7005 -2992
- package/dist/client/assets/AgentDetailView-DGqT1oDt.js +18 -0
- package/dist/client/assets/AgentDetailView-yu8Xltqk.css +1 -0
- package/dist/client/assets/AgentsView-BmemrfrO.js +517 -0
- package/dist/client/assets/AgentsView-Bs03ptrd.css +1 -0
- package/dist/client/assets/ChatView-CZQUBFlV.js +1 -0
- package/dist/client/assets/{DevServerView-CV_PpbnZ.js → DevServerView-C3Q0XqDA.js} +1 -1
- package/dist/client/assets/{DirectoryPicker-DPfkGnj5.js → DirectoryPicker-BZWVA9ND.js} +1 -1
- package/dist/client/assets/{DocumentsView-CESb6RI7.js → DocumentsView-DO48ivSq.js} +1 -1
- package/dist/client/assets/InsightsView-CAngTfMf.js +11 -0
- package/dist/client/assets/MemoryView-B3rNcAOW.js +2 -0
- package/dist/client/assets/NodesView-BnV1LWa8.js +14 -0
- package/dist/client/assets/NodesView-DuAXX_0j.css +1 -0
- package/dist/client/assets/{PiExtensionsManager-C4fTzemh.js → PiExtensionsManager-C3_Lw4sa.js} +3 -3
- package/dist/client/assets/{PluginManager-C2-dExUL.js → PluginManager-Vv3nzrJ1.js} +1 -1
- package/dist/client/assets/ResearchView-BzCcDAS4.css +1 -0
- package/dist/client/assets/ResearchView-Dfdsuc21.js +1 -0
- package/dist/client/assets/RoadmapsView-BiIpE-b8.js +6 -0
- package/dist/client/assets/RoadmapsView-DdGlfuu-.css +1 -0
- package/dist/client/assets/SettingsModal-BN00HYJ2.js +31 -0
- package/dist/client/assets/{SettingsModal-BGnSAeqa.js → SettingsModal-CK4w8Ztb.js} +1 -1
- package/dist/client/assets/SettingsModal-Dq4a5KSX.css +1 -0
- package/dist/client/assets/{SetupWizardModal-C_d9clJp.js → SetupWizardModal-Dw6N4UvY.js} +1 -1
- package/dist/client/assets/{SkillsView-C096TB7i.js → SkillsView-C1196wgA.js} +1 -1
- package/dist/client/assets/{folder-open-CKivQd8c.js → folder-open-WVtgE4k3.js} +1 -1
- package/dist/client/assets/index-BIJgrHEn.css +1 -0
- package/dist/client/assets/index-Bv0TGzDH.js +682 -0
- package/dist/client/assets/{star-damu_EYz.js → star-MSImEC8V.js} +1 -1
- package/dist/client/assets/{upload-uH6CHlEw.js → upload-Dmvy3xXd.js} +1 -1
- package/dist/client/assets/{users-CUySbfji.js → users-CncYvHNf.js} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/version.json +1 -1
- package/dist/extension.js +6220 -3829
- package/dist/pi-claude-cli/package.json +1 -1
- package/dist/pi-claude-cli/src/__tests__/process-manager.test.ts +11 -0
- package/dist/pi-claude-cli/src/__tests__/provider.test.ts +25 -0
- package/dist/plugins/fusion-plugin-dependency-graph/manifest.json +16 -0
- package/dist/plugins/fusion-plugin-dependency-graph/package.json +34 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/DependencyGraphView.css +132 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/DependencyGraphView.tsx +428 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraphView.test.tsx +261 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/__tests__/storage.test.ts +31 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/index.ts +25 -0
- package/dist/plugins/fusion-plugin-dependency-graph/src/storage.ts +23 -0
- package/package.json +8 -4
- package/skill/fusion/SKILL.md +5 -5
- package/skill/fusion/references/engine-tools.md +4 -4
- package/skill/fusion/references/extension-tools.md +3 -3
- package/skill/fusion/references/fusion-capabilities.md +1 -1
- package/skill/fusion/references/skill-patterns.md +1 -1
- package/skill/fusion/workflows/dashboard-cli.md +3 -3
- package/skill/fusion/workflows/task-management.md +1 -1
- package/dist/client/assets/AgentDetailView-B1zViykq.js +0 -18
- package/dist/client/assets/AgentDetailView-B5tq9ius.css +0 -1
- package/dist/client/assets/AgentsView-Bl9JH5C8.js +0 -522
- package/dist/client/assets/AgentsView-V5GhlBYu.css +0 -1
- package/dist/client/assets/ChatView-liNErE53.js +0 -1
- package/dist/client/assets/InsightsView-BKhvyEyQ.js +0 -11
- package/dist/client/assets/MemoryView-DB-l2miV.js +0 -2
- package/dist/client/assets/NodesView-DCoS6iYh.css +0 -1
- package/dist/client/assets/NodesView-DgTXO8mm.js +0 -14
- package/dist/client/assets/ResearchView-BzRdUzNq.css +0 -1
- package/dist/client/assets/ResearchView-CkVwRDVA.js +0 -1
- package/dist/client/assets/RoadmapsView-BOYnyMCh.css +0 -1
- package/dist/client/assets/RoadmapsView-Cu85_XrQ.js +0 -6
- package/dist/client/assets/SettingsModal-C0DokcId.js +0 -31
- package/dist/client/assets/SettingsModal-DcGFm6NR.css +0 -1
- package/dist/client/assets/SkillMultiselect-DDHJnrkn.css +0 -1
- package/dist/client/assets/SkillMultiselect-DwGWYZi6.js +0 -1
- package/dist/client/assets/TodoView-CUiAt2mR.js +0 -6
- package/dist/client/assets/TodoView-SeO9o7km.css +0 -1
- package/dist/client/assets/index-B4StE1qN.js +0 -662
- package/dist/client/assets/index-DYJk0WDc.css +0 -1
- 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.
|
|
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
|
+
}
|