bosun 0.40.2 → 0.40.3

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/ui/tabs/tasks.js CHANGED
@@ -96,12 +96,26 @@ const DAG_GLOBAL_ENDPOINT_CANDIDATES = [
96
96
  "/api/tasks/dag/global",
97
97
  "/api/tasks/graph/global",
98
98
  ];
99
+ const DAG_EPIC_DEPENDENCY_ENDPOINT_CANDIDATES = [
100
+ "/api/tasks/epic-dependencies",
101
+ "/api/tasks/epics/dependencies",
102
+ "/api/tasks/dag/epics",
103
+ ];
99
104
  const EMPTY_DAG_GRAPH = {
100
105
  title: "",
101
106
  description: "",
102
107
  nodes: [],
103
108
  edges: [],
104
109
  };
110
+ const DAG_EDGE_STYLES = {
111
+ "depends-on": { color: "var(--accent)", dash: "" },
112
+ dependency: { color: "var(--accent)", dash: "" },
113
+ sequential: { color: "var(--color-warning)", dash: "7 4" },
114
+ sequence: { color: "var(--color-warning)", dash: "7 4" },
115
+ blocks: { color: "var(--color-error)", dash: "2 4" },
116
+ };
117
+ const DAG_MIN_ZOOM = 0.25;
118
+ const DAG_MAX_ZOOM = 2.4;
105
119
 
106
120
  /* ─── Status/Priority → MUI Chip color ─── */
107
121
  function statusChipColor(status) {
@@ -562,6 +576,120 @@ function normalizeDagGraph(raw, fallbackTitle = "") {
562
576
  };
563
577
  }
564
578
 
