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/README.md +4 -0
- package/cli.mjs +127 -69
- package/config/config.mjs +35 -15
- package/desktop/package.json +2 -2
- package/infra/monitor.mjs +52 -16
- package/infra/session-tracker.mjs +1 -0
- package/infra/sync-engine.mjs +6 -1
- package/infra/update-check.mjs +16 -10
- package/kanban/kanban-adapter.mjs +19 -4
- package/kanban/ve-orchestrator.ps1 +25 -0
- package/package.json +1 -1
- package/server/ui-server.mjs +502 -39
- package/task/task-executor.mjs +690 -6
- package/task/task-store.mjs +116 -1
- package/ui/components/kanban-board.js +137 -9
- package/ui/components/shared.js +107 -45
- package/ui/demo-defaults.js +20 -20
- package/ui/demo.html +26 -1
- package/ui/modules/mui.js +600 -397
- package/ui/styles/components.css +43 -3
- package/ui/styles/kanban.css +66 -11
- package/ui/styles.monolith.css +89 -0
- package/ui/tabs/agents.js +194 -20
- package/ui/tabs/tasks.js +673 -162
- package/workflow/workflow-engine.mjs +30 -29
- package/workflow/workflow-nodes.mjs +321 -22
- package/workflow/workflow-templates.mjs +1 -1
- package/workflow-templates/task-batch.mjs +10 -10
- package/workspace/workspace-manager.mjs +25 -0
- package/workspace/worktree-manager.mjs +8 -2
package/task/task-store.mjs
CHANGED
|
@@ -429,6 +429,7 @@ function defaultMeta() {
|
|
|
429
429
|
version: 1,
|
|
430
430
|
projectId: null,
|
|
431
431
|
lastFullSync: null,
|
|
432
|
+
epicDependencies: {},
|
|
432
433
|
sprintOrderMode: "parallel",
|
|
433
434
|
taskCount: 0,
|
|
434
435
|
stats: {
|
|
@@ -808,6 +809,25 @@ function ensureSprintsMap() {
|
|
|
808
809
|
return _store.sprints;
|
|
809
810
|
}
|
|
810
811
|
|
|
812
|
+
function ensureEpicDependenciesMap() {
|
|
813
|
+
if (!_store._meta || typeof _store._meta !== "object") {
|
|
814
|
+
_store._meta = defaultMeta();
|
|
815
|
+
}
|
|
816
|
+
if (!_store._meta.epicDependencies || typeof _store._meta.epicDependencies !== "object") {
|
|
817
|
+
_store._meta.epicDependencies = {};
|
|
818
|
+
}
|
|
819
|
+
const map = _store._meta.epicDependencies;
|
|
820
|
+
for (const [key, value] of Object.entries(map)) {
|
|
821
|
+
const epicId = String(key || "").trim();
|
|
822
|
+
if (!epicId) {
|
|
823
|
+
delete map[key];
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
map[epicId] = uniqueStringList(Array.isArray(value) ? value : []);
|
|
827
|
+
}
|
|
828
|
+
return map;
|
|
829
|
+
}
|
|
830
|
+
|
|
811
831
|
export function listSprints() {
|
|
812
832
|
ensureLoaded();
|
|
813
833
|
const sprints = Object.values(ensureSprintsMap());
|
|
@@ -1693,12 +1713,18 @@ export function getGlobalDagOfDags() {
|
|
|
1693
1713
|
return String(a.to).localeCompare(String(b.to));
|
|
1694
1714
|
});
|
|
1695
1715
|
|
|
1716
|
+
const epicDependencies = getEpicDependencies();
|
|
1717
|
+
|
|
1696
1718
|
return {
|
|
1697
1719
|
sprintOrderMode: mode,
|
|
1698
1720
|
sprintCount: nodes.length,
|
|
1699
1721
|
edgeCount: edges.length,
|
|
1722
|
+
epicDependencies,
|
|
1700
1723
|
nodes,
|
|
1701
|
-
edges
|
|
1724
|
+
edges: edges.map((edge) => ({
|
|
1725
|
+
...edge,
|
|
1726
|
+
kind: edge.taskLinks.some((link) => link?.type === 'sequence') ? 'sequential' : 'dependency',
|
|
1727
|
+
})),
|
|
1702
1728
|
};
|
|
1703
1729
|
}
|
|
1704
1730
|
|
|
@@ -1706,6 +1732,54 @@ export function getDagOfDags() {
|
|
|
1706
1732
|
return getGlobalDagOfDags();
|
|
1707
1733
|
}
|
|
1708
1734
|
|
|
1735
|
+
export function getEpicDependencies() {
|
|
1736
|
+
ensureLoaded();
|
|
1737
|
+
const map = ensureEpicDependenciesMap();
|
|
1738
|
+
return Object.entries(map).map(([epicId, dependencies]) => ({
|
|
1739
|
+
epicId,
|
|
1740
|
+
dependencies: uniqueStringList(Array.isArray(dependencies) ? dependencies : []),
|
|
1741
|
+
}));
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
export function setEpicDependencies(epicId, dependencies = []) {
|
|
1745
|
+
ensureLoaded();
|
|
1746
|
+
const normalizedEpicId = String(epicId || '').trim();
|
|
1747
|
+
if (!normalizedEpicId) return null;
|
|
1748
|
+
const cleaned = uniqueStringList((Array.isArray(dependencies) ? dependencies : [])
|
|
1749
|
+
.map((entry) => String(entry || '').trim())
|
|
1750
|
+
.filter((entry) => entry && entry !== normalizedEpicId));
|
|
1751
|
+
const map = ensureEpicDependenciesMap();
|
|
1752
|
+
if (cleaned.length > 0) map[normalizedEpicId] = cleaned;
|
|
1753
|
+
else delete map[normalizedEpicId];
|
|
1754
|
+
saveStore();
|
|
1755
|
+
return { epicId: normalizedEpicId, dependencies: [...(map[normalizedEpicId] || [])] };
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
export function addEpicDependency(epicId, dependencyEpicId) {
|
|
1759
|
+
ensureLoaded();
|
|
1760
|
+
const normalizedEpicId = String(epicId || '').trim();
|
|
1761
|
+
const normalizedDependency = String(dependencyEpicId || '').trim();
|
|
1762
|
+
if (!normalizedEpicId || !normalizedDependency || normalizedEpicId === normalizedDependency) return null;
|
|
1763
|
+
const map = ensureEpicDependenciesMap();
|
|
1764
|
+
const next = uniqueStringList([...(map[normalizedEpicId] || []), normalizedDependency]);
|
|
1765
|
+
map[normalizedEpicId] = next;
|
|
1766
|
+
saveStore();
|
|
1767
|
+
return { epicId: normalizedEpicId, dependencies: [...next] };
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
export function removeEpicDependency(epicId, dependencyEpicId) {
|
|
1771
|
+
ensureLoaded();
|
|
1772
|
+
const normalizedEpicId = String(epicId || '').trim();
|
|
1773
|
+
const normalizedDependency = String(dependencyEpicId || '').trim();
|
|
1774
|
+
if (!normalizedEpicId || !normalizedDependency) return null;
|
|
1775
|
+
const map = ensureEpicDependenciesMap();
|
|
1776
|
+
const next = uniqueStringList((map[normalizedEpicId] || []).filter((entry) => entry !== normalizedDependency));
|
|
1777
|
+
if (next.length > 0) map[normalizedEpicId] = next;
|
|
1778
|
+
else delete map[normalizedEpicId];
|
|
1779
|
+
saveStore();
|
|
1780
|
+
return { epicId: normalizedEpicId, dependencies: [...(map[normalizedEpicId] || [])] };
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1709
1783
|
export function canStartTask(taskId, options = {}) {
|
|
1710
1784
|
return canTaskStart(taskId, options);
|
|
1711
1785
|
}
|
|
@@ -1721,6 +1795,7 @@ export function canTaskStart(taskId, options = {}) {
|
|
|
1721
1795
|
blockingTaskIds: [],
|
|
1722
1796
|
missingDependencyTaskIds: [],
|
|
1723
1797
|
blockingSprintIds: [],
|
|
1798
|
+
blockingEpicIds: [],
|
|
1724
1799
|
sprintOrderMode,
|
|
1725
1800
|
};
|
|
1726
1801
|
}
|
|
@@ -1732,6 +1807,7 @@ export function canTaskStart(taskId, options = {}) {
|
|
|
1732
1807
|
blockingTaskIds: [],
|
|
1733
1808
|
missingDependencyTaskIds: [],
|
|
1734
1809
|
blockingSprintIds: [],
|
|
1810
|
+
blockingEpicIds: [],
|
|
1735
1811
|
sprintOrderMode,
|
|
1736
1812
|
};
|
|
1737
1813
|
}
|
|
@@ -1756,6 +1832,7 @@ export function canTaskStart(taskId, options = {}) {
|
|
|
1756
1832
|
blockingTaskIds: uniqueStringList(blockingTaskIds),
|
|
1757
1833
|
missingDependencyTaskIds: uniqueStringList(missingDependencyTaskIds),
|
|
1758
1834
|
blockingSprintIds: [],
|
|
1835
|
+
blockingEpicIds: [],
|
|
1759
1836
|
sprintOrderMode,
|
|
1760
1837
|
};
|
|
1761
1838
|
}
|
|
@@ -1787,6 +1864,42 @@ export function canTaskStart(taskId, options = {}) {
|
|
|
1787
1864
|
blockingTaskIds: uniqueStringList(blockingTaskIds),
|
|
1788
1865
|
missingDependencyTaskIds: [],
|
|
1789
1866
|
blockingSprintIds: [sprintId],
|
|
1867
|
+
blockingEpicIds: [],
|
|
1868
|
+
sprintOrderMode,
|
|
1869
|
+
sprintTaskOrderMode,
|
|
1870
|
+
};
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
const taskEpicId = String(task?.epicId || task?.meta?.epicId || '').trim();
|
|
1876
|
+
if (taskEpicId) {
|
|
1877
|
+
const epicDependenciesMap = ensureEpicDependenciesMap();
|
|
1878
|
+
const requiredEpics = uniqueStringList(epicDependenciesMap[taskEpicId] || []);
|
|
1879
|
+
if (requiredEpics.length > 0) {
|
|
1880
|
+
const blockingEpicIds = [];
|
|
1881
|
+
const blockingEpicTaskIds = [];
|
|
1882
|
+
for (const requiredEpicId of requiredEpics) {
|
|
1883
|
+
let hasIncomplete = false;
|
|
1884
|
+
for (const candidate of Object.values(_store.tasks)) {
|
|
1885
|
+
if (!candidate) continue;
|
|
1886
|
+
const candidateEpicId = String(candidate?.epicId || candidate?.meta?.epicId || '').trim();
|
|
1887
|
+
if (candidateEpicId !== requiredEpicId) continue;
|
|
1888
|
+
if (!isTaskTerminal(candidate)) {
|
|
1889
|
+
hasIncomplete = true;
|
|
1890
|
+
blockingEpicTaskIds.push(candidate.id);
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
if (hasIncomplete) blockingEpicIds.push(requiredEpicId);
|
|
1894
|
+
}
|
|
1895
|
+
if (blockingEpicIds.length > 0) {
|
|
1896
|
+
return {
|
|
1897
|
+
canStart: false,
|
|
1898
|
+
reason: "epic_dependencies_unresolved",
|
|
1899
|
+
blockingTaskIds: uniqueStringList(blockingEpicTaskIds),
|
|
1900
|
+
missingDependencyTaskIds: [],
|
|
1901
|
+
blockingSprintIds: [],
|
|
1902
|
+
blockingEpicIds: uniqueStringList(blockingEpicIds),
|
|
1790
1903
|
sprintOrderMode,
|
|
1791
1904
|
sprintTaskOrderMode,
|
|
1792
1905
|
};
|
|
@@ -1818,6 +1931,7 @@ export function canTaskStart(taskId, options = {}) {
|
|
|
1818
1931
|
blockingTaskIds: uniqueStringList(blockingTaskIds),
|
|
1819
1932
|
missingDependencyTaskIds: [],
|
|
1820
1933
|
blockingSprintIds: uniqueStringList(blockingSprintIds),
|
|
1934
|
+
blockingEpicIds: [],
|
|
1821
1935
|
sprintOrderMode,
|
|
1822
1936
|
sprintTaskOrderMode,
|
|
1823
1937
|
};
|
|
@@ -1831,6 +1945,7 @@ export function canTaskStart(taskId, options = {}) {
|
|
|
1831
1945
|
blockingTaskIds: [],
|
|
1832
1946
|
missingDependencyTaskIds: [],
|
|
1833
1947
|
blockingSprintIds: [],
|
|
1948
|
+
blockingEpicIds: [],
|
|
1834
1949
|
sprintOrderMode,
|
|
1835
1950
|
};
|
|
1836
1951
|
}
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
* Kanban Board Component — GitHub Projects-style task board
|
|
3
3
|
* ────────────────────────────────────────────────────────────── */
|
|
4
4
|
import { h } from "preact";
|
|
5
|
-
import { useState, useCallback, useRef, useEffect, useMemo } from "preact/hooks";
|
|
5
|
+
import { useState, useCallback, useRef, useEffect, useMemo, useLayoutEffect } from "preact/hooks";
|
|
6
6
|
import htm from "htm";
|
|
7
7
|
import { signal, computed } from "@preact/signals";
|
|
8
8
|
import {
|
|
9
9
|
tasksData,
|
|
10
10
|
tasksLoaded,
|
|
11
|
+
tasksPage,
|
|
11
12
|
showToast,
|
|
12
13
|
runOptimistic,
|
|
13
14
|
loadTasks,
|
|
@@ -97,6 +98,27 @@ function getTaskBaseBranch(task) {
|
|
|
97
98
|
);
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
function getTaskRuntimeSnapshot(task) {
|
|
102
|
+
return task?.runtimeSnapshot || task?.meta?.runtimeSnapshot || null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function getTaskEpic(task) {
|
|
106
|
+
return String(task?.epic || task?.epicName || task?.meta?.epic || task?.meta?.epicName || "").trim();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function getTaskSprint(task) {
|
|
110
|
+
return String(task?.sprintName || task?.sprint || task?.meta?.sprintName || task?.meta?.sprintId || "").trim();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getTaskStoryPoints(task) {
|
|
114
|
+
const value = task?.storyPoints ?? task?.story_points ?? task?.points ?? task?.meta?.storyPoints;
|
|
115
|
+
return Number.isFinite(Number(value)) && String(value).trim() !== "" ? String(value) : "";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getTaskDueDate(task) {
|
|
119
|
+
return String(task?.dueDate || task?.due_date || task?.meta?.dueDate || "").trim();
|
|
120
|
+
}
|
|
121
|
+
|
|
100
122
|
/* ─── Derived column data ─── */
|
|
101
123
|
const columnData = computed(() => {
|
|
102
124
|
const tasks = tasksData.value || [];
|
|
@@ -130,6 +152,14 @@ const TOUCH_DRAG_DELAY_MS = 180;
|
|
|
130
152
|
const TOUCH_DRAG_START_PX = 6;
|
|
131
153
|
const TOUCH_CANCEL_PX = 14;
|
|
132
154
|
|
|
155
|
+
function queueBoardTasksRefresh() {
|
|
156
|
+
const page = Number(tasksPage?.value ?? 0);
|
|
157
|
+
const append = Number.isFinite(page) && page > 0;
|
|
158
|
+
setTimeout(() => {
|
|
159
|
+
void loadTasks({ append });
|
|
160
|
+
}, 500);
|
|
161
|
+
}
|
|
162
|
+
|
|
133
163
|
/* ─── Touch drag helpers ─── */
|
|
134
164
|
|
|
135
165
|
function _createTouchClone(el) {
|
|
@@ -269,7 +299,7 @@ async function executeBoardTransition(task, newStatus, columnLabel) {
|
|
|
269
299
|
);
|
|
270
300
|
|
|
271
301
|
showToast(`Moved to ${columnLabel || "updated status"}`, "success");
|
|
272
|
-
|
|
302
|
+
queueBoardTasksRefresh();
|
|
273
303
|
return { ok: true, cancelled: false, action: decision.action, status: optimisticStatus };
|
|
274
304
|
}
|
|
275
305
|
|
|
@@ -411,6 +441,11 @@ function KanbanCard({ task, onOpen }) {
|
|
|
411
441
|
const priorityLabel = PRIORITY_LABELS[task.priority] || null;
|
|
412
442
|
const tags = getTaskTags(task);
|
|
413
443
|
const baseBranch = getTaskBaseBranch(task);
|
|
444
|
+
const runtime = getTaskRuntimeSnapshot(task);
|
|
445
|
+
const epic = getTaskEpic(task);
|
|
446
|
+
const sprint = getTaskSprint(task);
|
|
447
|
+
const storyPoints = getTaskStoryPoints(task);
|
|
448
|
+
const dueDate = getTaskDueDate(task);
|
|
414
449
|
const repoName = task.repo || task.repository || "";
|
|
415
450
|
const issueNum = task.issueNumber || task.issue_number || (typeof task.id === "string" && /^\d+$/.test(task.id) ? task.id : null);
|
|
416
451
|
const hasAgent = Boolean(
|
|
@@ -459,11 +494,25 @@ function KanbanCard({ task, onOpen }) {
|
|
|
459
494
|
${priorityLabel && html`
|
|
460
495
|
<${Chip} label=${priorityLabel} size="small" sx=${{ backgroundColor: priorityColor, color: '#fff', height: 18, fontSize: '0.65rem' }} />
|
|
461
496
|
`}
|
|
497
|
+
${runtime?.state === "running" && html`
|
|
498
|
+
<${Chip} label="LIVE" size="small" color="success" sx=${{ height: 18, fontSize: '0.65rem' }} />
|
|
499
|
+
`}
|
|
500
|
+
${runtime?.state === "queued" && html`
|
|
501
|
+
<${Chip} label="QUEUED" size="small" color="warning" sx=${{ height: 18, fontSize: '0.65rem' }} />
|
|
502
|
+
`}
|
|
462
503
|
</${Stack}>
|
|
463
504
|
<${Typography} variant="body2" fontWeight=${500}>${truncate(task.title || "(untitled)", 80)}</${Typography}>
|
|
464
505
|
${task.description && html`
|
|
465
506
|
<${Typography} variant="caption" color="text.secondary" sx=${{ display: 'block', mt: 0.5 }}>${truncate(task.description, 72)}</${Typography}>
|
|
466
507
|
`}
|
|
508
|
+
${(epic || sprint || storyPoints || dueDate) && html`
|
|
509
|
+
<${Stack} direction="row" spacing=${0.5} flexWrap="wrap" sx=${{ mt: 0.75 }}>
|
|
510
|
+
${epic && html`<${Chip} label=${`Epic: ${truncate(epic, 18)}`} size="small" variant="outlined" sx=${{ height: 20, fontSize: '0.65rem' }} />`}
|
|
511
|
+
${sprint && html`<${Chip} label=${`Sprint: ${truncate(sprint, 18)}`} size="small" variant="outlined" sx=${{ height: 20, fontSize: '0.65rem' }} />`}
|
|
512
|
+
${storyPoints && html`<${Chip} label=${`${storyPoints} pts`} size="small" variant="outlined" sx=${{ height: 20, fontSize: '0.65rem' }} />`}
|
|
513
|
+
${dueDate && html`<${Chip} label=${`Due: ${truncate(dueDate, 18)}`} size="small" variant="outlined" color="warning" sx=${{ height: 20, fontSize: '0.65rem' }} />`}
|
|
514
|
+
</${Stack}>
|
|
515
|
+
`}
|
|
467
516
|
${baseBranch && html`
|
|
468
517
|
<${Typography} variant="caption" color="text.secondary" sx=${{ display: 'block', mt: 0.5 }}>Base: ${truncate(baseBranch, 24)}</${Typography}>
|
|
469
518
|
`}
|
|
@@ -498,19 +547,80 @@ function KanbanColumn({
|
|
|
498
547
|
}) {
|
|
499
548
|
const [showCreate, setShowCreate] = useState(false);
|
|
500
549
|
const inputRef = useRef(null);
|
|
550
|
+
const cardsRef = useRef(null);
|
|
551
|
+
const tailSentinelRef = useRef(null);
|
|
552
|
+
const lastAutoLoadCountRef = useRef(-1);
|
|
501
553
|
|
|
502
554
|
useEffect(() => {
|
|
503
555
|
if (showCreate && inputRef.current) inputRef.current.focus();
|
|
504
556
|
}, [showCreate]);
|
|
505
557
|
|
|
558
|
+
const triggerLoadMore = useCallback(() => {
|
|
559
|
+
if (!hasMoreTasks || loadingMoreTasks || typeof onLoadMoreTasks !== "function") return false;
|
|
560
|
+
void onLoadMoreTasks();
|
|
561
|
+
return true;
|
|
562
|
+
}, [hasMoreTasks, loadingMoreTasks, onLoadMoreTasks]);
|
|
563
|
+
|
|
564
|
+
useEffect(() => {
|
|
565
|
+
if (!hasMoreTasks || typeof onLoadMoreTasks !== "function") {
|
|
566
|
+
lastAutoLoadCountRef.current = -1;
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
const root = cardsRef.current;
|
|
570
|
+
const sentinel = tailSentinelRef.current;
|
|
571
|
+
if (!root || !sentinel || typeof IntersectionObserver !== "function") return;
|
|
572
|
+
const observer = new IntersectionObserver(
|
|
573
|
+
(entries) => {
|
|
574
|
+
for (const entry of entries) {
|
|
575
|
+
if (!entry.isIntersecting) continue;
|
|
576
|
+
const key = tasks.length;
|
|
577
|
+
if (lastAutoLoadCountRef.current === key || loadingMoreTasks) continue;
|
|
578
|
+
lastAutoLoadCountRef.current = key;
|
|
579
|
+
void onLoadMoreTasks();
|
|
580
|
+
}
|
|
581
|
+
},
|
|
582
|
+
{
|
|
583
|
+
root,
|
|
584
|
+
rootMargin: "0px 0px 160px 0px",
|
|
585
|
+
threshold: 0,
|
|
586
|
+
},
|
|
587
|
+
);
|
|
588
|
+
observer.observe(sentinel);
|
|
589
|
+
return () => observer.disconnect();
|
|
590
|
+
}, [hasMoreTasks, loadingMoreTasks, onLoadMoreTasks, tasks.length]);
|
|
591
|
+
|
|
592
|
+
useLayoutEffect(() => {
|
|
593
|
+
const root = cardsRef.current;
|
|
594
|
+
if (!root || !hasMoreTasks || loadingMoreTasks || typeof onLoadMoreTasks !== "function") return;
|
|
595
|
+
const remaining = root.scrollHeight - root.scrollTop - root.clientHeight;
|
|
596
|
+
const underfilled = root.scrollHeight <= root.clientHeight + LOAD_MORE_THRESHOLD_PX;
|
|
597
|
+
if (!underfilled && remaining > LOAD_MORE_THRESHOLD_PX) return;
|
|
598
|
+
const key = tasks.length;
|
|
599
|
+
if (lastAutoLoadCountRef.current === key) return;
|
|
600
|
+
lastAutoLoadCountRef.current = key;
|
|
601
|
+
void onLoadMoreTasks();
|
|
602
|
+
}, [hasMoreTasks, loadingMoreTasks, onLoadMoreTasks, tasks.length, showCreate]);
|
|
603
|
+
|
|
506
604
|
const onCardsScroll = useCallback((event) => {
|
|
507
605
|
const el = event?.currentTarget;
|
|
508
606
|
if (!el) return;
|
|
509
607
|
const remaining = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
510
608
|
if (remaining > LOAD_MORE_THRESHOLD_PX) return;
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
}, [
|
|
609
|
+
lastAutoLoadCountRef.current = tasks.length;
|
|
610
|
+
triggerLoadMore();
|
|
611
|
+
}, [tasks.length, triggerLoadMore]);
|
|
612
|
+
|
|
613
|
+
const onCardsWheel = useCallback((event) => {
|
|
614
|
+
const el = event?.currentTarget;
|
|
615
|
+
if (!el) return;
|
|
616
|
+
if (Math.abs(event.deltaY) < Math.abs(event.deltaX || 0)) return;
|
|
617
|
+
const canScroll = el.scrollHeight > el.clientHeight + 1;
|
|
618
|
+
if (!canScroll) return;
|
|
619
|
+
const atTop = el.scrollTop <= 0;
|
|
620
|
+
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
|
|
621
|
+
if ((event.deltaY < 0 && atTop) || (event.deltaY > 0 && atBottom)) return;
|
|
622
|
+
event.stopPropagation();
|
|
623
|
+
}, []);
|
|
514
624
|
|
|
515
625
|
const onDragOver = useCallback((e) => {
|
|
516
626
|
e.preventDefault();
|
|
@@ -562,7 +672,7 @@ function KanbanColumn({
|
|
|
562
672
|
},
|
|
563
673
|
);
|
|
564
674
|
showToast(`Moved to ${col.title}`, "success");
|
|
565
|
-
|
|
675
|
+
queueBoardTasksRefresh();
|
|
566
676
|
} catch (err) {
|
|
567
677
|
showToast(err?.message || "Failed to move task", "error");
|
|
568
678
|
}
|
|
@@ -597,7 +707,12 @@ function KanbanColumn({
|
|
|
597
707
|
<${Chip} label=${countLabel} size="small" />
|
|
598
708
|
<${IconButton} size="small" onClick=${() => { setShowCreate(!showCreate); haptic(); }} title=${"Add task to " + col.title}>+</${IconButton}>
|
|
599
709
|
</${Box}>
|
|
600
|
-
<div
|
|
710
|
+
<div
|
|
711
|
+
ref=${cardsRef}
|
|
712
|
+
class="kanban-cards"
|
|
713
|
+
onScroll=${onCardsScroll}
|
|
714
|
+
onWheel=${onCardsWheel}
|
|
715
|
+
>
|
|
601
716
|
${showCreate && html`
|
|
602
717
|
<${TextField}
|
|
603
718
|
inputRef=${inputRef}
|
|
@@ -616,10 +731,23 @@ function KanbanColumn({
|
|
|
616
731
|
: html`<${Typography} variant="body2" color="text.secondary" sx=${{ textAlign: 'center', py: 2 }}>Drop tasks here</${Typography}>`
|
|
617
732
|
}
|
|
618
733
|
${hasMoreTasks && html`
|
|
619
|
-
<div class="kanban-tail-sentinel"></div>
|
|
620
|
-
<div class="kanban-load-more">${loadingMoreTasks ? "Loading more tasks..." : "Scroll down to load more"}</div>
|
|
734
|
+
<div ref=${tailSentinelRef} class="kanban-tail-sentinel"></div>
|
|
621
735
|
`}
|
|
622
736
|
</div>
|
|
737
|
+
${hasMoreTasks && html`
|
|
738
|
+
<div class="kanban-column-footer">
|
|
739
|
+
<button
|
|
740
|
+
type="button"
|
|
741
|
+
class="kanban-load-more"
|
|
742
|
+
onClick=${() => triggerLoadMore()}
|
|
743
|
+
disabled=${loadingMoreTasks}
|
|
744
|
+
aria-label=${loadingMoreTasks ? `Loading more ${col.title} tasks` : `Load more ${col.title} tasks`}
|
|
745
|
+
>
|
|
746
|
+
<span class="kanban-load-more-label">${loadingMoreTasks ? "Loading more tasks..." : `Load more ${col.title}`}</span>
|
|
747
|
+
<span class="kanban-load-more-icon" aria-hidden="true">⌄</span>
|
|
748
|
+
</button>
|
|
749
|
+
</div>
|
|
750
|
+
`}
|
|
623
751
|
<div class="kanban-scroll-fade"></div>
|
|
624
752
|
</div>
|
|
625
753
|
`;
|
package/ui/components/shared.js
CHANGED
|
@@ -45,7 +45,6 @@ import {
|
|
|
45
45
|
ListItemText,
|
|
46
46
|
ListItemSecondaryAction,
|
|
47
47
|
IconButton,
|
|
48
|
-
Fade,
|
|
49
48
|
} from "@mui/material";
|
|
50
49
|
|
|
51
50
|
import { ICONS } from "../modules/icons.js";
|
|
@@ -227,7 +226,13 @@ export function SkeletonCard({ height = "80px", className = "" }) {
|
|
|
227
226
|
* onSaveBeforeClose?: (() => Promise<boolean|{closed?: boolean}|void>)|null,
|
|
228
227
|
* onDiscardBeforeClose?: (() => Promise<boolean|{closed?: boolean}|void>)|null,
|
|
229
228
|
* activeOperationLabel?: string,
|
|
230
|
-
* closeGuard?: boolean
|
|
229
|
+
* closeGuard?: boolean,
|
|
230
|
+
* layout?: "sheet"|"side-sheet",
|
|
231
|
+
* resizable?: boolean,
|
|
232
|
+
* defaultWidth?: number,
|
|
233
|
+
* minWidth?: number,
|
|
234
|
+
* maxWidth?: number,
|
|
235
|
+
* widthStorageKey?: string
|
|
231
236
|
* }} props
|
|
232
237
|
*/
|
|
233
238
|
export function Modal({
|
|
@@ -242,6 +247,12 @@ export function Modal({
|
|
|
242
247
|
onDiscardBeforeClose = null,
|
|
243
248
|
activeOperationLabel = "",
|
|
244
249
|
closeGuard = true,
|
|
250
|
+
layout = "sheet",
|
|
251
|
+
resizable = false,
|
|
252
|
+
defaultWidth = 760,
|
|
253
|
+
minWidth = 440,
|
|
254
|
+
maxWidth = 1120,
|
|
255
|
+
widthStorageKey = "",
|
|
245
256
|
}) {
|
|
246
257
|
const [visible, setVisible] = useState(false);
|
|
247
258
|
const contentRef = useRef(null);
|
|
@@ -249,6 +260,13 @@ export function Modal({
|
|
|
249
260
|
const [dragY, setDragY] = useState(0);
|
|
250
261
|
const [closePromptOpen, setClosePromptOpen] = useState(false);
|
|
251
262
|
const [closePromptSaving, setClosePromptSaving] = useState(false);
|
|
263
|
+
const isSideSheet = layout === "side-sheet";
|
|
264
|
+
const [sheetWidth, setSheetWidth] = useState(() => {
|
|
265
|
+
const fallback = Number.isFinite(Number(defaultWidth)) ? Number(defaultWidth) : 760;
|
|
266
|
+
if (!isSideSheet || !widthStorageKey || typeof localStorage === "undefined") return fallback;
|
|
267
|
+
const stored = Number(localStorage.getItem(widthStorageKey));
|
|
268
|
+
return Number.isFinite(stored) ? stored : fallback;
|
|
269
|
+
});
|
|
252
270
|
const scopedUnsavedCount = Number.isFinite(Number(unsavedChanges))
|
|
253
271
|
? Math.max(0, Number(unsavedChanges))
|
|
254
272
|
: 0;
|
|
@@ -346,8 +364,43 @@ export function Modal({
|
|
|
346
364
|
}, []);
|
|
347
365
|
|
|
348
366
|
const isDragHandleTarget = useCallback((target) => (
|
|
349
|
-
Boolean(target?.closest?.(".modal-handle, .modal-header"))
|
|
350
|
-
), []);
|
|
367
|
+
!isSideSheet && Boolean(target?.closest?.(".modal-handle, .modal-header"))
|
|
368
|
+
), [isSideSheet]);
|
|
369
|
+
|
|
370
|
+
const persistSheetWidth = useCallback((nextWidth) => {
|
|
371
|
+
if (!widthStorageKey || typeof localStorage === "undefined") return;
|
|
372
|
+
try {
|
|
373
|
+
localStorage.setItem(widthStorageKey, String(nextWidth));
|
|
374
|
+
} catch {
|
|
375
|
+
/* ignore width persistence failures */
|
|
376
|
+
}
|
|
377
|
+
}, [widthStorageKey]);
|
|
378
|
+
|
|
379
|
+
const handleSheetResizeStart = useCallback((event) => {
|
|
380
|
+
if (!isSideSheet || !resizable) return;
|
|
381
|
+
event.preventDefault();
|
|
382
|
+
event.stopPropagation();
|
|
383
|
+
const startX = event.clientX;
|
|
384
|
+
const startWidth = sheetWidth;
|
|
385
|
+
const minAllowed = Math.max(320, Number.isFinite(Number(minWidth)) ? Number(minWidth) : 440);
|
|
386
|
+
const maxAllowedBase = Number.isFinite(Number(maxWidth)) ? Number(maxWidth) : 1120;
|
|
387
|
+
const maxAllowed = typeof window !== "undefined"
|
|
388
|
+
? Math.min(maxAllowedBase, Math.max(minAllowed, window.innerWidth - 48))
|
|
389
|
+
: maxAllowedBase;
|
|
390
|
+
let latestWidth = startWidth;
|
|
391
|
+
const onMove = (moveEvent) => {
|
|
392
|
+
const delta = startX - moveEvent.clientX;
|
|
393
|
+
latestWidth = Math.max(minAllowed, Math.min(maxAllowed, startWidth + delta));
|
|
394
|
+
setSheetWidth(latestWidth);
|
|
395
|
+
};
|
|
396
|
+
const onUp = () => {
|
|
397
|
+
window.removeEventListener("pointermove", onMove);
|
|
398
|
+
window.removeEventListener("pointerup", onUp);
|
|
399
|
+
persistSheetWidth(latestWidth);
|
|
400
|
+
};
|
|
401
|
+
window.addEventListener("pointermove", onMove);
|
|
402
|
+
window.addEventListener("pointerup", onUp);
|
|
403
|
+
}, [isSideSheet, maxWidth, minWidth, persistSheetWidth, resizable, sheetWidth]);
|
|
351
404
|
|
|
352
405
|
const handleTouchStart = useCallback((e) => {
|
|
353
406
|
if (!isDragHandleTarget(e.target)) return;
|
|
@@ -530,53 +583,62 @@ export function Modal({
|
|
|
530
583
|
const dragStyle = dragY > 0
|
|
531
584
|
? `transform: translateY(${dragY}px); opacity: ${Math.max(0.2, 1 - dragY / 400)}`
|
|
532
585
|
: "";
|
|
586
|
+
const contentStyle = [
|
|
587
|
+
dragStyle,
|
|
588
|
+
isSideSheet ? `--modal-sheet-width:${sheetWidth}px` : "",
|
|
589
|
+
].filter(Boolean).join("; ");
|
|
533
590
|
|
|
534
591
|
const content = html`
|
|
535
|
-
|
|
592
|
+
<div
|
|
593
|
+
class="modal-overlay ${visible ? "modal-overlay-visible" : ""}"
|
|
594
|
+
onClick=${(e) => {
|
|
595
|
+
if (e.target === e.currentTarget) requestClose();
|
|
596
|
+
}}
|
|
597
|
+
>
|
|
536
598
|
<div
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
}
|
|
599
|
+
ref=${contentRef}
|
|
600
|
+
class="modal-content ${isSideSheet ? "modal-side-sheet" : ""} ${contentClassName} ${visible ? "modal-content-visible" : ""} ${dragY > 0 ? "modal-dragging" : ""}"
|
|
601
|
+
style=${contentStyle}
|
|
602
|
+
onClick=${(e) => e.stopPropagation()}
|
|
603
|
+
onTouchStart=${handleTouchStart}
|
|
604
|
+
onTouchMove=${handleTouchMove}
|
|
605
|
+
onTouchEnd=${handleTouchEnd}
|
|
606
|
+
onTouchCancel=${handleTouchCancel}
|
|
607
|
+
onPointerDown=${handlePointerDown}
|
|
608
|
+
onPointerMove=${handlePointerMove}
|
|
609
|
+
onPointerUp=${handlePointerEnd}
|
|
610
|
+
onPointerCancel=${handlePointerCancel}
|
|
541
611
|
>
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
sx=${{ position: "absolute", right: 8, top: 8, zIndex: 8, pointerEvents: "auto" }}
|
|
569
|
-
>
|
|
570
|
-
${ICONS.close}
|
|
571
|
-
<//>
|
|
572
|
-
</div>
|
|
573
|
-
<div class="modal-body" onTouchStart=${handleBodyTouchStart}>
|
|
574
|
-
${children}
|
|
575
|
-
</div>
|
|
576
|
-
${footer ? html`<div class="modal-footer">${footer}</div>` : null}
|
|
612
|
+
${isSideSheet && resizable
|
|
613
|
+
? html`<div
|
|
614
|
+
class="modal-sheet-resizer"
|
|
615
|
+
onPointerDown=${handleSheetResizeStart}
|
|
616
|
+
title="Resize panel"
|
|
617
|
+
></div>`
|
|
618
|
+
: null}
|
|
619
|
+
<div class="modal-header">
|
|
620
|
+
<div class="modal-handle"></div>
|
|
621
|
+
${title ? html`<div class="modal-title">${title}</div>` : null}
|
|
622
|
+
<${IconButton}
|
|
623
|
+
className="modal-close-btn"
|
|
624
|
+
size="small"
|
|
625
|
+
onTouchStart=${(e) => e.stopPropagation()}
|
|
626
|
+
onPointerDown=${(e) => e.stopPropagation()}
|
|
627
|
+
onPointerUp=${(e) => e.stopPropagation()}
|
|
628
|
+
onMouseDown=${(e) => e.stopPropagation()}
|
|
629
|
+
onClick=${requestClose}
|
|
630
|
+
aria-label="Close"
|
|
631
|
+
sx=${{ position: "absolute", right: 8, top: 8, zIndex: 8, pointerEvents: "auto" }}
|
|
632
|
+
>
|
|
633
|
+
${ICONS.close}
|
|
634
|
+
<//>
|
|
635
|
+
</div>
|
|
636
|
+
<div class="modal-body" onTouchStart=${handleBodyTouchStart}>
|
|
637
|
+
${children}
|
|
577
638
|
</div>
|
|
639
|
+
${footer ? html`<div class="modal-footer">${footer}</div>` : null}
|
|
578
640
|
</div>
|
|
579
|
-
|
|
641
|
+
</div>
|
|
580
642
|
`;
|
|
581
643
|
|
|
582
644
|
const guard = closePromptOpen
|