trekoon 0.4.0 → 0.4.2

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 (54) hide show
  1. package/.agents/skills/trekoon/SKILL.md +20 -577
  2. package/.agents/skills/trekoon/reference/execution-with-team.md +21 -9
  3. package/.agents/skills/trekoon/reference/execution.md +246 -7
  4. package/.agents/skills/trekoon/reference/planning.md +138 -1
  5. package/.agents/skills/trekoon/reference/status-machine.md +21 -0
  6. package/.agents/skills/trekoon/reference/sync.md +129 -0
  7. package/README.md +8 -7
  8. package/docs/ai-agents.md +17 -2
  9. package/docs/commands.md +147 -3
  10. package/docs/machine-contracts.md +123 -0
  11. package/docs/quickstart.md +52 -0
  12. package/package.json +1 -1
  13. package/src/board/assets/app.js +49 -16
  14. package/src/board/assets/components/Component.js +22 -8
  15. package/src/board/assets/components/Workspace.js +9 -3
  16. package/src/board/assets/components/helpers.js +5 -1
  17. package/src/board/assets/runtime/delegation.js +8 -0
  18. package/src/board/assets/runtime/focus-trap.js +48 -0
  19. package/src/board/assets/state/actions.js +47 -4
  20. package/src/board/assets/state/api.js +284 -11
  21. package/src/board/assets/state/store.js +87 -11
  22. package/src/board/assets/state/url.js +10 -0
  23. package/src/board/assets/state/utils.js +2 -1
  24. package/src/board/event-bus.ts +72 -0
  25. package/src/board/routes.ts +412 -33
  26. package/src/board/server.ts +77 -8
  27. package/src/board/wal-watcher.ts +302 -0
  28. package/src/commands/board.ts +52 -17
  29. package/src/commands/epic.ts +7 -9
  30. package/src/commands/error-utils.ts +54 -1
  31. package/src/commands/help.ts +69 -4
  32. package/src/commands/migrate.ts +153 -24
  33. package/src/commands/quickstart.ts +7 -0
  34. package/src/commands/subtask.ts +71 -10
  35. package/src/commands/suggest.ts +6 -13
  36. package/src/commands/task.ts +137 -88
  37. package/src/domain/batch-validation.ts +329 -0
  38. package/src/domain/cascade-planner.ts +412 -0
  39. package/src/domain/dependency-rules.ts +15 -0
  40. package/src/domain/mutation-service.ts +828 -192
  41. package/src/domain/search.ts +113 -0
  42. package/src/domain/tracker-domain.ts +150 -680
  43. package/src/domain/types.ts +53 -2
  44. package/src/index.ts +37 -0
  45. package/src/runtime/cli-shell.ts +44 -0
  46. package/src/runtime/daemon.ts +639 -0
  47. package/src/storage/backup.ts +166 -0
  48. package/src/storage/database.ts +261 -4
  49. package/src/storage/migrations.ts +422 -20
  50. package/src/storage/path.ts +8 -0
  51. package/src/storage/schema.ts +5 -1
  52. package/src/sync/event-writes.ts +38 -11
  53. package/src/sync/git-context.ts +226 -8
  54. package/src/sync/service.ts +650 -147
@@ -13,6 +13,133 @@ function cloneSnapshot(snapshot) {
13
13
  return JSON.parse(JSON.stringify(snapshot));
14
14
  }
15
15
 
