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.
@@ -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
- setTimeout(() => loadTasks(), 500);
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
- if (!hasMoreTasks || loadingMoreTasks || typeof onLoadMoreTasks !== "function") return;
512
- void onLoadMoreTasks();
513
- }, [hasMoreTasks, loadingMoreTasks, onLoadMoreTasks]);
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
- setTimeout(() => loadTasks(), 500);
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 class="kanban-cards" onScroll=${onCardsScroll}>
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
  `;
@@ -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
- <${Fade} in=${visible} timeout=${300}>
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
- class="modal-overlay ${visible ? "modal-overlay-visible" : ""}"
538
- onClick=${(e) => {
539
- if (e.target === e.currentTarget) requestClose();
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
- <div
543
- ref=${contentRef}
544
- class="modal-content ${contentClassName} ${visible ? "modal-content-visible" : ""} ${dragY > 0 ? "modal-dragging" : ""}"
545
- style=${dragStyle}
546
- onClick=${(e) => e.stopPropagation()}
547
- onTouchStart=${handleTouchStart}
548
- onTouchMove=${handleTouchMove}
549
- onTouchEnd=${handleTouchEnd}
550
- onTouchCancel=${handleTouchCancel}
551
- onPointerDown=${handlePointerDown}
552
- onPointerMove=${handlePointerMove}
553
- onPointerUp=${handlePointerEnd}
554
- onPointerCancel=${handlePointerCancel}
555
- >
556
- <div class="modal-header">
557
- <div class="modal-handle"></div>
558
- ${title ? html`<div class="modal-title">${title}</div>` : null}
559
- <${IconButton}
560
- className="modal-close-btn"
561
- size="small"
562
- onTouchStart=${(e) => e.stopPropagation()}
563
- onPointerDown=${(e) => e.stopPropagation()}
564
- onPointerUp=${(e) => e.stopPropagation()}
565
- onMouseDown=${(e) => e.stopPropagation()}
566
- onClick=${requestClose}
567
- aria-label="Close"
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
- </${Fade}>
641
+ </div>
580
642
  `;
581
643
 
582
644
  const guard = closePromptOpen