bosun 0.40.2 → 0.40.4

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,291 @@ 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
+ }
693
+ function slugifyPlanningId(value, fallback = "item") {
694
+ const normalized = String(value || "")
695
+ .trim()
696
+ .toLowerCase()
697
+ .replace(/[^a-z0-9]+/g, "-")
698
+ .replace(/^-+|-+$/g, "");
699
+ return normalized || fallback;
700
+ }
701
+
702
+ function normalizeTaskTypeValue(value, fallback = "task") {
703
+ const normalized = toText(value, fallback).toLowerCase();
704
+ return ["epic", "task", "subtask"].includes(normalized) ? normalized : fallback;
705
+ }
706
+
707
+ function normalizeTaskStatusValue(value) {
708
+ return toText(value).toLowerCase();
709
+ }
710
+
711
+ function isTerminalTaskStatus(value) {
712
+ return ["done", "completed", "closed", "merged", "cancelled"].includes(normalizeTaskStatusValue(value));
713
+ }
714
+
715
+ function isQueuedTask(task) {
716
+ const runtime = getTaskRuntimeSnapshot(task);
717
+ return runtime?.state === "queued" || normalizeTaskStatusValue(task?.status) === "queued";
718
+ }
719
+
720
+ function isBacklogDraftTask(task) {
721
+ const status = normalizeTaskStatusValue(task?.status);
722
+ return status === "draft" || status === "todo" || status === "backlog" || status === "planned" || status === "";
723
+ }
724
+
725
+ function isExecutionTask(task) {
726
+ return isActiveStatus(task?.status) || isReviewStatus(task?.status) || isQueuedTask(task);
727
+ }
728
+
729
+ function filterDagGraphByIds(graph = EMPTY_DAG_GRAPH, allowedIds = null, resolver = (node) => toText(node?.id || node?.taskId)) {
730
+ if (!allowedIds || allowedIds === "all") return graph;
731
+ const allowed = allowedIds instanceof Set ? allowedIds : new Set(Array.isArray(allowedIds) ? allowedIds : []);
732
+ const nodes = (graph?.nodes || []).filter((node) => allowed.has(resolver(node)) || allowed.has(toText(node?.id || node?.taskId)));
733
+ const nodeIds = new Set(nodes.map((node) => toText(node?.id || node?.taskId)).filter(Boolean));
734
+ const edges = (graph?.edges || []).filter((edge) => nodeIds.has(toText(edge?.source || edge?.from)) && nodeIds.has(toText(edge?.target || edge?.to)));
735
+ return { ...graph, nodes, edges };
736
+ }
737
+
738
+ function buildEpicCatalog(tasks = [], epicDependencies = []) {
739
+ const catalog = new Map();
740
+ const ensureEpic = (epicId) => {
741
+ const id = toText(epicId);
742
+ if (!id) return null;
743
+ if (!catalog.has(id)) {
744
+ catalog.set(id, {
745
+ id,
746
+ label: id,
747
+ taskIds: [],
748
+ dependencies: [],
749
+ completedCount: 0,
750
+ activeCount: 0,
751
+ });
752
+ }
753
+ return catalog.get(id);
754
+ };
755
+ for (const task of tasks || []) {
756
+ const epicId = toText(task?.epicId || task?.meta?.epicId);
757
+ if (!epicId) continue;
758
+ const entry = ensureEpic(epicId);
759
+ if (!entry) continue;
760
+ entry.taskIds.push(task.id);
761
+ if (normalizeTaskTypeValue(task?.type) === "epic") {
762
+ entry.label = toText(task?.title, epicId);
763
+ entry.anchorTaskId = task.id;
764
+ }
765
+ if (isTerminalTaskStatus(task?.status)) entry.completedCount += 1;
766
+ if (isExecutionTask(task)) entry.activeCount += 1;
767
+ }
768
+ for (const row of epicDependencies || []) {
769
+ const entry = ensureEpic(row?.epicId);
770
+ if (!entry) continue;
771
+ entry.dependencies = normalizeDependencyInput(row?.dependencies || []);
772
+ }
773
+ return [...catalog.values()]
774
+ .map((entry) => ({
775
+ ...entry,
776
+ taskIds: normalizeDependencyInput(entry.taskIds),
777
+ taskCount: normalizeDependencyInput(entry.taskIds).length,
778
+ }))
779
+ .sort((a, b) => String(a.label || a.id).localeCompare(String(b.label || b.id)));
780
+ }
781
+
782
+ function buildDagPlanningState({ tasks = [], sprintId = "all", sprintOrderMode = "parallel", sprintOptions = [], epicDependencies = [] }) {
783
+ const scopedTasks = (tasks || []).filter((task) => sprintId === "all" ? true : getTaskSprintId(task) === sprintId);
784
+ const allTaskMap = new Map((tasks || []).map((task) => [toText(task?.id), task]));
785
+ const scopedTaskIds = new Set(scopedTasks.map((task) => toText(task?.id)).filter(Boolean));
786
+ const sprintModeMap = new Map((sprintOptions || []).map((sprint) => [sprint.id, toText(sprint.executionMode || sprint.taskOrderMode || 'parallel', 'parallel')]));
787
+ const epicDependencyMap = new Map((epicDependencies || []).map((row) => [toText(row?.epicId), normalizeDependencyInput(row?.dependencies || [])]));
788
+ const tasksByEpic = new Map();
789
+ for (const task of tasks || []) {
790
+ const epicId = toText(task?.epicId || task?.meta?.epicId);
791
+ if (!epicId) continue;
792
+ const list = tasksByEpic.get(epicId) || [];
793
+ list.push(task);
794
+ tasksByEpic.set(epicId, list);
795
+ }
796
+ const isSequentialBlocked = (task) => {
797
+ const taskSprintId = getTaskSprintId(task);
798
+ const mode = sprintModeMap.get(taskSprintId) || sprintOrderMode;
799
+ if (mode !== 'sequential') return false;
800
+ const currentOrder = Number(getTaskSprintOrder(task));
801
+ if (!Number.isFinite(currentOrder) || currentOrder <= 1) return false;
802
+ return scopedTasks.some((candidate) => getTaskSprintId(candidate) === taskSprintId && Number(getTaskSprintOrder(candidate)) < currentOrder && !isTerminalTaskStatus(candidate?.status));
803
+ };
804
+ const isEpicBlocked = (task) => {
805
+ const epicId = toText(task?.epicId || task?.meta?.epicId);
806
+ if (!epicId) return false;
807
+ const requiredEpics = epicDependencyMap.get(epicId) || [];
808
+ if (!requiredEpics.length) return false;
809
+ return requiredEpics.some((requiredEpicId) => {
810
+ const epicTasks = tasksByEpic.get(requiredEpicId) || [];
811
+ if (!epicTasks.length) return true;
812
+ return epicTasks.some((candidate) => !isTerminalTaskStatus(candidate?.status));
813
+ });
814
+ };
815
+ const isDependencyBlocked = (task) => getTaskDependencyIds(task).some((depId) => {
816
+ const dependencyTask = allTaskMap.get(depId);
817
+ return !dependencyTask || !isTerminalTaskStatus(dependencyTask?.status);
818
+ });
819
+ const backlogTaskIds = new Set();
820
+ const executionTaskIds = new Set();
821
+ const readyTaskIds = new Set();
822
+ const sprintIdsByFocus = { all: new Set(), backlog: new Set(), execution: new Set(), ready: new Set() };
823
+ const epicIdsByFocus = { all: new Set(), backlog: new Set(), execution: new Set(), ready: new Set() };
824
+ for (const task of scopedTasks) {
825
+ const taskId = toText(task?.id);
826
+ const taskSprintId = getTaskSprintId(task);
827
+ const epicId = toText(task?.epicId || task?.meta?.epicId);
828
+ sprintIdsByFocus.all.add(taskSprintId || 'unassigned');
829
+ if (epicId) epicIdsByFocus.all.add(epicId);
830
+ if (isBacklogDraftTask(task)) {
831
+ backlogTaskIds.add(taskId);
832
+ sprintIdsByFocus.backlog.add(taskSprintId || 'unassigned');
833
+ if (epicId) epicIdsByFocus.backlog.add(epicId);
834
+ }
835
+ if (isExecutionTask(task)) {
836
+ executionTaskIds.add(taskId);
837
+ sprintIdsByFocus.execution.add(taskSprintId || 'unassigned');
838
+ if (epicId) epicIdsByFocus.execution.add(epicId);
839
+ }
840
+ const ready = !isBacklogDraftTask(task) && !isExecutionTask(task) && !isTerminalTaskStatus(task?.status) && !isDependencyBlocked(task) && !isEpicBlocked(task) && !isSequentialBlocked(task);
841
+ if (ready) {
842
+ readyTaskIds.add(taskId);
843
+ sprintIdsByFocus.ready.add(taskSprintId || 'unassigned');
844
+ if (epicId) epicIdsByFocus.ready.add(epicId);
845
+ }
846
+ }
847
+ return {
848
+ scopedTaskIds,
849
+ backlogTaskIds,
850
+ executionTaskIds,
851
+ readyTaskIds,
852
+ sprintIdsByFocus,
853
+ epicIdsByFocus,
854
+ epicCatalog: buildEpicCatalog(tasks, epicDependencies),
855
+ counts: {
856
+ all: scopedTaskIds.size,
857
+ backlog: backlogTaskIds.size,
858
+ execution: executionTaskIds.size,
859
+ ready: readyTaskIds.size,
860
+ },
861
+ };
862
+ }
863
+
565
864
  function extractGlobalDagPayload(...sources) {
566
865
  for (const source of sources) {
567
866
  const payload = extractDagPayload(source);
@@ -802,7 +1101,9 @@ async function applyTaskLifecycleTransition(task, requestedStatus) {
802
1101
 
803
1102
  const wantsDraft = decision.nextStatus === "draft";
804
1103
  const prevTasks = cloneValue(tasksData.value);
805
- const optimisticStatus = decision.action === "start" ? "inprogress" : decision.nextStatus;
1104
+ const optimisticStatus = decision.action === "start"
1105
+ ? String(task?.status || "todo")
1106
+ : decision.nextStatus;
806
1107
  let apiResult = null;
807
1108
 
808
1109
  await runOptimistic(
@@ -1435,6 +1736,18 @@ function isReviewStatus(s) {
1435
1736
  return ["inreview", "review", "pr-open", "pr-review"].includes(String(s || ""));
1436
1737
  }
1437
1738
 
1739
+ function getTaskRuntimeSnapshot(task) {
1740
+ return task?.runtimeSnapshot || task?.meta?.runtimeSnapshot || null;
1741
+ }
1742
+
1743
+ function hasLiveExecutionEvidence(task) {
1744
+ const runtime = getTaskRuntimeSnapshot(task);
1745
+ if (runtime?.isLive === true) return true;
1746
+ if (runtime?.state === "running") return true;
1747
+ if (runtime?.slot?.taskId) return true;
1748
+ return false;
1749
+ }
1750
+
1438
1751
  async function reactivateTaskSession(taskId, options = {}) {
1439
1752
  const normalizedTaskId = String(taskId || "").trim();
1440
1753
  if (!normalizedTaskId) return false;
@@ -1462,41 +1775,20 @@ async function reactivateTaskSession(taskId, options = {}) {
1462
1775
  showToast("Agent session reactivated", "success");
1463
1776
  }
1464
1777
 
1778
+ const nextStatus = String(res?.data?.status || (res?.queued ? "queued" : "inprogress")).trim() || "todo";
1465
1779
  tasksData.value = (tasksData.value || []).map((t) =>
1466
1780
  String(t?.id || "").trim() === normalizedTaskId
1467
- ? { ...t, status: "inprogress" }
1781
+ ? {
1782
+ ...t,
1783
+ ...(res?.data || {}),
1784
+ status: nextStatus,
1785
+ }
1468
1786
  : t,
1469
1787
  );
1470
1788
  scheduleRefresh(150);
1471
1789
  return true;
1472
1790
  }
1473
1791
 
1474
- /* ─── Derive agent steps from task title/description ─── */
1475
- function deriveSteps(task) {
1476
- const t = String(task?.title || "").toLowerCase();
1477
- const steps = [];
1478
- if (/feat|add|implement|build|create/.test(t)) steps.push({ label: "Understand requirements & read context" });
1479
- if (/fix|bug|patch|resolve|repair/.test(t)) steps.push({ label: "Reproduce and diagnose issue" });
1480
- if (/refactor|restructure|cleanup|reorganize/.test(t)) steps.push({ label: "Map current code structure" });
1481
- if (/test|spec|coverage/.test(t)) steps.push({ label: "Write test cases" });
1482
- if (/docs|document|readme/.test(t)) steps.push({ label: "Draft documentation content" });
1483
- steps.push({ label: "Write implementation" });
1484
- if (!/docs|readme/.test(t)) steps.push({ label: "Run tests & fix issues" });
1485
- steps.push({ label: "Commit changes" });
1486
- steps.push({ label: "Handoff PR lifecycle to Bosun" });
1487
- return steps;
1488
- }
1489
-
1490
- /* ─── Estimate step progress from timing ─── */
1491
- function estimateStep(task, steps) {
1492
- const elapsed = task?.updated ? (Date.now() - new Date(task.updated).getTime()) : 0;
1493
- const totalDur = task?.created ? (Date.now() - new Date(task.created).getTime()) : 60000;
1494
- const pct = Math.min(0.85, totalDur > 0 ? (elapsed / totalDur) : 0);
1495
- // Bias: show 40-75% through to look realistic
1496
- const biasedPct = 0.35 + pct * 0.4;
1497
- return Math.max(0, Math.min(steps.length - 1, Math.floor(biasedPct * steps.length)));
1498
- }
1499
-
1500
1792
  /* ─── TaskProgressModal — live view for in-progress tasks ─── */
1501
1793
  export function TaskProgressModal({ task, onClose }) {
1502
1794
  const [liveTask, setLiveTask] = useState(task);
@@ -1544,8 +1836,7 @@ export function TaskProgressModal({ task, onClose }) {
1544
1836
  logEndRef.current?.scrollIntoView({ behavior: "smooth" });
1545
1837
  }, [logs]);
1546
1838
 
1547
- const steps = useMemo(() => deriveSteps(liveTask || task), [liveTask?.id]);
1548
- const currentStep = useMemo(() => estimateStep(liveTask || task, steps), [liveTask?.updated]);
1839
+ const runtime = getTaskRuntimeSnapshot(liveTask || task);
1549
1840
 
1550
1841
  const healthScore = health?.currentHealthScore ?? health?.averageHealthScore ?? null;
1551
1842
  const healthColor =
@@ -1557,6 +1848,10 @@ export function TaskProgressModal({ task, onClose }) {
1557
1848
  const startedRelative = liveTask?.created ? formatRelative(liveTask.created) : "—";
1558
1849
  const agentLabel = liveTask?.assignee || task.assignee || "Agent";
1559
1850
  const branchLabel = liveTask?.branch || task.branch || "—";
1851
+ const runtimeState = runtime?.state || "pending";
1852
+ const runtimeLabel = runtime?.statusLabel || "Live execution";
1853
+ const activeSlots = Number(runtime?.executor?.activeSlots || 0);
1854
+ const maxParallel = Number(runtime?.executor?.maxParallel || 0);
1560
1855
 
1561
1856
  const handleCancel = async () => {
1562
1857
  const ok = await showConfirm("Cancel this task?");
@@ -1608,9 +1903,9 @@ export function TaskProgressModal({ task, onClose }) {
1608
1903
  <div class="tp-hero">
1609
1904
  <div class="tp-pulse-dot"></div>
1610
1905
  <div class="tp-hero-title">
1611
- <div class="tp-hero-status-label">${iconText(":zap: Active — Agent Working")}</div>
1906
+ <div class="tp-hero-status-label">${iconText(`:zap: ${runtimeLabel}`)}</div>
1612
1907
  </div>
1613
- <${Badge} status="inprogress" text="running" />
1908
+ <${Badge} status="inprogress" text=${runtimeState} />
1614
1909
  </div>
1615
1910
 
1616
1911
 
@@ -3229,8 +3524,19 @@ function DagGraphSection({
3229
3524
  description = "",
3230
3525
  graph = EMPTY_DAG_GRAPH,
3231
3526
  onOpenTask,
3527
+ onCreateEdge,
3528
+ allowWiring = false,
3529
+ graphKey = "dag",
3232
3530
  emptyMessage = "No DAG nodes available for this view yet.",
3531
+ highlightNodeIds = null,
3233
3532
  }) {
3533
+ const stageRef = useRef(null);
3534
+ const [zoom, setZoom] = useState(1);
3535
+ const [pan, setPan] = useState({ x: 24, y: 24 });
3536
+ const [isPanning, setIsPanning] = useState(false);
3537
+ const [wireSourceId, setWireSourceId] = useState("");
3538
+ const [wiringBusy, setWiringBusy] = useState(false);
3539
+
3234
3540
  const sortedNodes = useMemo(() => {
3235
3541
  const nodes = [...(graph?.nodes || [])];
3236
3542
  nodes.sort((a, b) => {
@@ -3258,22 +3564,20 @@ function DagGraphSection({
3258
3564
  list.push(node);
3259
3565
  map.set(key, list);
3260
3566
  }
3261
- if (!map.size && sortedNodes.length) {
3262
- map.set(0, [...sortedNodes]);
3263
- }
3567
+ if (!map.size && sortedNodes.length) map.set(0, [...sortedNodes]);
3264
3568
  return [...map.entries()].sort((a, b) => a[0] - b[0]);
3265
3569
  }, [sortedNodes]);
3266
3570
 
3267
3571
  const layout = useMemo(() => {
3268
- const nodeWidth = 220;
3269
- const nodeHeight = 84;
3270
- const colGap = 120;
3572
+ const nodeWidth = 250;
3573
+ const nodeHeight = 92;
3574
+ const colGap = 130;
3271
3575
  const rowGap = 34;
3272
- const marginX = 36;
3273
- const marginY = 24;
3576
+ const marginX = 40;
3577
+ const marginY = 28;
3274
3578
 
3275
3579
  const positions = new Map();
3276
- let maxRows = 0;
3580
+ let maxRows = 1;
3277
3581
  levels.forEach(([, nodes], colIdx) => {
3278
3582
  maxRows = Math.max(maxRows, nodes.length);
3279
3583
  nodes.forEach((node, rowIdx) => {
@@ -3283,8 +3587,8 @@ function DagGraphSection({
3283
3587
  });
3284
3588
  });
3285
3589
 
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);
3590
+ const totalWidth = Math.max(720, marginX * 2 + Math.max(1, levels.length) * nodeWidth + Math.max(0, levels.length - 1) * colGap);
3591
+ const totalHeight = Math.max(360, marginY * 2 + maxRows * nodeHeight + Math.max(0, maxRows - 1) * rowGap);
3288
3592
  return { positions, totalWidth, totalHeight };
3289
3593
  }, [levels]);
3290
3594
 
@@ -3304,24 +3608,109 @@ function DagGraphSection({
3304
3608
  .filter(Boolean);
3305
3609
  }, [graph?.edges, layout.positions]);
3306
3610
 
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]);
3611
+ const worldBounds = useMemo(() => ({ width: layout.totalWidth, height: layout.totalHeight }), [layout]);
3612
+
3613
+ const fitToView = useCallback(() => {
3614
+ const el = stageRef.current;
3615
+ if (!el) return;
3616
+ const rect = el.getBoundingClientRect();
3617
+ const availableWidth = Math.max(320, rect.width - 24);
3618
+ const availableHeight = Math.max(240, rect.height - 24);
3619
+ const scaleX = availableWidth / Math.max(1, worldBounds.width);
3620
+ const scaleY = availableHeight / Math.max(1, worldBounds.height);
3621
+ const nextZoom = Math.max(DAG_MIN_ZOOM, Math.min(DAG_MAX_ZOOM, Math.min(scaleX, scaleY)));
3622
+ const nextPanX = (rect.width - worldBounds.width * nextZoom) / 2;
3623
+ const nextPanY = (rect.height - worldBounds.height * nextZoom) / 2;
3624
+ setZoom(nextZoom);
3625
+ setPan({ x: nextPanX, y: nextPanY });
3626
+ }, [worldBounds.width, worldBounds.height]);
3315
3627
 
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);
3628
+ useEffect(() => {
3629
+ fitToView();
3630
+ }, [fitToView, graphKey, sortedNodes.length, edges.length]);
3631
+
3632
+ const applyZoomAtPoint = useCallback((nextZoom, clientX, clientY) => {
3633
+ const el = stageRef.current;
3634
+ if (!el) return;
3635
+ const rect = el.getBoundingClientRect();
3636
+ const clamped = Math.max(DAG_MIN_ZOOM, Math.min(DAG_MAX_ZOOM, nextZoom));
3637
+ const localX = clientX - rect.left;
3638
+ const localY = clientY - rect.top;
3639
+ const worldX = (localX - pan.x) / zoom;
3640
+ const worldY = (localY - pan.y) / zoom;
3641
+ setZoom(clamped);
3642
+ setPan({ x: localX - worldX * clamped, y: localY - worldY * clamped });
3643
+ }, [pan.x, pan.y, zoom]);
3644
+
3645
+ const handleWheel = useCallback((event) => {
3646
+ event.preventDefault();
3647
+ const delta = event.deltaY < 0 ? 1.1 : 0.9;
3648
+ applyZoomAtPoint(zoom * delta, event.clientX, event.clientY);
3649
+ }, [applyZoomAtPoint, zoom]);
3650
+
3651
+ const handlePanStart = useCallback((event) => {
3652
+ if (event.button !== 0) return;
3653
+ const targetEl = event.target;
3654
+ if (targetEl?.closest?.(".dag-node")) return;
3655
+ event.preventDefault();
3656
+ const start = { x: event.clientX, y: event.clientY, panX: pan.x, panY: pan.y };
3657
+ setIsPanning(true);
3658
+ const onMove = (moveEvent) => {
3659
+ setPan({ x: start.panX + (moveEvent.clientX - start.x), y: start.panY + (moveEvent.clientY - start.y) });
3660
+ };
3661
+ const onUp = () => {
3662
+ setIsPanning(false);
3663
+ window.removeEventListener("pointermove", onMove);
3664
+ window.removeEventListener("pointerup", onUp);
3665
+ };
3666
+ window.addEventListener("pointermove", onMove);
3667
+ window.addEventListener("pointerup", onUp);
3668
+ }, [pan.x, pan.y]);
3669
+
3670
+ const nodeById = useMemo(() => {
3671
+ const map = new Map();
3672
+ for (const node of sortedNodes) map.set(String(node.id), node);
3673
+ return map;
3323
3674
  }, [sortedNodes]);
3324
3675
 
3676
+ const highlightedIds = useMemo(() => {
3677
+ if (!highlightNodeIds || highlightNodeIds === "all") return new Set();
3678
+ return highlightNodeIds instanceof Set
3679
+ ? highlightNodeIds
3680
+ : new Set(Array.isArray(highlightNodeIds) ? highlightNodeIds : []);
3681
+ }, [highlightNodeIds]);
3682
+
3683
+ const handleNodeClick = useCallback(async (node, event) => {
3684
+ event?.stopPropagation?.();
3685
+ if (allowWiring && typeof onCreateEdge === "function") {
3686
+ const id = String(node?.id || "");
3687
+ if (!id || wiringBusy) return;
3688
+ if (!wireSourceId) {
3689
+ setWireSourceId(id);
3690
+ return;
3691
+ }
3692
+ if (wireSourceId === id) {
3693
+ setWireSourceId("");
3694
+ return;
3695
+ }
3696
+ const sourceNode = nodeById.get(wireSourceId) || null;
3697
+ const targetNode = nodeById.get(id) || null;
3698
+ if (!sourceNode || !targetNode) {
3699
+ setWireSourceId("");
3700
+ return;
3701
+ }
3702
+ setWiringBusy(true);
3703
+ try {
3704
+ await onCreateEdge({ sourceNode, targetNode });
3705
+ } finally {
3706
+ setWireSourceId("");
3707
+ setWiringBusy(false);
3708
+ }
3709
+ return;
3710
+ }
3711
+ if (node?.taskId) onOpenTask?.(node.taskId);
3712
+ }, [allowWiring, onCreateEdge, onOpenTask, wireSourceId, nodeById, wiringBusy]);
3713
+
3325
3714
  if (!sortedNodes.length) {
3326
3715
  return html`
3327
3716
  <${Paper} variant="outlined" style=${{ padding: "12px", marginBottom: "10px" }}>
@@ -3332,90 +3721,97 @@ function DagGraphSection({
3332
3721
 
3333
3722
  return html`
3334
3723
  <div class="tasks-dag-section">
3335
- <div class="flex-between" style=${{ marginBottom: "8px", gap: "8px", flexWrap: "wrap" }}>
3724
+ <div class="task-dag-header-row">
3336
3725
  <div>
3337
3726
  <div style=${{ fontWeight: "700" }}>${title || "Task DAG"}</div>
3338
- ${description && html`<div class="meta-text">${description}</div>`}
3727
+ ${description ? html`<div class="meta-text">${description}</div>` : null}
3728
+ <div class="meta-text">Drag to pan · wheel to zoom · click node to ${allowWiring ? "wire edges" : "open task"}.</div>
3339
3729
  </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}`} />
3730
+ <div class="task-dag-controls">
3731
+ <${Button} size="small" variant="outlined" onClick=${() => setZoom((z) => Math.max(DAG_MIN_ZOOM, z * 0.9))}>-</${Button}>
3732
+ <${Button} size="small" variant="outlined" onClick=${() => setZoom((z) => Math.min(DAG_MAX_ZOOM, z * 1.1))}>+</${Button}>
3733
+ <${Button} size="small" variant="outlined" onClick=${fitToView}>Fit</${Button}>
3734
+ <${Button} size="small" variant="text" onClick=${() => { setZoom(1); setPan({ x: 24, y: 24 }); }}>Reset</${Button}>
3735
+ <span class="task-dag-zoom-pill">${Math.round(zoom * 100)}%</span>
3736
+ ${allowWiring
3737
+ ? html`<span class="task-dag-wire-pill">${wireSourceId ? `Source: ${wireSourceId}` : wiringBusy ? "Saving edge…" : "Wiring: click source then target"}</span>`
3738
+ : null}
3346
3739
  </div>
3347
3740
  </div>
3348
3741
  <div class="task-dag-legend">
3349
3742
  <span class="task-dag-legend-item"><span class="task-dag-legend-line" style=${{ background: "var(--accent)" }}></span>depends-on</span>
3350
3743
  <span class="task-dag-legend-item"><span class="task-dag-legend-line task-dag-legend-line-dashed"></span>sequential</span>
3351
3744
  <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
3745
  </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">
3746
+ <div class=${`task-dag-canvas-wrap ${isPanning ? "is-panning" : ""}`} ref=${stageRef} onWheel=${handleWheel} onPointerDown=${handlePanStart}>
3747
+ <svg class="task-dag-canvas" role="img" aria-label="Task dependency graph">
3356
3748
  <defs>
3357
- <marker id="dag-arrow" markerWidth="10" markerHeight="8" refX="10" refY="4" orient="auto">
3749
+ <marker id=${`dag-arrow-${graphKey}`} markerWidth="10" markerHeight="8" refX="10" refY="4" orient="auto">
3358
3750
  <path d="M0,0 L10,4 L0,8 z" fill="var(--accent)" />
3359
3751
  </marker>
3360
3752
  </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"
3753
+ <g transform=${`translate(${pan.x} ${pan.y}) scale(${zoom})`}>
3754
+ <rect x="0" y="0" width=${worldBounds.width} height=${worldBounds.height} fill="transparent" />
3755
+ ${edges.map(({ source, target, kind }, idx) => {
3756
+ const x1 = source.x + source.width;
3757
+ const y1 = source.y + source.height / 2;
3758
+ const x2 = target.x;
3759
+ const y2 = target.y + target.height / 2;
3760
+ const c1 = x1 + Math.max(40, (x2 - x1) * 0.35);
3761
+ const c2 = x2 - Math.max(30, (x2 - x1) * 0.35);
3762
+ const style = DAG_EDGE_STYLES[kind] || DAG_EDGE_STYLES["depends-on"];
3763
+ return html`
3764
+ <path
3765
+ key=${`edge-${idx}`}
3766
+ d=${`M ${x1} ${y1} C ${c1} ${y1}, ${c2} ${y2}, ${x2} ${y2}`}
3767
+ fill="none"
3768
+ stroke=${style.color}
3769
+ stroke-dasharray=${style.dash || ""}
3770
+ stroke-opacity="0.75"
3771
+ stroke-width="2"
3772
+ marker-end=${`url(#dag-arrow-${graphKey})`}
3403
3773
  />
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
- })}
3774
+ `;
3775
+ })}
3776
+ ${sortedNodes.map((node) => {
3777
+ const pos = layout.positions.get(String(node.id));
3778
+ if (!pos) return null;
3779
+ const selected = wireSourceId && String(node.id) === wireSourceId;
3780
+ const highlighted = highlightedIds.has(String(node.id)) || highlightedIds.has(String(node.taskId || ""));
3781
+ return html`
3782
+ <g
3783
+ key=${node.id}
3784
+ class=${`dag-node ${selected ? "dag-node-selected" : ""} ${highlighted ? "dag-node-highlighted" : ""}`}
3785
+ onPointerDown=${(event) => event.stopPropagation()}
3786
+ onClick=${(event) => handleNodeClick(node, event)}
3787
+ style=${{ cursor: allowWiring || node.taskId ? "pointer" : "default" }}
3788
+ >
3789
+ <rect
3790
+ x=${pos.x}
3791
+ y=${pos.y}
3792
+ width=${pos.width}
3793
+ height=${pos.height}
3794
+ rx="14"
3795
+ ry="14"
3796
+ fill="var(--bg-surface)"
3797
+ stroke=${selected ? "var(--accent)" : highlighted ? "var(--color-done)" : "var(--border)"}
3798
+ stroke-width=${selected || highlighted ? "2.2" : "1.5"}
3799
+ />
3800
+ <text x=${pos.x + 12} y=${pos.y + 24} fill="var(--text-primary)" font-size="13" font-weight="700">
3801
+ ${truncate(node.title || "(untitled)", 34)}
3802
+ </text>
3803
+ <text x=${pos.x + 12} y=${pos.y + 44} fill="var(--text-muted)" font-size="11">
3804
+ ${truncate(node.taskId || node.id, 38)}
3805
+ </text>
3806
+ <text x=${pos.x + 12} y=${pos.y + 64} fill="var(--accent)" font-size="11">
3807
+ ${String(node.status || "todo")}
3808
+ </text>
3809
+ ${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>`}
3810
+ ${highlighted && html`<text x=${pos.x + pos.width - 12} y=${pos.y + pos.height - 12} text-anchor="end" fill="var(--color-done)" font-size="11" font-weight="700">Ready</text>`}
3811
+ </g>
3812
+ `;
3813
+ })}
3814
+ </g>
3419
3815
  </svg>
3420
3816
  </div>
3421
3817
  </div>
@@ -3443,8 +3839,14 @@ export function TasksTab() {
3443
3839
  const [dagSelectedSprint, setDagSelectedSprint] = useState("all");
3444
3840
  const [dagSprintGraph, setDagSprintGraph] = useState(EMPTY_DAG_GRAPH);
3445
3841
  const [dagGlobalGraph, setDagGlobalGraph] = useState(EMPTY_DAG_GRAPH);
3446
- const [dagSources, setDagSources] = useState({ sprints: "", sprintGraph: "", globalGraph: "" });
3842
+ const [dagEpicGraph, setDagEpicGraph] = useState(EMPTY_DAG_GRAPH);
3843
+ const [dagSources, setDagSources] = useState({ sprints: "", sprintGraph: "", globalGraph: "", epicDeps: "", tasks: "" });
3447
3844
  const [dagSprintOrderMode, setDagSprintOrderMode] = useState("parallel");
3845
+ const [dagAllTasks, setDagAllTasks] = useState([]);
3846
+ const [dagEpicDependencies, setDagEpicDependencies] = useState([]);
3847
+ const [dagFocusMode, setDagFocusMode] = useState("all");
3848
+ const [showCreateSprint, setShowCreateSprint] = useState(false);
3849
+ const [createSeed, setCreateSeed] = useState(null);
3448
3850
  const [isCompact, setIsCompact] = useState(() => {
3449
3851
  try { return globalThis.matchMedia?.("(max-width: 768px)")?.matches ?? false; }
3450
3852
  catch { return false; }
@@ -3512,6 +3914,21 @@ export function TasksTab() {
3512
3914
  const hasMoreKanbanPages = isKanban && page + 1 < totalPages;
3513
3915
  const boardColumnTotals = tasksStatusCounts?.value || { draft: 0, backlog: 0, inProgress: 0, inReview: 0, done: 0 };
3514
3916
  const boardTotalTasks = Number(tasksTotal?.value || 0);
3917
+ const dagTaskCatalog = dagAllTasks.length ? dagAllTasks : tasks;
3918
+ const dagPlanningState = useMemo(() => buildDagPlanningState({
3919
+ tasks: dagTaskCatalog,
3920
+ sprintId: dagSelectedSprint,
3921
+ sprintOrderMode: dagSprintOrderMode,
3922
+ sprintOptions: dagSprints,
3923
+ epicDependencies: dagEpicDependencies,
3924
+ }), [dagAllTasks, dagEpicDependencies, dagSelectedSprint, dagSprintOrderMode, dagSprints, tasks]);
3925
+ const dagEpicCatalog = dagPlanningState.epicCatalog;
3926
+ const dagFocusOptions = [
3927
+ { id: "all", label: "All structure", count: dagPlanningState.counts.all },
3928
+ { id: "backlog", label: "Backlog & draft", count: dagPlanningState.counts.backlog },
3929
+ { id: "execution", label: "Running & review", count: dagPlanningState.counts.execution },
3930
+ { id: "ready", label: "Ready next", count: dagPlanningState.counts.ready },
3931
+ ];
3515
3932
 
3516
3933
  const loadMoreKanbanTasks = useCallback(async () => {
3517
3934
  if (!isKanban || kanbanLoadingMore || isSearching) return;
@@ -3542,6 +3959,8 @@ export function TasksTab() {
3542
3959
 
3543
3960
  const sprintGraphMeta = await fetchFirstAvailableDagPath(sprintGraphCandidates);
3544
3961
  const globalGraphMeta = await fetchFirstAvailableDagPath(globalGraphCandidates);
3962
+ const epicDepsMeta = await fetchFirstAvailableDagPath(DAG_EPIC_DEPENDENCY_ENDPOINT_CANDIDATES);
3963
+ const tasksMeta = await fetchFirstAvailableDagPath(["/api/tasks?limit=1000", "/api/tasks?limit=500"]);
3545
3964
 
3546
3965
  const globalSource =
3547
3966
  extractGlobalDagPayload(
@@ -3556,15 +3975,31 @@ export function TasksTab() {
3556
3975
  );
3557
3976
  const nextGlobalGraph = normalizeDagGraph(globalSource, "DAG of DAGs");
3558
3977
 
3978
+ const allTasksPayload = extractDagPayload(tasksMeta?.payload);
3979
+ const allTasks = Array.isArray(allTasksPayload?.data)
3980
+ ? allTasksPayload.data
3981
+ : Array.isArray(allTasksPayload?.tasks)
3982
+ ? allTasksPayload.tasks
3983
+ : Array.isArray(allTasksPayload)
3984
+ ? allTasksPayload
3985
+ : tasks;
3986
+ const epicDeps = normalizeEpicDependenciesPayload(epicDepsMeta?.payload);
3987
+ const nextEpicGraph = buildEpicDagGraph(allTasks, epicDeps);
3988
+ setDagAllTasks(allTasks);
3989
+ setDagEpicDependencies(epicDeps);
3990
+
3559
3991
  const sprintMetaEntry = sprintOptions.find((entry) => entry.id === resolvedSprint) || null;
3560
3992
  setDagSprintOrderMode(toText(sprintMetaEntry?.executionMode || sprintMetaEntry?.taskOrderMode || sprintMetaEntry?.sprintOrderMode || "parallel", "parallel"));
3561
3993
  setDagSprints(sprintOptions);
3562
3994
  setDagSprintGraph(nextSprintGraph);
3563
3995
  setDagGlobalGraph(nextGlobalGraph);
3996
+ setDagEpicGraph(nextEpicGraph);
3564
3997
  setDagSources({
3565
3998
  sprints: sprintMeta?.path || "",
3566
3999
  sprintGraph: sprintGraphMeta?.path || "",
3567
4000
  globalGraph: globalGraphMeta?.path || "",
4001
+ epicDeps: epicDepsMeta?.path || "",
4002
+ tasks: tasksMeta?.path || "",
3568
4003
  });
3569
4004
 
3570
4005
  if (resolvedSprint !== dagSelectedSprint) {
@@ -3573,11 +4008,12 @@ export function TasksTab() {
3573
4008
 
3574
4009
  const hasAnyGraphData =
3575
4010
  nextSprintGraph.nodes.length > 0 ||
3576
- nextGlobalGraph.nodes.length > 0;
4011
+ nextGlobalGraph.nodes.length > 0 ||
4012
+ nextEpicGraph.nodes.length > 0;
3577
4013
  if (!hasAnyGraphData) {
3578
4014
  throw new Error("No DAG data was returned from DAG endpoints.");
3579
4015
  }
3580
- }, [dagSelectedSprint]);
4016
+ }, [dagSelectedSprint, tasks]);
3581
4017
 
3582
4018
  useEffect(() => {
3583
4019
  if (!isDag) return;
@@ -3793,6 +4229,32 @@ export function TasksTab() {
3793
4229
  });
3794
4230
  }, [visible, listSortCol, listSortDir]);
3795
4231
 
4232
+ const dagTaskFocusIds = useMemo(() => {
4233
+ if (dagFocusMode === "backlog") return dagPlanningState.backlogTaskIds;
4234
+ if (dagFocusMode === "ready") return dagPlanningState.readyTaskIds;
4235
+ if (dagFocusMode === "execution") return new Set([...dagPlanningState.executionTaskIds, ...dagPlanningState.readyTaskIds]);
4236
+ return "all";
4237
+ }, [dagFocusMode, dagPlanningState]);
4238
+
4239
+ const dagSprintFocusIds = useMemo(() => {
4240
+ if (dagFocusMode === "backlog") return dagPlanningState.sprintIdsByFocus.backlog;
4241
+ if (dagFocusMode === "ready") return dagPlanningState.sprintIdsByFocus.ready;
4242
+ if (dagFocusMode === "execution") return new Set([...dagPlanningState.sprintIdsByFocus.execution, ...dagPlanningState.sprintIdsByFocus.ready]);
4243
+ return "all";
4244
+ }, [dagFocusMode, dagPlanningState]);
4245
+
4246
+ const dagEpicFocusIds = useMemo(() => {
4247
+ if (dagFocusMode === "backlog") return dagPlanningState.epicIdsByFocus.backlog;
4248
+ if (dagFocusMode === "ready") return dagPlanningState.epicIdsByFocus.ready;
4249
+ if (dagFocusMode === "execution") return new Set([...dagPlanningState.epicIdsByFocus.execution, ...dagPlanningState.epicIdsByFocus.ready]);
4250
+ return "all";
4251
+ }, [dagFocusMode, dagPlanningState]);
4252
+
4253
+ const dagSprintGraphView = useMemo(() => filterDagGraphByIds(dagSprintGraph, dagTaskFocusIds, (node) => toText(node?.taskId || node?.id)), [dagSprintGraph, dagTaskFocusIds]);
4254
+ const dagGlobalGraphView = useMemo(() => filterDagGraphByIds(dagGlobalGraph, dagSprintFocusIds, (node) => toText(node?.sprintId || node?.id)), [dagGlobalGraph, dagSprintFocusIds]);
4255
+ const dagEpicGraphView = useMemo(() => filterDagGraphByIds(dagEpicGraph, dagEpicFocusIds, (node) => toText(node?.epicId || node?.id)), [dagEpicGraph, dagEpicFocusIds]);
4256
+ const dagReadyHighlightIds = dagFocusMode === "execution" || dagFocusMode === "ready" ? dagPlanningState.readyTaskIds : null;
4257
+
3796
4258
  /* ── Handlers ── */
3797
4259
  const handleFilter = async (s) => {
3798
4260
  haptic();
@@ -3879,29 +4341,31 @@ export function TasksTab() {
3879
4341
  }
3880
4342
  }, [loadDagViews]);
3881
4343
 
3882
- const handleCreateSprint = useCallback(async () => {
3883
- const rawName = globalThis.prompt?.("Sprint name", "Sprint " + new Date().toISOString().slice(0, 10));
3884
- const name = toText(rawName);
3885
- if (!name) return;
3886
- const rawId = globalThis.prompt?.("Sprint ID (optional)", name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""));
3887
- const id = toText(rawId);
4344
+ const handleCreateSprint = useCallback(() => {
4345
+ haptic("medium");
4346
+ setShowCreateSprint(true);
4347
+ }, []);
4348
+
4349
+ const handleMoveSprint = useCallback(async (sprintId, direction) => {
4350
+ const currentIndex = dagSprints.findIndex((entry) => entry.id === sprintId);
4351
+ const targetIndex = currentIndex + direction;
4352
+ if (currentIndex < 0 || targetIndex < 0 || targetIndex >= dagSprints.length) return;
4353
+ const reordered = [...dagSprints];
4354
+ const [moved] = reordered.splice(currentIndex, 1);
4355
+ reordered.splice(targetIndex, 0, moved);
3888
4356
  haptic("medium");
3889
4357
  try {
3890
- await apiFetch("/api/tasks/sprints", {
3891
- method: "POST",
3892
- body: JSON.stringify({
3893
- ...(id ? { id } : {}),
3894
- name,
3895
- status: "active",
3896
- }),
3897
- });
3898
- showToast("Sprint created", "success");
4358
+ await Promise.all(reordered.map((entry, index) => apiFetch(`/api/tasks/sprints/${encodeURIComponent(entry.id)}`, {
4359
+ method: "PATCH",
4360
+ body: JSON.stringify({ order: index + 1 }),
4361
+ })));
4362
+ showToast("Sprint order updated", "success");
3899
4363
  await loadDagViews();
3900
- if (id) setDagSelectedSprint(id);
3901
4364
  } catch {
3902
- /* toast via apiFetch */
4365
+ setDagError("Failed to reorder sprints.");
3903
4366
  }
3904
- }, [loadDagViews]);
4367
+ }, [dagSprints, loadDagViews]);
4368
+
3905
4369
  const handleDagSprintModeChange = useCallback(async (mode) => {
3906
4370
  const nextMode = toText(mode, "parallel").toLowerCase();
3907
4371
  if (!dagSelectedSprint || dagSelectedSprint === "all") {
@@ -3922,6 +4386,43 @@ export function TasksTab() {
3922
4386
  setDagError("Failed to update sprint execution mode.");
3923
4387
  }
3924
4388
  }, [dagSelectedSprint, loadDagViews]);
4389
+ const handleCreateDagEdge = useCallback(async ({ sourceNode, targetNode, graphKind }) => {
4390
+ const srcTaskId = toText(sourceNode?.taskId || sourceNode?.id);
4391
+ const dstTaskId = toText(targetNode?.taskId || targetNode?.id);
4392
+
4393
+ if (graphKind === "epic") {
4394
+ const srcEpic = toText(sourceNode?.epicId || sourceNode?.id);
4395
+ const dstEpic = toText(targetNode?.epicId || targetNode?.id);
4396
+ if (!srcEpic || !dstEpic || srcEpic === dstEpic) return;
4397
+ const existing = normalizeDependencyInput(targetNode?.dependencies || []);
4398
+ const dependencies = normalizeDependencyInput([...existing, srcEpic]);
4399
+ await apiFetch("/api/tasks/epic-dependencies", {
4400
+ method: "PUT",
4401
+ body: JSON.stringify({ epicId: dstEpic, dependencies }),
4402
+ });
4403
+ showToast(`Wired epic dependency: ${srcEpic} -> ${dstEpic}`, "success");
4404
+ await loadDagViews();
4405
+ return;
4406
+ }
4407
+
4408
+ if (!srcTaskId || !dstTaskId || srcTaskId === dstTaskId) return;
4409
+ const existing = normalizeDependencyInput(
4410
+ targetNode?.dependencies ||
4411
+ targetNode?.dependencyTaskIds ||
4412
+ [],
4413
+ );
4414
+ const dependencies = normalizeDependencyInput([...existing, srcTaskId]);
4415
+ await apiFetch("/api/tasks/dependencies", {
4416
+ method: "PUT",
4417
+ body: JSON.stringify({
4418
+ taskId: dstTaskId,
4419
+ dependencies,
4420
+ }),
4421
+ });
4422
+ showToast(`Wired dependency: ${srcTaskId} -> ${dstTaskId}`, "success");
4423
+ await loadDagViews();
4424
+ }, [loadDagViews]);
4425
+
3925
4426
  const handleSprintChange = useCallback((nextSprint) => {
3926
4427
  const sprintId = toText(nextSprint, "all");
3927
4428
  if (sprintId === dagSelectedSprint) return;
@@ -4539,6 +5040,7 @@ export function TasksTab() {
4539
5040
  <span class="snapshot-view-tag">${iconText(":link: DAG")}</span>
4540
5041
  <span class="pill">Sprint nodes: ${dagSprintGraph.nodes.length}</span>
4541
5042
  <span class="pill">Global nodes: ${dagGlobalGraph.nodes.length}</span>
5043
+ <span class="pill">Epic nodes: ${dagEpicGraph.nodes.length}</span>
4542
5044
  <span class="pill">Global edges: ${dagGlobalGraph.edges.length}</span>
4543
5045
  </div>
4544
5046
  `}
@@ -4583,22 +5085,37 @@ export function TasksTab() {
4583
5085
 
4584
5086
  ${isDag && html`
4585
5087
  <div class="task-dag-wrap" style=${{ display: "grid", gap: "10px", marginTop: "8px" }}>
4586
- ${dagError && html`<${Alert} severity="warning">${dagError}</${Alert}>`}
4587
- ${dagLoading && html`<${Alert} severity="info">Loading DAG data…</${Alert}>`}
5088
+ ${dagError ? html`<${Alert} severity="warning">${dagError}</${Alert}>` : null}
5089
+ ${dagLoading ? html`<${Alert} severity="info">Loading DAG data…</${Alert}>` : null}
4588
5090
  <${DagGraphSection}
4589
5091
  title=${dagSprintGraph.title || (dagSelectedSprint === "all" ? "All Sprint DAG" : `Sprint ${dagSelectedSprint} DAG`)}
4590
5092
  description=${dagSprintGraph.description || "Task dependency order within the selected sprint."}
4591
5093
  graph=${dagSprintGraph}
5094
+ graphKey="sprint"
4592
5095
  onOpenTask=${openDetail}
5096
+ onCreateEdge={({ sourceNode, targetNode }) => handleCreateDagEdge({ sourceNode, targetNode, graphKind: "task" })}
5097
+ allowWiring=${true}
4593
5098
  emptyMessage="No sprint DAG data available yet."
4594
- />
5099
+ ><//>
4595
5100
  <${DagGraphSection}
4596
5101
  title=${dagGlobalGraph.title || "Global DAG of DAGs"}
4597
5102
  description=${dagGlobalGraph.description || "Cross-sprint dependency overview."}
4598
5103
  graph=${dagGlobalGraph}
5104
+ graphKey="global"
4599
5105
  onOpenTask=${openDetail}
5106
+ onCreateEdge={({ sourceNode, targetNode }) => handleCreateDagEdge({ sourceNode, targetNode, graphKind: "task" })}
5107
+ allowWiring=${true}
4600
5108
  emptyMessage="No global DAG data available yet."
4601
- />
5109
+ ><//>
5110
+ <${DagGraphSection}
5111
+ title=${dagEpicGraph.title || "Epic Dependency DAG"}
5112
+ description=${dagEpicGraph.description || "Epics and their run prerequisites."}
5113
+ graph=${dagEpicGraph}
5114
+ graphKey="epic"
5115
+ onCreateEdge={({ sourceNode, targetNode }) => handleCreateDagEdge({ sourceNode, targetNode, graphKind: "epic" })}
5116
+ allowWiring=${true}
5117
+ emptyMessage="No epic DAG data available yet."
5118
+ ><//>
4602
5119
  </div>
4603
5120
  `}
4604
5121
 
@@ -4725,22 +5242,14 @@ export function TasksTab() {
4725
5242
  html`
4726
5243
  <${CreateTaskModalInline} onClose=${() => setShowCreate(false)} />
4727
5244
  `}
4728
- ${detailTask && isActiveStatus(detailTask.status) &&
5245
+ ${detailTask && isActiveStatus(detailTask.status) && hasLiveExecutionEvidence(detailTask) &&
4729
5246
  html`
4730
5247
  <${TaskProgressModal}
4731
5248
  task=${detailTask}
4732
5249
  onClose=${() => setDetailTask(null)}
4733
5250
  />
4734
5251
  `}
4735
- ${detailTask && isReviewStatus(detailTask.status) &&
4736
- html`
4737
- <${TaskReviewModal}
4738
- task=${detailTask}
4739
- onClose=${() => setDetailTask(null)}
4740
- onStart=${(task) => openStartModal(task)}
4741
- />
4742
- `}
4743
- ${detailTask && !isActiveStatus(detailTask.status) && !isReviewStatus(detailTask.status) &&
5252
+ ${detailTask && (!isActiveStatus(detailTask.status) || !hasLiveExecutionEvidence(detailTask)) &&
4744
5253
  html`
4745
5254
  <${TaskDetailModal}
4746
5255
  task=${detailTask}
@@ -5196,6 +5705,8 @@ function CreateTaskModalInline({ onClose }) {
5196
5705
 
5197
5706
 
5198
5707
 
5708
+
5709
+
5199
5710
 
5200
5711
 
5201
5712