16
+ const SNAPSHOT_COLLECTIONS = ["epics", "tasks", "subtasks", "dependencies"];
17
+
18
+ const COLLECTION_TO_DELETED_KEY = {
19
+ epics: "deletedEpicIds",
20
+ tasks: "deletedTaskIds",
21
+ subtasks: "deletedSubtaskIds",
22
+ dependencies: "deletedDependencyIds",
23
+ };
24
+
25
+ function arraysShallowEqual(left, right) {
26
+ if (left === right) return true;
27
+ if (!Array.isArray(left) || !Array.isArray(right)) return false;
28
+ if (left.length !== right.length) return false;
29
+ for (let i = 0; i < left.length; i += 1) {
30
+ if (left[i] !== right[i]) return false;
31
+ }
32
+ return true;
33
+ }
34
+
35
+ /**
36
+ * Shallow equality on plain board records.
37
+ *
38
+ * Board records are flat objects whose values are primitives or arrays of
39
+ * primitives (e.g. dependency-id arrays). A field-by-field shallow comparison
40
+ * is therefore equivalent to a structural deep-equal but avoids the
41
+ * O(snapshot) JSON.stringify cost the previous implementation paid on every
42
+ * rollback. Cross-realm values and odd nested objects fall back to
43
+ * reference equality, which is a safe over-restore (worst case: an unchanged
44
+ * record is included in the inverse delta).
45
+ */
46
+ function recordsShallowEqual(left, right) {
47
+ if (left === right) return true;
48
+ if (!left || !right || typeof left !== "object" || typeof right !== "object") return false;
49
+ const leftKeys = Object.keys(left);
50
+ const rightKeys = Object.keys(right);
51
+ if (leftKeys.length !== rightKeys.length) return false;
52
+ for (const key of leftKeys) {
53
+ const leftValue = left[key];
54
+ const rightValue = right[key];
55
+ if (leftValue === rightValue) continue;
56
+ if (Array.isArray(leftValue) || Array.isArray(rightValue)) {
57
+ if (!arraysShallowEqual(leftValue, rightValue)) return false;
58
+ continue;
59
+ }
60
+ return false;
61
+ }
62
+ return true;
63
+ }
64
+
65
+ function indexById(records) {
66
+ const map = new Map();
67
+ if (!Array.isArray(records)) {
68
+ return map;
69
+ }
70
+
71
+ for (const record of records) {
72
+ if (record && typeof record === "object" && typeof record.id === "string" && record.id.length > 0) {
73
+ map.set(record.id, record);
74
+ }
75
+ }
76
+
77
+ return map;
78
+ }
79
+
80
+ /**
81
+ * Compute the inverse delta needed to revert an optimistic mutation.
82
+ *
83
+ * The inverse is built by diffing the snapshot _before_ the optimistic patch
84
+ * against the snapshot _after_ the patch. We only describe entities the
85
+ * optimistic patch actually touched so that concurrent deltas pushed by the
86
+ * server (for unrelated entities) are preserved when we apply the inverse.
87
+ *
88
+ * @param {object} previousSnapshot - Snapshot prior to optimistic apply.
89
+ * @param {object} optimisticSnapshot - Snapshot after optimistic apply.
90
+ * @returns {{
91
+ * epics?: object[], tasks?: object[], subtasks?: object[], dependencies?: object[],
92
+ * deletedEpicIds?: string[], deletedTaskIds?: string[], deletedSubtaskIds?: string[], deletedDependencyIds?: string[],
93
+ * }}
94
+ */
95
+ export function computeInverseDelta(previousSnapshot, optimisticSnapshot) {
96
+ const inverse = {};
97
+ for (const collection of SNAPSHOT_COLLECTIONS) {
98
+ const before = indexById(previousSnapshot?.[collection]);
99
+ const after = indexById(optimisticSnapshot?.[collection]);
100
+
101
+ const restored = [];
102
+ const deletedIds = [];
103
+
104
+ // Entities that the optimistic patch deleted -> restore them.
105
+ for (const [id, beforeRecord] of before) {
106
+ if (!after.has(id)) {
107
+ restored.push(beforeRecord);
108
+ }
109
+ }
110
+
111
+ // Entities present in both but mutated -> restore the previous version.
112
+ // cloneSnapshot/normalizeSnapshot always produce fresh references for
113
+ // every record in the optimistic snapshot, so plain reference inequality
114
+ // would flag every entity. Use a shallow field-by-field equality check
115
+ // instead — equivalent to a structural compare for these flat records but
116
+ // O(field) per record rather than O(snapshot) JSON.stringify on each
117
+ // rollback (P2 perf finding).
118
+ for (const [id, afterRecord] of after) {
119
+ const beforeRecord = before.get(id);
120
+ if (beforeRecord && beforeRecord !== afterRecord && !recordsShallowEqual(beforeRecord, afterRecord)) {
121
+ restored.push(beforeRecord);
122
+ }
123
+ }
124
+
125
+ // Entities the optimistic patch added -> mark for deletion.
126
+ for (const id of after.keys()) {
127
+ if (!before.has(id)) {
128
+ deletedIds.push(id);
129
+ }
130
+ }
131
+
132
+ if (restored.length > 0) {
133
+ inverse[collection] = restored;
134
+ }
135
+ if (deletedIds.length > 0) {
136
+ inverse[COLLECTION_TO_DELETED_KEY[collection]] = deletedIds;
137
+ }
138
+ }
139
+
140
+ return inverse;
141
+ }
142
+
16
143
  async function readJsonPayload(response) {
17
144
  const text = await response.text();
18
145
  if (text.length === 0) {
@@ -119,14 +246,27 @@ export function createMutationQueue(model, rerender) {
119
246
 
120
247
  while (queue.length > 0) {
121
248
  const mutation = queue.shift();
122
- const previousSnapshot = cloneSnapshot(model.store.snapshot);
123
249
  if (model.store.notice?.retryMutationId !== mutation.id) {
124
250
  model.store.notice = null;
125
251
  }
126
252
 
253
+ // Capture per-mutation inverse delta if the optimistic patch ran.
254
+ // Using an inverse delta (rather than wholesale replaceSnapshot) means
255
+ // concurrent server-pushed deltas applied to unrelated entities while
256
+ // the request was in flight survive a rollback.
257
+ let inverseDelta = null;
258
+
127
259
  try {
128
260
  if (typeof mutation.optimistic === "function") {
129
- model.store.snapshot = mutation.optimistic(cloneSnapshot(model.store.snapshot));
261
+ const previousSnapshot = cloneSnapshot(model.store.snapshot);
262
+ const optimisticSnapshot = mutation.optimistic(cloneSnapshot(model.store.snapshot));
263
+ inverseDelta = computeInverseDelta(previousSnapshot, optimisticSnapshot);
264
+ model.store.snapshot = optimisticSnapshot;
265
+ // Direct snapshot mutation bypasses setState/syncState; invalidate
266
+ // the memo so the next getBoardState() reflects the optimistic write.
267
+ if (typeof model.invalidateBoardStateMemo === "function") {
268
+ model.invalidateBoardStateMemo();
269
+ }
130
270
  rerender();
131
271
  }
132
272
 
@@ -146,8 +286,11 @@ export function createMutationQueue(model, rerender) {
146
286
  ? { type: "success", message: mutation.successMessage }
147
287
  : null;
148
288
  } catch (error) {
149
- // Revert to pre-optimistic snapshot
150
- model.replaceSnapshot(previousSnapshot);
289
+ // Revert only the entities this mutation touched. Any unrelated
290
+ // entities updated by concurrent server deltas remain intact.
291
+ if (inverseDelta) {
292
+ model.applySnapshotDelta(inverseDelta);
293
+ }
151
294
 
152
295
  const message = error instanceof Error ? error.message : String(error);
153
296
  model.store.notice = {
@@ -166,6 +309,9 @@ export function createMutationQueue(model, rerender) {
166
309
 
167
310
  processing = false;
168
311
  model.store.isMutating = false;
312
+ if (typeof model.invalidateBoardStateMemo === "function") {
313
+ model.invalidateBoardStateMemo();
314
+ }
169
315
  rerender();
170
316
  resolveFlushes();
171
317
  }
@@ -204,20 +350,26 @@ export function createApi(model, options) {
204
350
  let lastFailedMutation = null;
205
351
 
206
352
  function enqueueMutation(definition) {
353
+ // Assign a stable identity token so success callbacks can clear
354
+ // lastFailedMutation by id rather than by function-reference equality
355
+ // (inline arrow functions are never the same reference across retries).
356
+ const mutationId = crypto.randomUUID();
357
+ const tagged = { ...definition, mutationId };
358
+
207
359
  queue.enqueue({
208
- ...definition,
360
+ ...tagged,
209
361
  onSuccess(data) {
210
- if (lastFailedMutation?.request === definition.request) {
362
+ if (lastFailedMutation?.mutationId === mutationId) {
211
363
  lastFailedMutation = null;
212
364
  }
213
- if (typeof definition.onSuccess === "function") {
214
- definition.onSuccess(data);
365
+ if (typeof tagged.onSuccess === "function") {
366
+ tagged.onSuccess(data);
215
367
  }
216
368
  },
217
369
  onError(error) {
218
- lastFailedMutation = definition;
219
- if (typeof definition.onError === "function") {
220
- definition.onError(error);
370
+ lastFailedMutation = tagged;
371
+ if (typeof tagged.onError === "function") {
372
+ tagged.onError(error);
221
373
  }
222
374
  },
223
375
  });
@@ -403,3 +555,124 @@ export function createApi(model, options) {
403
555
  },
404
556
  };
405
557
  }
558
+
559
+ /**
560
+ * Subscribe the board client to /api/snapshot/stream.
561
+ *
562
+ * Receives `snapshotDelta` events emitted by the per-server-instance event bus
563
+ * (own server mutations + WAL-watcher-derived deltas from external CLI writes)
564
+ * and applies them via `model.applySnapshotDelta`. Idempotent merge means
565
+ * re-applying a delta we already saw via the mutation response is harmless.
566
+ *
567
+ * Returns a `dispose()` function that closes the EventSource and stops
568
+ * processing further events.
569
+ *
570
+ * @param {object} model - Store with `applySnapshotDelta` method
571
+ * @param {object} options
572
+ * @param {string} options.sessionToken - Auth token (forwarded as ?token=)
573
+ * @param {function} options.rerender - Trigger UI rerender after applying deltas
574
+ * @param {typeof EventSource} [options.EventSourceCtor] - Constructor override for tests
575
+ * @param {string} [options.path] - Override stream path; default /api/snapshot/stream
576
+ * @returns {{ dispose: () => void, eventSource: EventSource | null }}
577
+ */
578
+ export function subscribeSnapshotStream(model, options) {
579
+ const {
580
+ sessionToken,
581
+ rerender,
582
+ EventSourceCtor = typeof EventSource !== "undefined" ? EventSource : null,
583
+ path = "/api/snapshot/stream",
584
+ } = options ?? {};
585
+
586
+ if (!EventSourceCtor) {
587
+ return { dispose: () => {}, eventSource: null };
588
+ }
589
+
590
+ // EventSource cannot set custom headers, so the auth token rides as a query
591
+ // parameter. Server `extractToken` already accepts ?token=.
592
+ const url = sessionToken && sessionToken.length > 0
593
+ ? `${path}?token=${encodeURIComponent(sessionToken)}`
594
+ : path;
595
+
596
+ let disposed = false;
597
+ const eventSource = new EventSourceCtor(url);
598
+
599
+ const handleSnapshotDelta = (event) => {
600
+ if (disposed) return;
601
+ const raw = typeof event?.data === "string" ? event.data : "";
602
+ if (raw.length === 0) return;
603
+ let payload;
604
+ try {
605
+ payload = JSON.parse(raw);
606
+ } catch (error) {
607
+ const message = error instanceof Error ? error.message : String(error);
608
+ // Surface malformed payloads so operators can spot serialization bugs.
609
+ // Avoid logging the raw text (may include sensitive content) — just length + error.
610
+ console.warn(`subscribeSnapshotStream: malformed snapshotDelta JSON (${raw.length} bytes): ${message}`);
611
+ return;
612
+ }
613
+ const delta = payload?.snapshotDelta;
614
+ if (!delta || typeof delta !== "object") return;
615
+ model.applySnapshotDelta(delta);
616
+ if (typeof rerender === "function") rerender();
617
+ };
618
+
619
+ const handleSnapshot = (event) => {
620
+ if (disposed) return;
621
+ const raw = typeof event?.data === "string" ? event.data : "";
622
+ if (raw.length === 0) return;
623
+ let payload;
624
+ try {
625
+ payload = JSON.parse(raw);
626
+ } catch (error) {
627
+ const message = error instanceof Error ? error.message : String(error);
628
+ console.warn(`subscribeSnapshotStream: malformed snapshot JSON (${raw.length} bytes): ${message}`);
629
+ return;
630
+ }
631
+ const snapshot = payload?.snapshot;
632
+ if (!snapshot || typeof snapshot !== "object") return;
633
+ if (typeof model.replaceSnapshot === "function") {
634
+ model.replaceSnapshot(snapshot);
635
+ if (typeof rerender === "function") rerender();
636
+ }
637
+ };
638
+
639
+ const handleError = () => {
640
+ if (disposed) return;
641
+ // Surface the disconnect to the user so they don't silently miss live
642
+ // updates. EventSource will keep auto-reconnecting; once a snapshot/
643
+ // snapshotDelta event lands again, the regular notice clearing flow
644
+ // (e.g. on the next mutation) replaces this notice.
645
+ if (model.store && typeof model.store === "object") {
646
+ const existing = model.store.notice;
647
+ if (!existing || existing.code !== "live_updates_disconnected") {
648
+ model.store.notice = {
649
+ type: "warning",
650
+ code: "live_updates_disconnected",
651
+ title: "Live updates disconnected",
652
+ message: "Reconnecting to the server. Changes from other sessions may be delayed.",
653
+ };
654
+ if (typeof rerender === "function") rerender();
655
+ }
656
+ }
657
+ };
658
+
659
+ eventSource.addEventListener("snapshotDelta", handleSnapshotDelta);
660
+ eventSource.addEventListener("snapshot", handleSnapshot);
661
+ // EventSource calls .onerror on disconnect (and will continue auto-reconnecting).
662
+ // Use the onerror property rather than addEventListener("error") so tests can
663
+ // trigger it via `instance.onerror?.()` without a full event object.
664
+ eventSource.onerror = handleError;
665
+
666
+ return {
667
+ eventSource,
668
+ dispose() {
669
+ if (disposed) return;
670
+ disposed = true;
671
+ try {
672
+ eventSource.close();
673
+ } catch {
674
+ // best-effort
675
+ }
676
+ },
677
+ };
678
+ }
@@ -116,9 +116,19 @@ export function orderEpicsNewestFirst(epics) {
116
116
 
117
117
  /** Recently-done epics stay visible for 24h even when the "done" filter is off. */
118
118
  const DONE_GRACE_PERIOD_MS = 86400000;
119
+ /**
120
+ * Bucket Date.now() into hour-aligned chunks so the memoized epic selector
121
+ * re-evaluates the 24h grace cutoff at most once per hour on a long-open page.
122
+ */
123
+ const GRACE_BUCKET_MS = 3600000;
119
124
 
120
125
  const selectVisibleEpics = createSelector(
121
- (s) => [s.snapshot?.epics, s.searchQuery, s.epicStatusFilter],
126
+ (s) => [
127
+ s.snapshot?.epics,
128
+ s.searchQuery,
129
+ s.epicStatusFilter,
130
+ Math.floor(Date.now() / GRACE_BUCKET_MS),
131
+ ],
122
132
  (epics, searchQuery, epicStatusFilter) => {
123
133
  if (!epics) return [];
124
134
  const now = Date.now();
@@ -224,6 +234,8 @@ function selectSearchScope(state) {
224
234
  * @property {object|null} selectedTask
225
235
  * @property {string|null} selectedSubtaskId
226
236
  * @property {object|null} selectedSubtask
237
+ * @property {boolean} taskModalOpen
238
+ * @property {boolean} subtaskModalOpen
227
239
  * @property {string} search
228
240
  * @property {string} searchQuery
229
241
  * @property {object} searchScope
@@ -231,6 +243,38 @@ function selectSearchScope(state) {
231
243
  * @property {object[]} visibleTasks
232
244
  */
233
245
 
246
+ /**
247
+ * Create a memoizer for deriveBoardState that returns the same reference until
248
+ * invalidate() is called or the hour bucket changes (so time-sensitive
249
+ * selectors like selectVisibleEpics can re-evaluate the 24h grace cutoff).
250
+ * Designed to be invalidated by the store's notify().
251
+ * @param {(state: object) => BoardState} compute
252
+ */
253
+ function createBoardStateMemo(compute) {
254
+ let cachedState = null;
255
+ let cachedBucket = null;
256
+ let cachedResult = null;
257
+ let dirty = true;
258
+
259
+ function get(state) {
260
+ const bucket = Math.floor(Date.now() / GRACE_BUCKET_MS);
261
+ if (!dirty && cachedState === state && cachedBucket === bucket) {
262
+ return cachedResult;
263
+ }
264
+ cachedState = state;
265
+ cachedBucket = bucket;
266
+ cachedResult = compute(state);
267
+ dirty = false;
268
+ return cachedResult;
269
+ }
270
+
271
+ function invalidate() {
272
+ dirty = true;
273
+ }
274
+
275
+ return { get, invalidate };
276
+ }
277
+
234
278
  /**
235
279
  * Compute full derived board state from the internal state, using memoized selectors.
236
280
  * @param {object} state
@@ -264,6 +308,9 @@ function deriveBoardState(state) {
264
308
  : stateWithScreen;
265
309
  const selectedSubtask = selectedTaskId ? selectSelectedSubtask(stateWithTaskSelection) : null;
266
310
 
311
+ const taskModalOpen = state.taskModalOpen === true;
312
+ const subtaskModalOpen = state.subtaskModalOpen === true && selectedSubtask !== null;
313
+
267
314
  return {
268
315
  screen,
269
316
  selectedEpicId: selectedEpic?.id ?? null,
@@ -272,6 +319,8 @@ function deriveBoardState(state) {
272
319
  selectedTask: selectedTaskId ? selectedTask : null,
273
320
  selectedSubtaskId: selectedSubtask?.id ?? null,
274
321
  selectedSubtask,
322
+ taskModalOpen,
323
+ subtaskModalOpen,
275
324
  search: stateWithScreen.search,
276
325
  searchQuery: stateWithScreen.searchQuery,
277
326
  searchScope: selectSearchScope(stateWithScreen),
@@ -289,6 +338,8 @@ function reconcileBoardState(state) {
289
338
  view: VIEW_MODES.includes(state.view) ? state.view : "kanban",
290
339
  selectedTaskId: derived.selectedTaskId,
291
340
  selectedSubtaskId: derived.selectedSubtaskId,
341
+ taskModalOpen: derived.taskModalOpen,
342
+ subtaskModalOpen: derived.subtaskModalOpen,
292
343
  };
293
344
  }
294
345
 
@@ -331,11 +382,14 @@ export function createStore(initialSnapshot, options = {}) {
331
382
  view: VIEW_MODES.includes(storedState.view) ? storedState.view : "kanban",
332
383
  selectedTaskId: typeof storedState.selectedTaskId === "string" ? storedState.selectedTaskId : null,
333
384
  selectedSubtaskId: null,
385
+ taskModalOpen: false,
386
+ subtaskModalOpen: false,
334
387
  theme: readThemePreference(),
335
388
  focusedEpicIndex: 0,
336
389
  copyFeedback: null,
337
390
  notice: null,
338
391
  isMutating: false,
392
+ dragFeedback: null,
339
393
  notesPanelOpen: storedState.notesPanelOpen === true,
340
394
  epicStatusFilter: readStatusFilter(storedState.epicStatusFilter),
341
395
  taskStatusFilter: readStatusFilter(storedState.taskStatusFilter),
@@ -344,7 +398,10 @@ export function createStore(initialSnapshot, options = {}) {
344
398
  /** @type {Set<(state: object) => void>} */
345
399
  const listeners = new Set();
346
400
 
401
+ const boardStateMemo = createBoardStateMemo(deriveBoardState);
402
+
347
403
  function notify() {
404
+ boardStateMemo.invalidate();
348
405
  for (const listener of listeners) {
349
406
  listener(state);
350
407
  }
@@ -355,12 +412,12 @@ export function createStore(initialSnapshot, options = {}) {
355
412
  screen: state.screen,
356
413
  selectedEpicId: state.selectedEpicId,
357
414
  search: state.search,
358
- view: state.view,
359
- selectedTaskId: state.selectedTaskId,
360
- notesPanelOpen: state.notesPanelOpen,
361
- epicStatusFilter: state.epicStatusFilter,
362
- taskStatusFilter: state.taskStatusFilter,
363
- });
415
+ view: state.view,
416
+ selectedTaskId: state.selectedTaskId,
417
+ notesPanelOpen: state.notesPanelOpen,
418
+ epicStatusFilter: state.epicStatusFilter,
419
+ taskStatusFilter: state.taskStatusFilter,
420
+ });
364
421
  }
365
422
 
366
423
  function syncState(patch = {}) {
@@ -375,7 +432,9 @@ export function createStore(initialSnapshot, options = {}) {
375
432
  || state.search !== reconciled.search
376
433
  || state.view !== reconciled.view
377
434
  || state.selectedTaskId !== reconciled.selectedTaskId
378
- || state.selectedSubtaskId !== reconciled.selectedSubtaskId;
435
+ || state.selectedSubtaskId !== reconciled.selectedSubtaskId
436
+ || state.taskModalOpen !== reconciled.taskModalOpen
437
+ || state.subtaskModalOpen !== reconciled.subtaskModalOpen;
379
438
 
380
439
  state.screen = reconciled.screen;
381
440
  state.selectedEpicId = reconciled.selectedEpicId;
@@ -384,10 +443,12 @@ export function createStore(initialSnapshot, options = {}) {
384
443
  state.view = reconciled.view;
385
444
  state.selectedTaskId = reconciled.selectedTaskId;
386
445
  state.selectedSubtaskId = reconciled.selectedSubtaskId;
446
+ state.taskModalOpen = reconciled.taskModalOpen;
447
+ state.subtaskModalOpen = reconciled.subtaskModalOpen;
387
448
  if (changed) {
388
449
  notify();
389
450
  }
390
- return deriveBoardState(state);
451
+ return boardStateMemo.get(state);
391
452
  }
392
453
 
393
454
  // Reconcile initial state
@@ -450,7 +511,7 @@ export function createStore(initialSnapshot, options = {}) {
450
511
  * @returns {BoardState}
451
512
  */
452
513
  getBoardState() {
453
- return deriveBoardState(state);
514
+ return boardStateMemo.get(state);
454
515
  },
455
516
 
456
517
  /**
@@ -528,7 +589,22 @@ export function createStore(initialSnapshot, options = {}) {
528
589
  * @returns {object|null}
529
590
  */
530
591
  getSelectedTask() {
531
- return deriveBoardState(state).selectedTask;
592
+ return boardStateMemo.get(state).selectedTask;
532
593
  },
594
+
595
+ /**
596
+ * Invalidate the memoized board state. Call after a direct mutation of
597
+ * `model.store` that didn't go through `setState`/`syncState`/`notify`,
598
+ * before triggering a rerender, so `getBoardState()` recomputes.
599
+ */
600
+ invalidateBoardStateMemo() {
601
+ boardStateMemo.invalidate();
602
+ },
603
+
604
+ /**
605
+ * Notify subscribers (and invalidate the memo). Use after a direct
606
+ * mutation of `model.store` that needs to be reflected in derived state.
607
+ */
608
+ notify,
533
609
  };
534
610
  }
@@ -47,6 +47,9 @@ export function stateToHash(state) {
47
47
  if (state.selectedTaskId) {
48
48
  params.set("task", state.selectedTaskId);
49
49
  }
50
+ if (state.selectedSubtaskId) {
51
+ params.set("subtask", state.selectedSubtaskId);
52
+ }
50
53
  if (state.screen === "epics" && state.selectedEpicId) {
51
54
  params.set("screen", "epics");
52
55
  }
@@ -73,6 +76,7 @@ export function hashToState(hash) {
73
76
 
74
77
  const epicId = params.get("epic") || null;
75
78
  const taskId = params.get("task") || null;
79
+ const subtaskId = params.get("subtask") || null;
76
80
  const screenParam = params.get("screen");
77
81
  const search = params.get("search") || "";
78
82
  const view = params.get("view") || DEFAULT_VIEW;
@@ -82,9 +86,15 @@ export function hashToState(hash) {
82
86
  ? "tasks"
83
87
  : "epics";
84
88
 
89
+ // Deep-linking with task=/subtask= must open the corresponding modals so the
90
+ // booted page actually renders the deep-linked dialog instead of just
91
+ // selecting a row in the workspace.
85
92
  return {
86
93
  selectedEpicId: epicId,
87
94
  selectedTaskId: taskId,
95
+ selectedSubtaskId: subtaskId,
96
+ taskModalOpen: Boolean(taskId),
97
+ subtaskModalOpen: Boolean(subtaskId),
88
98
  search,
89
99
  view,
90
100
  screen,
@@ -311,7 +311,8 @@ export function escapeHtml(value) {
311
311
  .replaceAll("&", "&amp;")
312
312
  .replaceAll("<", "&lt;")
313
313
  .replaceAll(">", "&gt;")
314
- .replaceAll('"', "&quot;");
314
+ .replaceAll('"', "&quot;")
315
+ .replaceAll("'", "&#39;");
315
316
  }
316
317
 
317
318
  /**
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Board event bus.
3
+ *
4
+ * Per-server-instance pub/sub used to broadcast snapshot deltas to SSE
5
+ * subscribers (browser tabs). Publishers (board route handlers, future WAL
6
+ * watcher) call publish; subscribers receive the JSON-serializable payload.
7
+ */
8
+
9
+ export interface BoardDeltaEvent {
10
+ readonly type: "snapshotDelta";
11
+ readonly id: number;
12
+ readonly snapshotDelta: Record<string, unknown>;
13
+ }
14
+
15
+ export type BoardEvent = BoardDeltaEvent;
16
+
17
+ export type BoardEventListener = (event: BoardEvent) => void;
18
+
19
+ export interface BoardEventBus {
20
+ publishSnapshotDelta(snapshotDelta: Record<string, unknown>): BoardDeltaEvent;
21
+ subscribe(listener: BoardEventListener): () => void;
22
+ readonly subscriberCount: number;
23
+ close(): void;
24
+ }
25
+
26
+ export function createBoardEventBus(): BoardEventBus {
27
+ const listeners = new Set<BoardEventListener>();
28
+ let nextId = 1;
29
+ let closed = false;
30
+
31
+ return {
32
+ publishSnapshotDelta(snapshotDelta: Record<string, unknown>): BoardDeltaEvent {
33
+ const event: BoardDeltaEvent = {
34
+ type: "snapshotDelta",
35
+ id: nextId++,
36
+ snapshotDelta,
37
+ };
38
+
39
+ if (closed) {
40
+ return event;
41
+ }
42
+
43
+ // Snapshot to allow listeners to unsubscribe during dispatch.
44
+ for (const listener of [...listeners]) {
45
+ try {
46
+ listener(event);
47
+ } catch {
48
+ // Listener errors must not block other subscribers.
49
+ }
50
+ }
51
+
52
+ return event;
53
+ },
54
+ subscribe(listener: BoardEventListener): () => void {
55
+ if (closed) {
56
+ return () => {};
57
+ }
58
+
59
+ listeners.add(listener);
60
+ return () => {
61
+ listeners.delete(listener);
62
+ };
63
+ },
64
+ get subscriberCount(): number {
65
+ return listeners.size;
66
+ },
67
+ close(): void {
68
+ closed = true;
69
+ listeners.clear();
70
+ },
71
+ };
72
+ }