579
+ function normalizeEpicDependenciesPayload(raw) {
580
+ const payload = extractDagPayload(raw);
581
+ const rows = toArray(payload?.data || payload?.items || payload);
582
+ return rows
583
+ .map((entry) => {
584
+ if (!entry || typeof entry !== "object") return null;
585
+ const epicId = toText(entry.epicId || entry.id || entry.epic);
586
+ if (!epicId) return null;
587
+ return {
588
+ epicId,
589
+ dependencies: normalizeDependencyInput(entry.dependencies || entry.dependsOn || []),
590
+ };
591
+ })
592
+ .filter(Boolean);
593
+ }
594
+
595
+ function buildEpicDagGraph(tasks = [], epicDependencies = []) {
596
+ const epicMap = new Map();
597
+ const pushEpicNode = (epicId) => {
598
+ const id = toText(epicId);
599
+ if (!id) return null;
600
+ if (!epicMap.has(id)) {
601
+ epicMap.set(id, { id, title: id, taskIds: [], statusCounts: new Map(), dependencies: [] });
602
+ }
603
+ return epicMap.get(id);
604
+ };
605
+
606
+ for (const task of tasks || []) {
607
+ const epicId = toText(task?.epicId || task?.meta?.epicId);
608
+ if (!epicId) continue;
609
+ const node = pushEpicNode(epicId);
610
+ node.taskIds.push(task.id);
611
+ const status = toText(task?.status || "todo", "todo").toLowerCase();
612
+ node.statusCounts.set(status, (node.statusCounts.get(status) || 0) + 1);
613
+ }
614
+
615
+ const depMap = new Map();
616
+ const addEdge = (from, to, kind = "dependency") => {
617
+ const src = toText(from);
618
+ const dst = toText(to);
619
+ if (!src || !dst || src === dst) return;
620
+ pushEpicNode(src);
621
+ const node = pushEpicNode(dst);
622
+ if (node && !node.dependencies.includes(src)) node.dependencies.push(src);
623
+ const key = `${src}->${dst}:${kind}`;
624
+ if (!depMap.has(key)) depMap.set(key, { source: src, target: dst, kind });
625
+ };
626
+
627
+ for (const row of epicDependencies || []) {
628
+ for (const dep of row.dependencies || []) addEdge(dep, row.epicId, "blocks");
629
+ }
630
+
631
+ const taskById = new Map((tasks || []).map((task) => [String(task?.id || ""), task]));
632
+ for (const task of tasks || []) {
633
+ const targetEpic = toText(task?.epicId || task?.meta?.epicId);
634
+ if (!targetEpic) continue;
635
+ for (const depId of normalizeDependencyInput(task?.dependencyTaskIds || task?.dependsOn || task?.meta?.dependencyTaskIds || [])) {
636
+ const depTask = taskById.get(String(depId));
637
+ const sourceEpic = toText(depTask?.epicId || depTask?.meta?.epicId);
638
+ if (!sourceEpic || sourceEpic === targetEpic) continue;
639
+ addEdge(sourceEpic, targetEpic, "dependency");
640
+ }
641
+ }
642
+
643
+ const indegree = new Map();
644
+ const outgoing = new Map();
645
+ for (const epicId of epicMap.keys()) {
646
+ indegree.set(epicId, 0);
647
+ outgoing.set(epicId, []);
648
+ }
649
+ for (const edge of depMap.values()) {
650
+ outgoing.get(edge.source)?.push(edge.target);
651
+ indegree.set(edge.target, (indegree.get(edge.target) || 0) + 1);
652
+ }
653
+
654
+ const queue = [];
655
+ for (const [id, degree] of indegree.entries()) if (degree === 0) queue.push(id);
656
+ const level = new Map();
657
+ for (const id of queue) level.set(id, 0);
658
+ while (queue.length) {
659
+ const id = queue.shift();
660
+ const nextLevel = (level.get(id) || 0) + 1;
661
+ for (const dst of outgoing.get(id) || []) {
662
+ if (!level.has(dst) || (level.get(dst) || 0) < nextLevel) level.set(dst, nextLevel);
663
+ const degree = (indegree.get(dst) || 0) - 1;
664
+ indegree.set(dst, degree);
665
+ if (degree === 0) queue.push(dst);
666
+ }
667
+ }
668
+
669
+ const nodes = [...epicMap.values()].map((entry) => {
670
+ const statuses = [...entry.statusCounts.entries()].sort((a, b) => b[1] - a[1]);
671
+ const dominantStatus = statuses[0]?.[0] || "todo";
672
+ return {
673
+ id: entry.id,
674
+ taskId: null,
675
+ title: `Epic ${entry.title}`,
676
+ status: dominantStatus,
677
+ depth: level.get(entry.id) || 0,
678
+ order: entry.taskIds.length,
679
+ taskCount: entry.taskIds.length,
680
+ dependencies: [...entry.dependencies],
681
+ epicId: entry.id,
682
+ };
683
+ });
684
+
685
+ const edges = [...depMap.values()];
686
+ return {
687
+ title: "Epic Dependency DAG",
688
+ description: "Epic-level execution dependencies.",
689
+ nodes,
690
+ edges,
691
+ };
692
+ }
565
693
  function extractGlobalDagPayload(...sources) {
566
694
  for (const source of sources) {
567
695
  const payload = extractDagPayload(source);
@@ -3229,8 +3357,18 @@ function DagGraphSection({
3229
3357
  description = "",
3230
3358
  graph = EMPTY_DAG_GRAPH,
3231
3359
  onOpenTask,
3360
+ onCreateEdge,
3361
+ allowWiring = false,
3362
+ graphKey = "dag",
3232
3363
  emptyMessage = "No DAG nodes available for this view yet.",
3233
3364
  }) {
3365
+ const stageRef = useRef(null);
3366
+ const [zoom, setZoom] = useState(1);
3367
+ const [pan, setPan] = useState({ x: 24, y: 24 });
3368
+ const [isPanning, setIsPanning] = useState(false);
3369
+ const [wireSourceId, setWireSourceId] = useState("");
3370
+ const [wiringBusy, setWiringBusy] = useState(false);
3371
+
3234
3372
  const sortedNodes = useMemo(() => {
3235
3373
  const nodes = [...(graph?.nodes || [])];
3236
3374
  nodes.sort((a, b) => {
@@ -3258,22 +3396,20 @@ function DagGraphSection({
3258
3396
  list.push(node);
3259
3397
  map.set(key, list);
3260
3398
  }
3261
- if (!map.size && sortedNodes.length) {
3262
- map.set(0, [...sortedNodes]);
3263
- }
3399
+ if (!map.size && sortedNodes.length) map.set(0, [...sortedNodes]);
3264
3400
  return [...map.entries()].sort((a, b) => a[0] - b[0]);
3265
3401
  }, [sortedNodes]);
3266
3402
 
3267
3403
  const layout = useMemo(() => {
3268
- const nodeWidth = 220;
3269
- const nodeHeight = 84;
3270
- const colGap = 120;
3404
+ const nodeWidth = 250;
3405
+ const nodeHeight = 92;
3406
+ const colGap = 130;
3271
3407
  const rowGap = 34;
3272
- const marginX = 36;
3273
- const marginY = 24;
3408
+ const marginX = 40;
3409
+ const marginY = 28;
3274
3410
 
3275
3411
  const positions = new Map();
3276
- let maxRows = 0;
3412
+ let maxRows = 1;
3277
3413
  levels.forEach(([, nodes], colIdx) => {
3278
3414
  maxRows = Math.max(maxRows, nodes.length);
3279
3415
  nodes.forEach((node, rowIdx) => {
@@ -3283,8 +3419,8 @@ function DagGraphSection({
3283
3419
  });
3284
3420
  });
3285
3421
 
3286
- const totalWidth = Math.max(520, marginX * 2 + Math.max(1, levels.length) * nodeWidth + Math.max(0, levels.length - 1) * colGap);
3287
- const totalHeight = Math.max(220, marginY * 2 + Math.max(1, maxRows) * nodeHeight + Math.max(0, maxRows - 1) * rowGap);
3422
+ const totalWidth = Math.max(720, marginX * 2 + Math.max(1, levels.length) * nodeWidth + Math.max(0, levels.length - 1) * colGap);
3423
+ const totalHeight = Math.max(360, marginY * 2 + maxRows * nodeHeight + Math.max(0, maxRows - 1) * rowGap);
3288
3424
  return { positions, totalWidth, totalHeight };
3289
3425
  }, [levels]);
3290
3426
 
@@ -3304,24 +3440,102 @@ function DagGraphSection({
3304
3440
  .filter(Boolean);
3305
3441
  }, [graph?.edges, layout.positions]);
3306
3442
 
3307
- const edgeKindCounts = useMemo(() => {
3308
- const counts = { "depends-on": 0, sequential: 0, blocks: 0 };
3309
- for (const edge of edges) {
3310
- const kind = toText(edge?.kind || "depends-on", "depends-on").toLowerCase();
3311
- counts[kind] = (counts[kind] || 0) + 1;
3312
- }
3313
- return counts;
3314
- }, [edges]);
3443
+ const worldBounds = useMemo(() => ({ width: layout.totalWidth, height: layout.totalHeight }), [layout]);
3444
+
3445
+ const fitToView = useCallback(() => {
3446
+ const el = stageRef.current;
3447
+ if (!el) return;
3448
+ const rect = el.getBoundingClientRect();
3449
+ const availableWidth = Math.max(320, rect.width - 24);
3450
+ const availableHeight = Math.max(240, rect.height - 24);
3451
+ const scaleX = availableWidth / Math.max(1, worldBounds.width);
3452
+ const scaleY = availableHeight / Math.max(1, worldBounds.height);
3453
+ const nextZoom = Math.max(DAG_MIN_ZOOM, Math.min(DAG_MAX_ZOOM, Math.min(scaleX, scaleY)));
3454
+ const nextPanX = (rect.width - worldBounds.width * nextZoom) / 2;
3455
+ const nextPanY = (rect.height - worldBounds.height * nextZoom) / 2;
3456
+ setZoom(nextZoom);
3457
+ setPan({ x: nextPanX, y: nextPanY });
3458
+ }, [worldBounds.width, worldBounds.height]);
3315
3459
 
3316
- const statusCounts = useMemo(() => {
3317
- const counts = new Map();
3318
- for (const node of sortedNodes) {
3319
- const key = toText(node?.status || "todo", "todo").toLowerCase();
3320
- counts.set(key, (counts.get(key) || 0) + 1);
3321
- }
3322
- return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 4);
3460
+ useEffect(() => {
3461
+ fitToView();
3462
+ }, [fitToView, graphKey, sortedNodes.length, edges.length]);
3463
+
3464
+ const applyZoomAtPoint = useCallback((nextZoom, clientX, clientY) => {
3465
+ const el = stageRef.current;
3466
+ if (!el) return;
3467
+ const rect = el.getBoundingClientRect();
3468
+ const clamped = Math.max(DAG_MIN_ZOOM, Math.min(DAG_MAX_ZOOM, nextZoom));
3469
+ const localX = clientX - rect.left;
3470
+ const localY = clientY - rect.top;
3471
+ const worldX = (localX - pan.x) / zoom;
3472
+ const worldY = (localY - pan.y) / zoom;
3473
+ setZoom(clamped);
3474
+ setPan({ x: localX - worldX * clamped, y: localY - worldY * clamped });
3475
+ }, [pan.x, pan.y, zoom]);
3476
+
3477
+ const handleWheel = useCallback((event) => {
3478
+ event.preventDefault();
3479
+ const delta = event.deltaY < 0 ? 1.1 : 0.9;
3480
+ applyZoomAtPoint(zoom * delta, event.clientX, event.clientY);
3481
+ }, [applyZoomAtPoint, zoom]);
3482
+
3483
+ const handlePanStart = useCallback((event) => {
3484
+ if (event.button !== 0) return;
3485
+ const targetEl = event.target;
3486
+ if (targetEl?.closest?.(".dag-node")) return;
3487
+ event.preventDefault();
3488
+ const start = { x: event.clientX, y: event.clientY, panX: pan.x, panY: pan.y };
3489
+ setIsPanning(true);
3490
+ const onMove = (moveEvent) => {
3491
+ setPan({ x: start.panX + (moveEvent.clientX - start.x), y: start.panY + (moveEvent.clientY - start.y) });
3492
+ };
3493
+ const onUp = () => {
3494
+ setIsPanning(false);
3495
+ window.removeEventListener("pointermove", onMove);
3496
+ window.removeEventListener("pointerup", onUp);
3497
+ };
3498
+ window.addEventListener("pointermove", onMove);
3499
+ window.addEventListener("pointerup", onUp);
3500
+ }, [pan.x, pan.y]);
3501
+
3502
+ const nodeById = useMemo(() => {
3503
+ const map = new Map();
3504
+ for (const node of sortedNodes) map.set(String(node.id), node);
3505
+ return map;
3323
3506
  }, [sortedNodes]);
3324
3507
 
3508
+ const handleNodeClick = useCallback(async (node, event) => {
3509
+ event?.stopPropagation?.();
3510
+ if (allowWiring && typeof onCreateEdge === "function") {
3511
+ const id = String(node?.id || "");
3512
+ if (!id || wiringBusy) return;
3513
+ if (!wireSourceId) {
3514
+ setWireSourceId(id);
3515
+ return;
3516
+ }
3517
+ if (wireSourceId === id) {
3518
+ setWireSourceId("");
3519
+ return;
3520
+ }
3521
+ const sourceNode = nodeById.get(wireSourceId) || null;
3522
+ const targetNode = nodeById.get(id) || null;
3523
+ if (!sourceNode || !targetNode) {
3524
+ setWireSourceId("");
3525
+ return;
3526
+ }
3527
+ setWiringBusy(true);
3528
+ try {
3529
+ await onCreateEdge({ sourceNode, targetNode });
3530
+ } finally {
3531
+ setWireSourceId("");
3532
+ setWiringBusy(false);
3533
+ }
3534
+ return;
3535
+ }
3536
+ if (node?.taskId) onOpenTask?.(node.taskId);
3537
+ }, [allowWiring, onCreateEdge, onOpenTask, wireSourceId, nodeById, wiringBusy]);
3538
+
3325
3539
  if (!sortedNodes.length) {
3326
3540
  return html`
3327
3541
  <${Paper} variant="outlined" style=${{ padding: "12px", marginBottom: "10px" }}>
@@ -3332,90 +3546,93 @@ function DagGraphSection({
3332
3546
 
3333
3547
  return html`
3334
3548
  <div class="tasks-dag-section">
3335
- <div class="flex-between" style=${{ marginBottom: "8px", gap: "8px", flexWrap: "wrap" }}>
3549
+ <div class="task-dag-header-row">
3336
3550
  <div>
3337
3551
  <div style=${{ fontWeight: "700" }}>${title || "Task DAG"}</div>
3338
3552
  ${description && html`<div class="meta-text">${description}</div>`}
3553
+ <div class="meta-text">Drag to pan · wheel to zoom · click node to ${allowWiring ? "wire edges" : "open task"}.</div>
3339
3554
  </div>
3340
- <div class="chip-group" style=${{ gap: "6px" }}>
3341
- <${Chip} size="small" label=${`${sortedNodes.length} nodes`} />
3342
- <${Chip} size="small" label=${`${edges.length} edges`} />
3343
- <${Chip} size="small" label=${`depends-on ${edgeKindCounts["depends-on"] || 0}`} />
3344
- <${Chip} size="small" label=${`sequential ${edgeKindCounts.sequential || 0}`} />
3345
- <${Chip} size="small" label=${`blocks ${edgeKindCounts.blocks || 0}`} />
3555
+ <div class="task-dag-controls">
3556
+ <${Button} size="small" variant="outlined" onClick=${() => setZoom((z) => Math.max(DAG_MIN_ZOOM, z * 0.9))}>-</${Button}>
3557
+ <${Button} size="small" variant="outlined" onClick=${() => setZoom((z) => Math.min(DAG_MAX_ZOOM, z * 1.1))}>+</${Button}>
3558
+ <${Button} size="small" variant="outlined" onClick=${fitToView}>Fit</${Button}>
3559
+ <${Button} size="small" variant="text" onClick=${() => { setZoom(1); setPan({ x: 24, y: 24 }); }}>Reset</${Button}>
3560
+ <span class="task-dag-zoom-pill">${Math.round(zoom * 100)}%</span>
3561
+ ${allowWiring && html`<span class="task-dag-wire-pill">${wireSourceId ? `Source: ${wireSourceId}` : wiringBusy ? "Saving edge…" : "Wiring: click source then target"}</span>`}
3346
3562
  </div>
3347
3563
  </div>
3348
3564
  <div class="task-dag-legend">
3349
3565
  <span class="task-dag-legend-item"><span class="task-dag-legend-line" style=${{ background: "var(--accent)" }}></span>depends-on</span>
3350
3566
  <span class="task-dag-legend-item"><span class="task-dag-legend-line task-dag-legend-line-dashed"></span>sequential</span>
3351
3567
  <span class="task-dag-legend-item"><span class="task-dag-legend-line task-dag-legend-line-block"></span>blocks</span>
3352
- ${statusCounts.map(([status, count]) => html`<span class="task-dag-status-pill">${status} ${count}</span>`)}
3353
3568
  </div>
3354
- <div class="task-dag-canvas-wrap">
3355
- <svg class="task-dag-canvas" viewBox=${`0 0 ${layout.totalWidth} ${layout.totalHeight}`} role="img" aria-label="Task dependency graph">
3569
+ <div class=${`task-dag-canvas-wrap ${isPanning ? "is-panning" : ""}`} ref=${stageRef} onWheel=${handleWheel} onPointerDown=${handlePanStart}>
3570
+ <svg class="task-dag-canvas" role="img" aria-label="Task dependency graph">
3356
3571
  <defs>
3357
- <marker id="dag-arrow" markerWidth="10" markerHeight="8" refX="10" refY="4" orient="auto">
3572
+ <marker id=${`dag-arrow-${graphKey}`} markerWidth="10" markerHeight="8" refX="10" refY="4" orient="auto">
3358
3573
  <path d="M0,0 L10,4 L0,8 z" fill="var(--accent)" />
3359
3574
  </marker>
3360
3575
  </defs>
3361
- ${edges.map(({ source, target, kind }, idx) => {
3362
- const x1 = source.x + source.width;
3363
- const y1 = source.y + source.height / 2;
3364
- const x2 = target.x;
3365
- const y2 = target.y + target.height / 2;
3366
- const c1 = x1 + Math.max(40, (x2 - x1) * 0.35);
3367
- const c2 = x2 - Math.max(30, (x2 - x1) * 0.35);
3368
- return html`
3369
- <path
3370
- key=${`edge-${idx}`}
3371
- d=${`M ${x1} ${y1} C ${c1} ${y1}, ${c2} ${y2}, ${x2} ${y2}`}
3372
- fill="none"
3373
- stroke=${DAG_EDGE_STYLES[kind]?.color || "var(--accent)"}
3374
- stroke-dasharray=${DAG_EDGE_STYLES[kind]?.dash || ""}
3375
- stroke-opacity="0.72"
3376
- stroke-width="2"
3377
- marker-end="url(#dag-arrow)"
3378
- />
3379
- `;
3380
- })}
3381
- ${sortedNodes.map((node) => {
3382
- const pos = layout.positions.get(String(node.id));
3383
- if (!pos) return null;
3384
- return html`
3385
- <g
3386
- key=${node.id}
3387
- class="dag-node"
3388
- onClick=${() => {
3389
- if (node.taskId) onOpenTask?.(node.taskId);
3390
- }}
3391
- style=${{ cursor: node.taskId ? "pointer" : "default" }}
3392
- >
3393
- <rect
3394
- x=${pos.x}
3395
- y=${pos.y}
3396
- width=${pos.width}
3397
- height=${pos.height}
3398
- rx="14"
3399
- ry="14"
3400
- fill="var(--bg-surface)"
3401
- stroke="var(--border)"
3402
- stroke-width="1.5"
3576
+ <g transform=${`translate(${pan.x} ${pan.y}) scale(${zoom})`}>
3577
+ <rect x="0" y="0" width=${worldBounds.width} height=${worldBounds.height} fill="transparent" />
3578
+ ${edges.map(({ source, target, kind }, idx) => {
3579
+ const x1 = source.x + source.width;
3580
+ const y1 = source.y + source.height / 2;
3581
+ const x2 = target.x;
3582
+ const y2 = target.y + target.height / 2;
3583
+ const c1 = x1 + Math.max(40, (x2 - x1) * 0.35);
3584
+ const c2 = x2 - Math.max(30, (x2 - x1) * 0.35);
3585
+ const style = DAG_EDGE_STYLES[kind] || DAG_EDGE_STYLES["depends-on"];
3586
+ return html`
3587
+ <path
3588
+ key=${`edge-${idx}`}
3589
+ d=${`M ${x1} ${y1} C ${c1} ${y1}, ${c2} ${y2}, ${x2} ${y2}`}
3590
+ fill="none"
3591
+ stroke=${style.color}
3592
+ stroke-dasharray=${style.dash || ""}
3593
+ stroke-opacity="0.75"
3594
+ stroke-width="2"
3595
+ marker-end=${`url(#dag-arrow-${graphKey})`}
3403
3596
  />
3404
- <text x=${pos.x + 12} y=${pos.y + 24} fill="var(--text-primary)" font-size="13" font-weight="700">
3405
- ${truncate(node.title || "(untitled)", 32)}
3406
- </text>
3407
- <text x=${pos.x + 12} y=${pos.y + 43} fill="var(--text-muted)" font-size="11">
3408
- ${truncate(node.taskId || node.id, 36)}
3409
- </text>
3410
- <text x=${pos.x + 12} y=${pos.y + 62} fill="var(--accent)" font-size="11">
3411
- ${String(node.status || "todo")}
3412
- </text>
3413
- ${Number.isFinite(node.order) && html`
3414
- <text x=${pos.x + pos.width - 16} y=${pos.y + 22} text-anchor="end" fill="var(--text-muted)" font-size="11">#${node.order}</text>
3415
- `}
3416
- </g>
3417
- `;
3418
- })}
3597
+ `;
3598
+ })}
3599
+ ${sortedNodes.map((node) => {
3600
+ const pos = layout.positions.get(String(node.id));
3601
+ if (!pos) return null;
3602
+ const selected = wireSourceId && String(node.id) === wireSourceId;
3603
+ return html`
3604
+ <g
3605
+ key=${node.id}
3606
+ class=${`dag-node ${selected ? "dag-node-selected" : ""}`}
3607
+ onPointerDown=${(event) => event.stopPropagation()}
3608
+ onClick=${(event) => handleNodeClick(node, event)}
3609
+ style=${{ cursor: allowWiring || node.taskId ? "pointer" : "default" }}
3610
+ >
3611
+ <rect
3612
+ x=${pos.x}
3613
+ y=${pos.y}
3614
+ width=${pos.width}
3615
+ height=${pos.height}
3616
+ rx="14"
3617
+ ry="14"
3618
+ fill="var(--bg-surface)"
3619
+ stroke=${selected ? "var(--accent)" : "var(--border)"}
3620
+ stroke-width=${selected ? "2.2" : "1.5"}
3621
+ />
3622
+ <text x=${pos.x + 12} y=${pos.y + 24} fill="var(--text-primary)" font-size="13" font-weight="700">
3623
+ ${truncate(node.title || "(untitled)", 34)}
3624
+ </text>
3625
+ <text x=${pos.x + 12} y=${pos.y + 44} fill="var(--text-muted)" font-size="11">
3626
+ ${truncate(node.taskId || node.id, 38)}
3627
+ </text>
3628
+ <text x=${pos.x + 12} y=${pos.y + 64} fill="var(--accent)" font-size="11">
3629
+ ${String(node.status || "todo")}
3630
+ </text>
3631
+ ${Number.isFinite(node.order) && html`<text x=${pos.x + pos.width - 16} y=${pos.y + 22} text-anchor="end" fill="var(--text-muted)" font-size="11">#${node.order}</text>`}
3632
+ </g>
3633
+ `;
3634
+ })}
3635
+ </g>
3419
3636
  </svg>
3420
3637
  </div>
3421
3638
  </div>
@@ -3443,7 +3660,8 @@ export function TasksTab() {
3443
3660
  const [dagSelectedSprint, setDagSelectedSprint] = useState("all");
3444
3661
  const [dagSprintGraph, setDagSprintGraph] = useState(EMPTY_DAG_GRAPH);
3445
3662
  const [dagGlobalGraph, setDagGlobalGraph] = useState(EMPTY_DAG_GRAPH);
3446
- const [dagSources, setDagSources] = useState({ sprints: "", sprintGraph: "", globalGraph: "" });
3663
+ const [dagEpicGraph, setDagEpicGraph] = useState(EMPTY_DAG_GRAPH);
3664
+ const [dagSources, setDagSources] = useState({ sprints: "", sprintGraph: "", globalGraph: "", epicDeps: "", tasks: "" });
3447
3665
  const [dagSprintOrderMode, setDagSprintOrderMode] = useState("parallel");
3448
3666
  const [isCompact, setIsCompact] = useState(() => {
3449
3667
  try { return globalThis.matchMedia?.("(max-width: 768px)")?.matches ?? false; }
@@ -3542,6 +3760,8 @@ export function TasksTab() {
3542
3760
 
3543
3761
  const sprintGraphMeta = await fetchFirstAvailableDagPath(sprintGraphCandidates);
3544
3762
  const globalGraphMeta = await fetchFirstAvailableDagPath(globalGraphCandidates);
3763
+ const epicDepsMeta = await fetchFirstAvailableDagPath(DAG_EPIC_DEPENDENCY_ENDPOINT_CANDIDATES);
3764
+ const tasksMeta = await fetchFirstAvailableDagPath(["/api/tasks?limit=1000", "/api/tasks?limit=500"]);
3545
3765
 
3546
3766
  const globalSource =
3547
3767
  extractGlobalDagPayload(
@@ -3556,15 +3776,29 @@ export function TasksTab() {
3556
3776
  );
3557
3777
  const nextGlobalGraph = normalizeDagGraph(globalSource, "DAG of DAGs");
3558
3778
 
3779
+ const allTasksPayload = extractDagPayload(tasksMeta?.payload);
3780
+ const allTasks = Array.isArray(allTasksPayload?.data)
3781
+ ? allTasksPayload.data
3782
+ : Array.isArray(allTasksPayload?.tasks)
3783
+ ? allTasksPayload.tasks
3784
+ : Array.isArray(allTasksPayload)
3785
+ ? allTasksPayload
3786
+ : tasks;
3787
+ const epicDeps = normalizeEpicDependenciesPayload(epicDepsMeta?.payload);
3788
+ const nextEpicGraph = buildEpicDagGraph(allTasks, epicDeps);
3789
+
3559
3790
  const sprintMetaEntry = sprintOptions.find((entry) => entry.id === resolvedSprint) || null;
3560
3791
  setDagSprintOrderMode(toText(sprintMetaEntry?.executionMode || sprintMetaEntry?.taskOrderMode || sprintMetaEntry?.sprintOrderMode || "parallel", "parallel"));
3561
3792
  setDagSprints(sprintOptions);
3562
3793
  setDagSprintGraph(nextSprintGraph);
3563
3794
  setDagGlobalGraph(nextGlobalGraph);
3795
+ setDagEpicGraph(nextEpicGraph);
3564
3796
  setDagSources({
3565
3797
  sprints: sprintMeta?.path || "",
3566
3798
  sprintGraph: sprintGraphMeta?.path || "",
3567
3799
  globalGraph: globalGraphMeta?.path || "",
3800
+ epicDeps: epicDepsMeta?.path || "",
3801
+ tasks: tasksMeta?.path || "",
3568
3802
  });
3569
3803
 
3570
3804
  if (resolvedSprint !== dagSelectedSprint) {
@@ -3573,11 +3807,12 @@ export function TasksTab() {
3573
3807
 
3574
3808
  const hasAnyGraphData =
3575
3809
  nextSprintGraph.nodes.length > 0 ||
3576
- nextGlobalGraph.nodes.length > 0;
3810
+ nextGlobalGraph.nodes.length > 0 ||
3811
+ nextEpicGraph.nodes.length > 0;
3577
3812
  if (!hasAnyGraphData) {
3578
3813
  throw new Error("No DAG data was returned from DAG endpoints.");
3579
3814
  }
3580
- }, [dagSelectedSprint]);
3815
+ }, [dagSelectedSprint, tasks]);
3581
3816
 
3582
3817
  useEffect(() => {
3583
3818
  if (!isDag) return;
@@ -3922,6 +4157,43 @@ export function TasksTab() {
3922
4157
  setDagError("Failed to update sprint execution mode.");
3923
4158
  }
3924
4159
  }, [dagSelectedSprint, loadDagViews]);
4160
+ const handleCreateDagEdge = useCallback(async ({ sourceNode, targetNode, graphKind }) => {
4161
+ const srcTaskId = toText(sourceNode?.taskId || sourceNode?.id);
4162
+ const dstTaskId = toText(targetNode?.taskId || targetNode?.id);
4163
+
4164
+ if (graphKind === "epic") {
4165
+ const srcEpic = toText(sourceNode?.epicId || sourceNode?.id);
4166
+ const dstEpic = toText(targetNode?.epicId || targetNode?.id);
4167
+ if (!srcEpic || !dstEpic || srcEpic === dstEpic) return;
4168
+ const existing = normalizeDependencyInput(targetNode?.dependencies || []);
4169
+ const dependencies = normalizeDependencyInput([...existing, srcEpic]);
4170
+ await apiFetch("/api/tasks/epic-dependencies", {
4171
+ method: "PUT",
4172
+ body: JSON.stringify({ epicId: dstEpic, dependencies }),
4173
+ });
4174
+ showToast(`Wired epic dependency: ${srcEpic} -> ${dstEpic}`, "success");
4175
+ await loadDagViews();
4176
+ return;
4177
+ }
4178
+
4179
+ if (!srcTaskId || !dstTaskId || srcTaskId === dstTaskId) return;
4180
+ const existing = normalizeDependencyInput(
4181
+ targetNode?.dependencies ||
4182
+ targetNode?.dependencyTaskIds ||
4183
+ [],
4184
+ );
4185
+ const dependencies = normalizeDependencyInput([...existing, srcTaskId]);
4186
+ await apiFetch("/api/tasks/dependencies", {
4187
+ method: "PUT",
4188
+ body: JSON.stringify({
4189
+ taskId: dstTaskId,
4190
+ dependencies,
4191
+ }),
4192
+ });
4193
+ showToast(`Wired dependency: ${srcTaskId} -> ${dstTaskId}`, "success");
4194
+ await loadDagViews();
4195
+ }, [loadDagViews]);
4196
+
3925
4197
  const handleSprintChange = useCallback((nextSprint) => {
3926
4198
  const sprintId = toText(nextSprint, "all");
3927
4199
  if (sprintId === dagSelectedSprint) return;
@@ -4539,6 +4811,7 @@ export function TasksTab() {
4539
4811
  <span class="snapshot-view-tag">${iconText(":link: DAG")}</span>
4540
4812
  <span class="pill">Sprint nodes: ${dagSprintGraph.nodes.length}</span>
4541
4813
  <span class="pill">Global nodes: ${dagGlobalGraph.nodes.length}</span>
4814
+ <span class="pill">Epic nodes: ${dagEpicGraph.nodes.length}</span>
4542
4815
  <span class="pill">Global edges: ${dagGlobalGraph.edges.length}</span>
4543
4816
  </div>
4544
4817
  `}
@@ -4589,16 +4862,31 @@ export function TasksTab() {
4589
4862
  title=${dagSprintGraph.title || (dagSelectedSprint === "all" ? "All Sprint DAG" : `Sprint ${dagSelectedSprint} DAG`)}
4590
4863
  description=${dagSprintGraph.description || "Task dependency order within the selected sprint."}
4591
4864
  graph=${dagSprintGraph}
4865
+ graphKey="sprint"
4592
4866
  onOpenTask=${openDetail}
4867
+ onCreateEdge={({ sourceNode, targetNode }) => handleCreateDagEdge({ sourceNode, targetNode, graphKind: "task" })}
4868
+ allowWiring=${true}
4593
4869
  emptyMessage="No sprint DAG data available yet."
4594
4870
  />
4595
4871
  <${DagGraphSection}
4596
4872
  title=${dagGlobalGraph.title || "Global DAG of DAGs"}
4597
4873
  description=${dagGlobalGraph.description || "Cross-sprint dependency overview."}
4598
4874
  graph=${dagGlobalGraph}
4875
+ graphKey="global"
4599
4876
  onOpenTask=${openDetail}
4877
+ onCreateEdge={({ sourceNode, targetNode }) => handleCreateDagEdge({ sourceNode, targetNode, graphKind: "task" })}
4878
+ allowWiring=${true}
4600
4879
  emptyMessage="No global DAG data available yet."
4601
4880
  />
4881
+ <${DagGraphSection}
4882
+ title=${dagEpicGraph.title || "Epic Dependency DAG"}
4883
+ description=${dagEpicGraph.description || "Epics and their run prerequisites."}
4884
+ graph=${dagEpicGraph}
4885
+ graphKey="epic"
4886
+ onCreateEdge={({ sourceNode, targetNode }) => handleCreateDagEdge({ sourceNode, targetNode, graphKind: "epic" })}
4887
+ allowWiring=${true}
4888
+ emptyMessage="No epic DAG data available yet."
4889
+ />
4602
4890
  </div>
4603
4891
  `}
4604
4892
 
@@ -5196,6 +5484,8 @@ function CreateTaskModalInline({ onClose }) {
5196
5484
 
5197
5485
 
5198
5486
 
5487
+
5488
+
5199
5489
 
5200
5490
 
5201
5491