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
@@ -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) {
@@ -95,10 +222,9 @@ function createTimeoutError(method, path, timeoutMs) {
95
222
  * }}
96
223
  */
97
224
  export function createMutationQueue(model, rerender) {
98
- /** @type {Array<{ optimistic?: function, request: function, onSuccess?: function, onError?: function, successMessage?: string }>} */
225
+ /** @type {Array<{ mutationId: string, optimistic?: function, request: function, onSuccess?: function, onError?: function, successMessage?: string }>} */
99
226
  const queue = [];
100
227
  let processing = false;
101
- let nextMutationId = 1;
102
228
  /** @type {Array<() => void>} */
103
229
  let flushResolvers = [];
104
230
 
@@ -119,14 +245,27 @@ export function createMutationQueue(model, rerender) {
119
245
 
120
246
  while (queue.length > 0) {
121
247
  const mutation = queue.shift();
122
- const previousSnapshot = cloneSnapshot(model.store.snapshot);
123
- if (model.store.notice?.retryMutationId !== mutation.id) {
248
+ if (model.store.notice?.retryMutationId !== mutation.mutationId) {
124
249
  model.store.notice = null;
125
250
  }
126
251
 
252
+ // Capture per-mutation inverse delta if the optimistic patch ran.
253
+ // Using an inverse delta (rather than wholesale replaceSnapshot) means
254
+ // concurrent server-pushed deltas applied to unrelated entities while
255
+ // the request was in flight survive a rollback.
256
+ let inverseDelta = null;
257
+
127
258
  try {
128
259
  if (typeof mutation.optimistic === "function") {
129
- model.store.snapshot = mutation.optimistic(cloneSnapshot(model.store.snapshot));
260
+ const previousSnapshot = model.store.snapshot;
261
+ const optimisticSnapshot = mutation.optimistic(cloneSnapshot(previousSnapshot));
262
+ inverseDelta = computeInverseDelta(previousSnapshot, optimisticSnapshot);
263
+ model.store.snapshot = optimisticSnapshot;
264
+ // Direct snapshot mutation bypasses setState/syncState; invalidate
265
+ // the memo so the next getBoardState() reflects the optimistic write.
266
+ if (typeof model.invalidateBoardStateMemo === "function") {
267
+ model.invalidateBoardStateMemo();
268
+ }
130
269
  rerender();
131
270
  }
132
271
 
@@ -146,8 +285,11 @@ export function createMutationQueue(model, rerender) {
146
285
  ? { type: "success", message: mutation.successMessage }
147
286
  : null;
148
287
  } catch (error) {
149
- // Revert to pre-optimistic snapshot
150
- model.replaceSnapshot(previousSnapshot);
288
+ // Revert only the entities this mutation touched. Any unrelated
289
+ // entities updated by concurrent server deltas remain intact.
290
+ if (inverseDelta) {
291
+ model.applySnapshotDelta(inverseDelta);
292
+ }
151
293
 
152
294
  const message = error instanceof Error ? error.message : String(error);
153
295
  model.store.notice = {
@@ -155,7 +297,7 @@ export function createMutationQueue(model, rerender) {
155
297
  title: "Action failed",
156
298
  message,
157
299
  retryLabel: "Retry",
158
- retryMutationId: mutation.id,
300
+ retryMutationId: mutation.mutationId,
159
301
  };
160
302
 
161
303
  if (typeof mutation.onError === "function") {
@@ -166,14 +308,19 @@ export function createMutationQueue(model, rerender) {
166
308
 
167
309
  processing = false;
168
310
  model.store.isMutating = false;
311
+ if (typeof model.invalidateBoardStateMemo === "function") {
312
+ model.invalidateBoardStateMemo();
313
+ }
169
314
  rerender();
170
315
  resolveFlushes();
171
316
  }
172
317
 
173
318
  return {
174
319
  enqueue(mutation) {
175
- queue.push({ ...mutation, id: nextMutationId });
176
- nextMutationId += 1;
320
+ queue.push({
321
+ ...mutation,
322
+ mutationId: mutation.mutationId ?? crypto.randomUUID(),
323
+ });
177
324
  processNext();
178
325
  },
179
326
 
@@ -204,20 +351,26 @@ export function createApi(model, options) {
204
351
  let lastFailedMutation = null;
205
352
 
206
353
  function enqueueMutation(definition) {
354
+ // Assign a stable identity token so success callbacks can clear
355
+ // lastFailedMutation by id rather than by function-reference equality
356
+ // (inline arrow functions are never the same reference across retries).
357
+ const mutationId = crypto.randomUUID();
358
+ const tagged = { ...definition, mutationId };
359
+
207
360
  queue.enqueue({
208
- ...definition,
361
+ ...tagged,
209
362
  onSuccess(data) {
210
- if (lastFailedMutation?.request === definition.request) {
363
+ if (lastFailedMutation?.mutationId === mutationId) {
211
364
  lastFailedMutation = null;
212
365
  }
213
- if (typeof definition.onSuccess === "function") {
214
- definition.onSuccess(data);
366
+ if (typeof tagged.onSuccess === "function") {
367
+ tagged.onSuccess(data);
215
368
  }
216
369
  },
217
370
  onError(error) {
218
- lastFailedMutation = definition;
219
- if (typeof definition.onError === "function") {
220
- definition.onError(error);
371
+ lastFailedMutation = tagged;
372
+ if (typeof tagged.onError === "function") {
373
+ tagged.onError(error);
221
374
  }
222
375
  },
223
376
  });
@@ -403,3 +556,137 @@ export function createApi(model, options) {
403
556
  },
404
557
  };
405
558
  }
559
+
560
+ /**
561
+ * Subscribe the board client to /api/snapshot/stream.
562
+ *
563
+ * Receives `snapshotDelta` events emitted by the per-server-instance event bus
564
+ * (own server mutations + WAL-watcher-derived deltas from external CLI writes)
565
+ * and applies them via `model.applySnapshotDelta`. Idempotent merge means
566
+ * re-applying a delta we already saw via the mutation response is harmless.
567
+ *
568
+ * Returns a `dispose()` function that closes the EventSource and stops
569
+ * processing further events.
570
+ *
571
+ * @param {object} model - Store with `applySnapshotDelta` method
572
+ * @param {object} options
573
+ * @param {string} options.sessionToken - Auth token for API parity; EventSource uses the same-origin HttpOnly cookie.
574
+ * @param {function} options.rerender - Trigger UI rerender after applying deltas
575
+ * @param {typeof EventSource} [options.EventSourceCtor] - Constructor override for tests
576
+ * @param {string} [options.path] - Override stream path; default /api/snapshot/stream
577
+ * @returns {{ dispose: () => void, eventSource: EventSource | null }}
578
+ */
579
+ export function subscribeSnapshotStream(model, options) {
580
+ const {
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
+ let disposed = false;
591
+ let consecutiveErrors = 0;
592
+ const eventSource = new EventSourceCtor(path);
593
+
594
+ const clearLiveUpdateNotice = () => {
595
+ if (model.store?.notice?.code === "live_updates_disconnected") {
596
+ model.store.notice = null;
597
+ }
598
+ };
599
+
600
+ const markLiveUpdateSuccess = () => {
601
+ consecutiveErrors = 0;
602
+ clearLiveUpdateNotice();
603
+ };
604
+
605
+ const handleSnapshotDelta = (event) => {
606
+ if (disposed) return;
607
+ const raw = typeof event?.data === "string" ? event.data : "";
608
+ if (raw.length === 0) return;
609
+ let payload;
610
+ try {
611
+ payload = JSON.parse(raw);
612
+ } catch (error) {
613
+ const message = error instanceof Error ? error.message : String(error);
614
+ // Surface malformed payloads so operators can spot serialization bugs.
615
+ // Avoid logging the raw text (may include sensitive content) — just length + error.
616
+ console.warn(`subscribeSnapshotStream: malformed snapshotDelta JSON (${raw.length} bytes): ${message}`);
617
+ return;
618
+ }
619
+ const delta = payload?.snapshotDelta;
620
+ if (!delta || typeof delta !== "object") return;
621
+ model.applySnapshotDelta(delta);
622
+ markLiveUpdateSuccess();
623
+ if (typeof rerender === "function") rerender();
624
+ };
625
+
626
+ const handleSnapshot = (event) => {
627
+ if (disposed) return;
628
+ const raw = typeof event?.data === "string" ? event.data : "";
629
+ if (raw.length === 0) return;
630
+ let payload;
631
+ try {
632
+ payload = JSON.parse(raw);
633
+ } catch (error) {
634
+ const message = error instanceof Error ? error.message : String(error);
635
+ console.warn(`subscribeSnapshotStream: malformed snapshot JSON (${raw.length} bytes): ${message}`);
636
+ return;
637
+ }
638
+ const snapshot = payload?.snapshot;
639
+ if (!snapshot || typeof snapshot !== "object") return;
640
+ if (typeof model.replaceSnapshot === "function") {
641
+ model.replaceSnapshot(snapshot);
642
+ markLiveUpdateSuccess();
643
+ if (typeof rerender === "function") rerender();
644
+ }
645
+ };
646
+
647
+ function dispose() {
648
+ if (disposed) return;
649
+ disposed = true;
650
+ try {
651
+ eventSource.close();
652
+ } catch {
653
+ // best-effort
654
+ }
655
+ }
656
+
657
+ const handleError = () => {
658
+ if (disposed) return;
659
+ consecutiveErrors += 1;
660
+ if (model.store && typeof model.store === "object") {
661
+ const existing = model.store.notice;
662
+ const disabled = consecutiveErrors >= 5;
663
+ const nextCode = disabled ? "live_updates_disabled" : "live_updates_disconnected";
664
+ if (!existing || existing.code !== nextCode) {
665
+ model.store.notice = {
666
+ type: "warning",
667
+ code: nextCode,
668
+ title: disabled ? "Live updates disabled" : "Live updates disconnected",
669
+ message: disabled
670
+ ? "Refresh the board to resume live updates from other sessions."
671
+ : "Reconnecting to the server. Changes from other sessions may be delayed.",
672
+ };
673
+ if (typeof rerender === "function") rerender();
674
+ }
675
+ if (disabled) {
676
+ dispose();
677
+ }
678
+ }
679
+ };
680
+
681
+ eventSource.addEventListener("snapshotDelta", handleSnapshotDelta);
682
+ eventSource.addEventListener("snapshot", handleSnapshot);
683
+ // EventSource calls .onerror on disconnect (and will continue auto-reconnecting).
684
+ // Use the onerror property rather than addEventListener("error") so tests can
685
+ // trigger it via `instance.onerror?.()` without a full event object.
686
+ eventSource.onerror = handleError;
687
+
688
+ return {
689
+ eventSource,
690
+ dispose,
691
+ };
692
+ }
@@ -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();
@@ -225,6 +235,7 @@ function selectSearchScope(state) {
225
235
  * @property {string|null} selectedSubtaskId
226
236
  * @property {object|null} selectedSubtask
227
237
  * @property {boolean} taskModalOpen
238
+ * @property {boolean} subtaskModalOpen
228
239
  * @property {string} search
229
240
  * @property {string} searchQuery
230
241
  * @property {object} searchScope
@@ -232,6 +243,38 @@ function selectSearchScope(state) {
232
243
  * @property {object[]} visibleTasks
233
244
  */
234
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
+
235
278
  /**
236
279
  * Compute full derived board state from the internal state, using memoized selectors.
237
280
  * @param {object} state
@@ -266,6 +309,7 @@ function deriveBoardState(state) {
266
309
  const selectedSubtask = selectedTaskId ? selectSelectedSubtask(stateWithTaskSelection) : null;
267
310
 
268
311
  const taskModalOpen = state.taskModalOpen === true;
312
+ const subtaskModalOpen = state.subtaskModalOpen === true && selectedSubtask !== null;
269
313
 
270
314
  return {
271
315
  screen,
@@ -276,6 +320,7 @@ function deriveBoardState(state) {
276
320
  selectedSubtaskId: selectedSubtask?.id ?? null,
277
321
  selectedSubtask,
278
322
  taskModalOpen,
323
+ subtaskModalOpen,
279
324
  search: stateWithScreen.search,
280
325
  searchQuery: stateWithScreen.searchQuery,
281
326
  searchScope: selectSearchScope(stateWithScreen),
@@ -294,6 +339,7 @@ function reconcileBoardState(state) {
294
339
  selectedTaskId: derived.selectedTaskId,
295
340
  selectedSubtaskId: derived.selectedSubtaskId,
296
341
  taskModalOpen: derived.taskModalOpen,
342
+ subtaskModalOpen: derived.subtaskModalOpen,
297
343
  };
298
344
  }
299
345
 
@@ -337,11 +383,13 @@ export function createStore(initialSnapshot, options = {}) {
337
383
  selectedTaskId: typeof storedState.selectedTaskId === "string" ? storedState.selectedTaskId : null,
338
384
  selectedSubtaskId: null,
339
385
  taskModalOpen: false,
386
+ subtaskModalOpen: false,
340
387
  theme: readThemePreference(),
341
388
  focusedEpicIndex: 0,
342
389
  copyFeedback: null,
343
390
  notice: null,
344
391
  isMutating: false,
392
+ dragFeedback: null,
345
393
  notesPanelOpen: storedState.notesPanelOpen === true,
346
394
  epicStatusFilter: readStatusFilter(storedState.epicStatusFilter),
347
395
  taskStatusFilter: readStatusFilter(storedState.taskStatusFilter),
@@ -350,7 +398,13 @@ export function createStore(initialSnapshot, options = {}) {
350
398
  /** @type {Set<(state: object) => void>} */
351
399
  const listeners = new Set();
352
400
 
401
+ const boardStateMemo = createBoardStateMemo(deriveBoardState);
402
+
403
+ // Direct `model.store` mutations must manually invalidate this memo before
404
+ // rerendering. Current direct-mutation fields: snapshot, notice, isMutating,
405
+ // dragFeedback.
353
406
  function notify() {
407
+ boardStateMemo.invalidate();
354
408
  for (const listener of listeners) {
355
409
  listener(state);
356
410
  }
@@ -361,12 +415,12 @@ export function createStore(initialSnapshot, options = {}) {
361
415
  screen: state.screen,
362
416
  selectedEpicId: state.selectedEpicId,
363
417
  search: state.search,
364
- view: state.view,
365
- selectedTaskId: state.selectedTaskId,
366
- notesPanelOpen: state.notesPanelOpen,
367
- epicStatusFilter: state.epicStatusFilter,
368
- taskStatusFilter: state.taskStatusFilter,
369
- });
418
+ view: state.view,
419
+ selectedTaskId: state.selectedTaskId,
420
+ notesPanelOpen: state.notesPanelOpen,
421
+ epicStatusFilter: state.epicStatusFilter,
422
+ taskStatusFilter: state.taskStatusFilter,
423
+ });
370
424
  }
371
425
 
372
426
  function syncState(patch = {}) {
@@ -382,7 +436,8 @@ export function createStore(initialSnapshot, options = {}) {
382
436
  || state.view !== reconciled.view
383
437
  || state.selectedTaskId !== reconciled.selectedTaskId
384
438
  || state.selectedSubtaskId !== reconciled.selectedSubtaskId
385
- || state.taskModalOpen !== reconciled.taskModalOpen;
439
+ || state.taskModalOpen !== reconciled.taskModalOpen
440
+ || state.subtaskModalOpen !== reconciled.subtaskModalOpen;
386
441
 
387
442
  state.screen = reconciled.screen;
388
443
  state.selectedEpicId = reconciled.selectedEpicId;
@@ -392,10 +447,11 @@ export function createStore(initialSnapshot, options = {}) {
392
447
  state.selectedTaskId = reconciled.selectedTaskId;
393
448
  state.selectedSubtaskId = reconciled.selectedSubtaskId;
394
449
  state.taskModalOpen = reconciled.taskModalOpen;
450
+ state.subtaskModalOpen = reconciled.subtaskModalOpen;
395
451
  if (changed) {
396
452
  notify();
397
453
  }
398
- return deriveBoardState(state);
454
+ return boardStateMemo.get(state);
399
455
  }
400
456
 
401
457
  // Reconcile initial state
@@ -458,7 +514,7 @@ export function createStore(initialSnapshot, options = {}) {
458
514
  * @returns {BoardState}
459
515
  */
460
516
  getBoardState() {
461
- return deriveBoardState(state);
517
+ return boardStateMemo.get(state);
462
518
  },
463
519
 
464
520
  /**
@@ -536,7 +592,22 @@ export function createStore(initialSnapshot, options = {}) {
536
592
  * @returns {object|null}
537
593
  */
538
594
  getSelectedTask() {
539
- return deriveBoardState(state).selectedTask;
595
+ return boardStateMemo.get(state).selectedTask;
540
596
  },
597
+
598
+ /**
599
+ * Invalidate the memoized board state. Call after a direct mutation of
600
+ * `model.store` that didn't go through `setState`/`syncState`/`notify`,
601
+ * before triggering a rerender, so `getBoardState()` recomputes.
602
+ */
603
+ invalidateBoardStateMemo() {
604
+ boardStateMemo.invalidate();
605
+ },
606
+
607
+ /**
608
+ * Notify subscribers (and invalidate the memo). Use after a direct
609
+ * mutation of `model.store` that needs to be reflected in derived state.
610
+ */
611
+ notify,
541
612
  };
542
613
  }
@@ -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
  /**