trekoon 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.agents/skills/trekoon/SKILL.md +97 -765
  2. package/.agents/skills/trekoon/reference/execution-with-team.md +91 -141
  3. package/.agents/skills/trekoon/reference/execution.md +188 -159
  4. package/.agents/skills/trekoon/reference/harness-primitives.md +77 -0
  5. package/.agents/skills/trekoon/reference/planning.md +213 -213
  6. package/.agents/skills/trekoon/reference/status-machine.md +21 -0
  7. package/.agents/skills/trekoon/reference/sync.md +82 -0
  8. package/README.md +29 -8
  9. package/docs/ai-agents.md +65 -6
  10. package/docs/commands.md +149 -5
  11. package/docs/machine-contracts.md +123 -0
  12. package/docs/quickstart.md +55 -3
  13. package/package.json +1 -1
  14. package/src/board/assets/app.js +47 -13
  15. package/src/board/assets/components/Component.js +20 -8
  16. package/src/board/assets/components/Workspace.js +9 -3
  17. package/src/board/assets/components/helpers.js +4 -0
  18. package/src/board/assets/runtime/delegation.js +8 -0
  19. package/src/board/assets/runtime/focus-trap.js +48 -0
  20. package/src/board/assets/state/actions.js +45 -4
  21. package/src/board/assets/state/api.js +304 -17
  22. package/src/board/assets/state/store.js +82 -11
  23. package/src/board/assets/state/url.js +10 -0
  24. package/src/board/assets/state/utils.js +2 -1
  25. package/src/board/event-bus.ts +81 -0
  26. package/src/board/routes.ts +430 -40
  27. package/src/board/server.ts +86 -10
  28. package/src/board/snapshot.ts +6 -0
  29. package/src/board/wal-watcher.ts +313 -0
  30. package/src/commands/board.ts +52 -17
  31. package/src/commands/epic.ts +7 -9
  32. package/src/commands/error-utils.ts +54 -1
  33. package/src/commands/help.ts +75 -10
  34. package/src/commands/migrate.ts +153 -24
  35. package/src/commands/quickstart.ts +7 -0
  36. package/src/commands/skills.ts +17 -5
  37. package/src/commands/subtask.ts +71 -10
  38. package/src/commands/suggest.ts +6 -13
  39. package/src/commands/task.ts +137 -88
  40. package/src/domain/batch-validation.ts +329 -0
  41. package/src/domain/cascade-planner.ts +412 -0
  42. package/src/domain/dependency-rules.ts +15 -0
  43. package/src/domain/mutation-service.ts +842 -187
  44. package/src/domain/search.ts +113 -0
  45. package/src/domain/tracker-domain.ts +167 -693
  46. package/src/domain/types.ts +56 -2
  47. package/src/export/render-markdown.ts +1 -2
  48. package/src/index.ts +37 -0
  49. package/src/runtime/cli-shell.ts +44 -0
  50. package/src/runtime/daemon.ts +700 -0
  51. package/src/storage/backup.ts +166 -0
  52. package/src/storage/database.ts +268 -4
  53. package/src/storage/migrations.ts +441 -22
  54. package/src/storage/path.ts +8 -0
  55. package/src/storage/schema.ts +5 -1
  56. package/src/sync/event-writes.ts +38 -11
  57. package/src/sync/git-context.ts +226 -8
  58. package/src/sync/service.ts +679 -156
@@ -1,9 +1,10 @@
1
1
  import { createBoardActions } from "./state/actions.js";
2
- import { createApi } from "./state/api.js";
2
+ import { createApi, subscribeSnapshotStream } from "./state/api.js";
3
3
  import { applyTheme, createStore, readThemePreference } from "./state/store.js";
4
4
  import { normalizeSnapshot, normalizeStatus } from "./state/utils.js";
5
5
  import { syncUrlHash } from "./state/url.js";
6
6
  import { createDelegation } from "./runtime/delegation.js";
7
+ import { createOverlayFocusTrap } from "./runtime/focus-trap.js";
7
8
  import { createTopBar } from "./components/TopBar.js";
8
9
  import { createWorkspace } from "./components/Workspace.js";
9
10
  import { createTaskModal } from "./components/TaskModal.js";
@@ -132,15 +133,16 @@ export async function bootLegacyBoard(options = {}) {
132
133
  const runtimeSession = resolveRuntimeSession();
133
134
  if (runtimeSession.shouldScrubAddressBar) scrubTokenFromAddressBar();
134
135
 
135
- // Fetch snapshot
136
- const bootstrap = readJsonScript("trekoon-board-bootstrap") ?? {};
137
- let snapshotPayload = bootstrap?.snapshot ?? readJsonScript("trekoon-board-snapshot") ?? {};
138
- if ((!snapshotPayload || typeof snapshotPayload !== "object") && runtimeSession.token.length > 0) {
139
- const response = await fetch("/api/snapshot");
140
- const payload = await response.json();
141
- if (!payload?.ok) throw new Error(payload?.error?.message || "Board request failed");
142
- snapshotPayload = payload?.data?.snapshot ?? {};
136
+ // Always fetch snapshot client-side. The bootstrap script in index.html
137
+ // only carries the auth token; the snapshot is fetched via /api/snapshot
138
+ // so index.html stays small and never carries board data in its HTML body.
139
+ const response = await fetch("/api/snapshot");
140
+ if (!response.ok) {
141
+ throw new Error(`Board snapshot request failed with status ${response.status}`);
143
142
  }
143
+ const payload = await response.json();
144
+ if (!payload?.ok) throw new Error(payload?.error?.message || "Board request failed");
145
+ const snapshotPayload = payload?.data?.snapshot ?? {};
144
146
 
145
147
  const snapshot = normalizeSnapshot(snapshotPayload);
146
148
 
@@ -258,6 +260,12 @@ export async function bootLegacyBoard(options = {}) {
258
260
  overlay.focus({ preventScroll: true });
259
261
  }
260
262
 
263
+ const overlayFocusTrap = createOverlayFocusTrap({
264
+ doc: document,
265
+ onTabKey: (event) => trapOverlayFocus(event),
266
+ onFocusIn: (event) => containOverlayFocus(event),
267
+ });
268
+
261
269
  function syncOverlayEnvironment() {
262
270
  const hadOverlay = activeOverlay instanceof HTMLElement;
263
271
  const nextOverlay = getActiveOverlayElement();
@@ -271,6 +279,10 @@ export async function bootLegacyBoard(options = {}) {
271
279
  lockBackgroundScroll();
272
280
  setBackgroundInert(true);
273
281
  }
282
+ // Attach the document-level focus trap only while an overlay is actually
283
+ // open. This avoids the microtask window where Tab keydowns get swallowed
284
+ // by a stale handler with no overlay to confine to.
285
+ overlayFocusTrap.attach();
274
286
  queueMicrotask(() => focusOverlay(nextOverlay));
275
287
  return;
276
288
  }
@@ -280,6 +292,7 @@ export async function bootLegacyBoard(options = {}) {
280
292
  unlockBackgroundScroll();
281
293
  setBackgroundInert(false);
282
294
  }
295
+ overlayFocusTrap.detach();
283
296
  queueMicrotask(() => restoreOverlayFocus());
284
297
  }
285
298
 
@@ -355,8 +368,9 @@ export async function bootLegacyBoard(options = {}) {
355
368
  return true;
356
369
  }
357
370
 
358
- document.addEventListener("keydown", trapOverlayFocus, true);
359
- document.addEventListener("focusin", containOverlayFocus, true);
371
+ // Focus-trap listeners are attached lazily by syncOverlayEnvironment when an
372
+ // overlay actually opens (and detached when it closes), so plain Tab outside
373
+ // any overlay reaches normal browser flow.
360
374
 
361
375
  // Render cycle
362
376
  function rerender() {
@@ -366,6 +380,7 @@ export async function bootLegacyBoard(options = {}) {
366
380
  const selectedTask = boardState.selectedTask;
367
381
  const selectedSubtask = boardState.selectedSubtask;
368
382
  const taskModalOpen = Boolean(boardState.taskModalOpen && selectedTask);
383
+ const subtaskModalOpen = Boolean(boardState.subtaskModalOpen && selectedSubtask);
369
384
  const currentNav = taskModalOpen ? "detail" : screen === "tasks" ? "board" : "epics";
370
385
 
371
386
  // Layout toggles
@@ -386,7 +401,7 @@ export async function bootLegacyBoard(options = {}) {
386
401
  taskModal.update(null);
387
402
  }
388
403
 
389
- if (!showTasks || !taskModalOpen || !selectedSubtask) {
404
+ if (!showTasks || !taskModalOpen || !subtaskModalOpen) {
390
405
  subtaskModal.update(null);
391
406
  }
392
407
 
@@ -443,7 +458,7 @@ export async function bootLegacyBoard(options = {}) {
443
458
  });
444
459
  }
445
460
 
446
- subtaskModal.update(showTasks && taskModalOpen && selectedSubtask ? {
461
+ subtaskModal.update(showTasks && taskModalOpen && subtaskModalOpen ? {
447
462
  subtask: selectedSubtask,
448
463
  isMutating: store.isMutating,
449
464
  } : null);
@@ -453,6 +468,24 @@ export async function bootLegacyBoard(options = {}) {
453
468
 
454
469
  const api = createApi(model, { sessionToken: runtimeSession.token, rerender });
455
470
 
471
+ // Subscribe to /api/snapshot/stream so external CLI writes (picked up by
472
+ // the WAL watcher) and concurrent-tab mutations are reflected in this
473
+ // board within ~1s without manual refresh. applySnapshotDelta is idempotent
474
+ // so re-receiving deltas already applied via mutation responses is safe.
475
+ // Capture the handle so we can tear it down on page unload (and so tests
476
+ // and future teardown paths can dispose the EventSource).
477
+ const snapshotSubscription = subscribeSnapshotStream(model, {
478
+ sessionToken: runtimeSession.token,
479
+ rerender,
480
+ });
481
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
482
+ const disposeSnapshotSubscription = () => {
483
+ snapshotSubscription.dispose();
484
+ };
485
+ window.addEventListener("beforeunload", disposeSnapshotSubscription, { once: true });
486
+ window.addEventListener("pagehide", disposeSnapshotSubscription, { once: true });
487
+ }
488
+
456
489
  // Actions for delegation
457
490
  const actions = createBoardActions({
458
491
  model,
@@ -551,6 +584,7 @@ export async function bootLegacyBoard(options = {}) {
551
584
  addDependency: (src, data) => actions.addDependency(src, data),
552
585
  dropTaskStatus: (id, status) => actions.dropTaskStatus(id, status),
553
586
  getTaskStatus: (id) => actions.getTaskStatus(id),
587
+ setDragFeedback: (feedback) => actions.setDragFeedback(feedback),
554
588
  changeEpicStatus: (epicId, status) => actions.changeEpicStatus(epicId, status),
555
589
  bulkSetStatus: (epicId, status) => actions.bulkSetStatus(epicId, status),
556
590
  toggleEpicStatusFilter: (status) => actions.toggleEpicStatusFilter(status),
@@ -80,11 +80,18 @@ function getNamespacedFormIdentity(form) {
80
80
  return "anonymous-form";
81
81
  }
82
82
 
83
- function getManagedControls(root) {
83
+ function getManagedControls(root, cache) {
84
+ if (cache) {
85
+ const cached = cache.get(root);
86
+ if (cached) return cached;
87
+ const controls = Array.from(root.querySelectorAll("input, textarea, select"));
88
+ cache.set(root, controls);
89
+ return controls;
90
+ }
84
91
  return Array.from(root.querySelectorAll("input, textarea, select"));
85
92
  }
86
93
 
87
- function getControlIdentity(el, form) {
94
+ function getControlIdentity(el, form, cache) {
88
95
  const controlKey = el.getAttribute("data-control-id");
89
96
  if (controlKey) {
90
97
  return `control:${controlKey}`;
@@ -98,12 +105,12 @@ function getControlIdentity(el, form) {
98
105
  if (name) {
99
106
  const tagName = el.tagName.toLowerCase();
100
107
  const type = tagName === "input" ? (el.getAttribute("type") ?? "text") : tagName;
101
- const peers = getManagedControls(form).filter((candidate) => candidate.getAttribute("name") === name);
108
+ const peers = getManagedControls(form, cache).filter((candidate) => candidate.getAttribute("name") === name);
102
109
  const index = peers.indexOf(el);
103
110
  return `name:${name}:${type}:${index}`;
104
111
  }
105
112
 
106
- const index = getManagedControls(form).indexOf(el);
113
+ const index = getManagedControls(form, cache).indexOf(el);
107
114
  return `index:${index}:${el.tagName.toLowerCase()}`;
108
115
  }
109
116
 
@@ -143,7 +150,11 @@ function restoreSelection(el, selectionStart, selectionEnd) {
143
150
  */
144
151
  export function preserveFormState(container, writeFn, options = {}) {
145
152
  const resetFormIds = new Set(options.resetFormIds ?? []);
146
- const inputs = getManagedControls(container);
153
+
154
+ // Per-form cache for getManagedControls: avoids the O(n^2) re-query that
155
+ // occurs when many controls share the same form root.
156
+ const controlCache = new Map();
157
+ const inputs = getManagedControls(container, controlCache);
147
158
 
148
159
  const activeElement = document.activeElement;
149
160
  let focusedIdentity = null;
@@ -151,7 +162,7 @@ export function preserveFormState(container, writeFn, options = {}) {
151
162
  const savedStates = inputs.map((el) => {
152
163
  const form = getFormRoot(el);
153
164
  const formId = getNamespacedFormIdentity(form);
154
- const controlId = form ? getControlIdentity(el, form) : null;
165
+ const controlId = form ? getControlIdentity(el, form, controlCache) : null;
155
166
  const identity = controlId ? { formId, controlId } : null;
156
167
 
157
168
  if (activeElement === el) {
@@ -169,6 +180,7 @@ export function preserveFormState(container, writeFn, options = {}) {
169
180
  }).filter(s => s.identity);
170
181
 
171
182
  writeFn();
183
+ controlCache.clear();
172
184
 
173
185
  const formsByIdentity = new Map(
174
186
  Array.from(container.querySelectorAll(FORM_ROOT_SELECTOR)).map((form) => [
@@ -183,7 +195,7 @@ export function preserveFormState(container, writeFn, options = {}) {
183
195
  continue;
184
196
  }
185
197
  const form = formsByIdentity.get(formId) ?? container;
186
- const restored = getManagedControls(form).find((control) => getControlIdentity(control, form) === controlId);
198
+ const restored = getManagedControls(form, controlCache).find((control) => getControlIdentity(control, form, controlCache) === controlId);
187
199
  if (restored && restored.value !== state.value) {
188
200
  restored.value = state.value;
189
201
  }
@@ -199,7 +211,7 @@ export function preserveFormState(container, writeFn, options = {}) {
199
211
  return;
200
212
  }
201
213
  const form = formsByIdentity.get(formId) ?? container;
202
- const restored = getManagedControls(form).find((control) => getControlIdentity(control, form) === controlId);
214
+ const restored = getManagedControls(form, controlCache).find((control) => getControlIdentity(control, form, controlCache) === controlId);
203
215
  if (restored) {
204
216
  restored.focus({ preventScroll: true });
205
217
  const focusedState = savedStates.find((state) => state.identity?.formId === formId && state.identity?.controlId === controlId);
@@ -166,7 +166,7 @@ function renderWorkspaceHeader(props) {
166
166
  // ---------------------------------------------------------------------------
167
167
 
168
168
  function renderKanbanColumns(props) {
169
- const { visibleTasks, selectedTaskId, isMutating, taskStatusFilter } = props;
169
+ const { visibleTasks, selectedTaskId, isMutating, taskStatusFilter, dragFeedback } = props;
170
170
  const filter = taskStatusFilter || { ...DEFAULT_STATUS_FILTER };
171
171
 
172
172
  const columnsMarkup = STATUS_ORDER
@@ -178,6 +178,12 @@ function renderKanbanColumns(props) {
178
178
  ? renderEmptyState(`No ${columnTitle.toLowerCase()} work`, "Adjust search or switch epics to inspect more tasks.")
179
179
  : columnTasks.map((task) => renderTaskCard({ task, selected: selectedTaskId === task.id, isMutating })).join("");
180
180
 
181
+ // Re-apply drag-feedback class from store so a rerender during drag does
182
+ // not silently wipe the visual feedback that was set by handleDragover.
183
+ const feedbackClass = dragFeedback?.targetStatus === status
184
+ ? (dragFeedback.kind === "valid" ? " board-drop-valid" : " board-drop-invalid")
185
+ : "";
186
+
181
187
  return `
182
188
  <section class="board-column board-column--dense ${secondaryPanelClasses("flex min-h-[20rem] min-w-0 flex-col p-3")}" aria-labelledby="column-${status}">
183
189
  <header class="board-column__header flex items-start justify-between gap-3 border-b border-[var(--board-border)] pb-3">
@@ -190,7 +196,7 @@ function renderKanbanColumns(props) {
190
196
  </div>
191
197
  ${columnTasks.length > 0 ? `<span class="board-column__count text-xs font-medium text-[var(--board-text-soft)]">${columnTasks.length === 1 ? "1 task" : `${columnTasks.length} tasks`}</span>` : ""}
192
198
  </header>
193
- <div class="board-column__tasks mt-3 grid min-h-0 flex-1 content-start gap-2.5 overflow-auto pr-1 overscroll-contain" id="column-${status}" data-drop-status="${escapeHtml(status)}">${content}</div>
199
+ <div class="board-column__tasks mt-3 grid min-h-0 flex-1 content-start gap-2.5 overflow-auto pr-1 overscroll-contain${feedbackClass}" id="column-${status}" data-drop-status="${escapeHtml(status)}">${content}</div>
194
200
  </section>
195
201
  `;
196
202
  }).join("");
@@ -292,7 +298,7 @@ function render(props) {
292
298
  });
293
299
 
294
300
  const contentMarkup = store.view === "kanban"
295
- ? renderKanbanColumns({ visibleTasks, selectedTaskId, isMutating: store.isMutating, taskStatusFilter: store.taskStatusFilter })
301
+ ? renderKanbanColumns({ visibleTasks, selectedTaskId, isMutating: store.isMutating, taskStatusFilter: store.taskStatusFilter, dragFeedback: store.dragFeedback ?? null })
296
302
  : renderListView({ visibleTasks, selectedTaskId });
297
303
 
298
304
  return `
@@ -255,6 +255,10 @@ export function shouldUseTaskModal(boardState, store) {
255
255
  return Boolean(boardState?.taskModalOpen && boardState?.selectedTask);
256
256
  }
257
257
 
258
+ export function shouldUseSubtaskModal(boardState) {
259
+ return Boolean(boardState?.subtaskModalOpen && boardState?.selectedSubtask);
260
+ }
261
+
258
262
  export function lookupNode(snapshot, id) {
259
263
  return snapshot.tasks.find((task) => task.id === id)
260
264
  ?? snapshot.subtasks.find((subtask) => subtask.id === id)
@@ -310,6 +310,8 @@ export function createDelegation(rootElement, actions) {
310
310
  for (const el of rootElement.querySelectorAll(".board-drop-valid, .board-drop-invalid")) {
311
311
  el.classList.remove("board-drop-valid", "board-drop-invalid");
312
312
  }
313
+ // Clear feedback in store so rerenders do not re-apply stale classes.
314
+ actions.setDragFeedback?.(null);
313
315
  }
314
316
 
315
317
  function handleDragstart(event) {
@@ -336,11 +338,15 @@ export function createDelegation(rootElement, actions) {
336
338
  const targetStatus = column.dataset.dropStatus;
337
339
  if (draggedTaskStatus && isValidTransition(draggedTaskStatus, targetStatus)) {
338
340
  event.preventDefault();
341
+ // Fast path: apply directly to DOM for immediate visual feedback.
339
342
  column.classList.add("board-drop-valid");
340
343
  column.classList.remove("board-drop-invalid");
344
+ // Also persist in store so a rerender during drag restores the class.
345
+ actions.setDragFeedback?.({ targetStatus, kind: "valid" });
341
346
  } else if (draggedTaskStatus && targetStatus !== draggedTaskStatus) {
342
347
  column.classList.add("board-drop-invalid");
343
348
  column.classList.remove("board-drop-valid");
349
+ actions.setDragFeedback?.({ targetStatus, kind: "invalid" });
344
350
  }
345
351
  }
346
352
 
@@ -348,6 +354,8 @@ export function createDelegation(rootElement, actions) {
348
354
  const column = event.target.closest("[data-drop-status]");
349
355
  if (column && !column.contains(event.relatedTarget)) {
350
356
  column.classList.remove("board-drop-valid", "board-drop-invalid");
357
+ // Clear store feedback when leaving the column.
358
+ actions.setDragFeedback?.(null);
351
359
  }
352
360
  }
353
361
 
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Lazy overlay focus-trap controller.
3
+ *
4
+ * Attaches the document-level keydown/focusin listeners only while an overlay
5
+ * is actually open, and detaches them on close. Without this, plain Tab outside
6
+ * any overlay can be intercepted by a stale handler in the microtask window
7
+ * between overlay close and rerender.
8
+ *
9
+ * @param {{
10
+ * doc?: Document,
11
+ * onTabKey: (event: KeyboardEvent) => void,
12
+ * onFocusIn: (event: FocusEvent) => void,
13
+ * }} options
14
+ */
15
+ export function createOverlayFocusTrap(options) {
16
+ const doc = options.doc ?? (typeof document !== "undefined" ? document : null);
17
+ if (!doc) {
18
+ return {
19
+ attach() {},
20
+ detach() {},
21
+ isAttached() { return false; },
22
+ };
23
+ }
24
+
25
+ const onKeyDown = options.onTabKey;
26
+ const onFocusIn = options.onFocusIn;
27
+ let attached = false;
28
+
29
+ function attach() {
30
+ if (attached) return;
31
+ doc.addEventListener("keydown", onKeyDown, true);
32
+ doc.addEventListener("focusin", onFocusIn, true);
33
+ attached = true;
34
+ }
35
+
36
+ function detach() {
37
+ if (!attached) return;
38
+ doc.removeEventListener("keydown", onKeyDown, true);
39
+ doc.removeEventListener("focusin", onFocusIn, true);
40
+ attached = false;
41
+ }
42
+
43
+ return {
44
+ attach,
45
+ detach,
46
+ isAttached() { return attached; },
47
+ };
48
+ }
@@ -142,6 +142,13 @@ export function createBoardActions(options) {
142
142
  searchFocusKeys,
143
143
  } = options;
144
144
  const { store, persist, getBoardState, getTaskById, syncState } = model;
145
+ // Direct mutations of `store.*` (filter toggles, notice changes, drag
146
+ // feedback) bypass setState/syncState, so `getBoardState()`'s memo would
147
+ // otherwise return a stale reference until the next syncState/notify.
148
+ // Route these through invalidateBoardStateMemo() before rerender.
149
+ const invalidateMemo = typeof model.invalidateBoardStateMemo === "function"
150
+ ? () => model.invalidateBoardStateMemo()
151
+ : () => {};
145
152
 
146
153
  const transition = (patch = {}, options = {}) => {
147
154
  const { persistState = true, rerenderBoard = true } = options;
@@ -348,13 +355,21 @@ export function createBoardActions(options) {
348
355
  });
349
356
  },
350
357
  closeTask() {
351
- transition({ selectedTaskId: null, selectedSubtaskId: null, taskModalOpen: false });
358
+ transition({
359
+ selectedTaskId: null,
360
+ selectedSubtaskId: null,
361
+ taskModalOpen: false,
362
+ subtaskModalOpen: false,
363
+ });
352
364
  },
353
365
  openSubtask(subtaskId) {
354
- transition({ selectedSubtaskId: subtaskId || null }, { persistState: false });
366
+ transition(
367
+ { selectedSubtaskId: subtaskId || null, subtaskModalOpen: Boolean(subtaskId) },
368
+ { persistState: false },
369
+ );
355
370
  },
356
371
  closeSubtask() {
357
- transition({ selectedSubtaskId: null }, { persistState: false });
372
+ transition({ selectedSubtaskId: null, subtaskModalOpen: false }, { persistState: false });
358
373
  },
359
374
  submitTaskForm(taskId, formData) {
360
375
  const updates = {
@@ -413,12 +428,34 @@ export function createBoardActions(options) {
413
428
  const task = getTaskById(taskId);
414
429
  return task?.status ?? null;
415
430
  },
431
+ /**
432
+ * Record the current drag-over feedback in store state so it survives a
433
+ * rerender triggered by an unrelated event (e.g. a server SSE push).
434
+ * Pass null to clear feedback (dragend / drop / dragleave).
435
+ *
436
+ * @param {{ targetStatus: string, kind: 'valid'|'invalid' }|null} feedback
437
+ */
438
+ setDragFeedback(feedback) {
439
+ // Reference equality on freshly-allocated `{ targetStatus, kind }` always
440
+ // fails, so dragover would rerender on every move. Compare by primitive
441
+ // key so repeats over the same column become a no-op.
442
+ const prev = store.dragFeedback;
443
+ const prevKey = prev ? `${prev.targetStatus}|${prev.kind}` : null;
444
+ const nextKey = feedback ? `${feedback.targetStatus}|${feedback.kind}` : null;
445
+ if (prevKey === nextKey) return;
446
+ store.dragFeedback = feedback;
447
+ if (typeof model.invalidateBoardStateMemo === "function") {
448
+ model.invalidateBoardStateMemo();
449
+ }
450
+ rerender();
451
+ },
416
452
  dropTaskStatus(taskId, nextStatus) {
417
453
  const task = getTaskById(taskId);
418
454
  if (!task || !nextStatus || task.status === nextStatus) {
419
455
  return;
420
456
  }
421
- transition({ selectedTaskId: taskId, taskModalOpen: false }, { rerenderBoard: false });
457
+ // Drag/drop is a status change only; do not mutate selection or modal state,
458
+ // otherwise dropping a card while another task modal is open hijacks it.
422
459
  api.patchTask(taskId, { status: nextStatus }, (snapshot) => updateTaskInSnapshot(snapshot, taskId, { status: nextStatus }, normalizeSnapshot));
423
460
  },
424
461
  changeEpicStatus(epicId, newStatus) {
@@ -438,22 +475,26 @@ export function createBoardActions(options) {
438
475
  toggleEpicStatusFilter(status) {
439
476
  const current = store.epicStatusFilter || { ...DEFAULT_STATUS_FILTER };
440
477
  store.epicStatusFilter = { ...current, [status]: !current[status] };
478
+ invalidateMemo();
441
479
  persist();
442
480
  rerender();
443
481
  },
444
482
  toggleTaskStatusFilter(status) {
445
483
  const current = store.taskStatusFilter || { ...DEFAULT_STATUS_FILTER };
446
484
  store.taskStatusFilter = { ...current, [status]: !current[status] };
485
+ invalidateMemo();
447
486
  persist();
448
487
  rerender();
449
488
  },
450
489
  resetEpicFilter() {
451
490
  store.epicStatusFilter = { ...DEFAULT_STATUS_FILTER };
491
+ invalidateMemo();
452
492
  persist();
453
493
  rerender();
454
494
  },
455
495
  resetTaskFilter() {
456
496
  store.taskStatusFilter = { ...DEFAULT_STATUS_FILTER };
497
+ invalidateMemo();
457
498
  persist();
458
499
  rerender();
459
500
  },