beads-ui 0.2.0 → 0.3.0

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 (48) hide show
  1. package/CHANGES.md +5 -0
  2. package/README.md +4 -4
  3. package/app/data/list-selectors.js +98 -0
  4. package/app/data/providers.js +5 -138
  5. package/app/data/sort.js +45 -0
  6. package/app/data/subscription-issue-store.js +161 -0
  7. package/app/data/subscription-issue-stores.js +102 -0
  8. package/app/data/subscriptions-store.js +219 -0
  9. package/app/main.js +342 -66
  10. package/app/protocol.js +10 -14
  11. package/app/protocol.md +18 -15
  12. package/app/styles.css +222 -197
  13. package/app/utils/markdown.js +15 -194
  14. package/app/utils/priority-badge.js +0 -2
  15. package/app/utils/status-badge.js +0 -1
  16. package/app/utils/toast.js +0 -1
  17. package/app/utils/type-badge.js +0 -3
  18. package/app/views/board.js +166 -144
  19. package/app/views/detail.js +76 -66
  20. package/app/views/epics.js +126 -74
  21. package/app/views/issue-dialog.js +8 -15
  22. package/app/views/issue-row.js +1 -3
  23. package/app/views/list.js +101 -104
  24. package/app/views/new-issue-dialog.js +27 -34
  25. package/app/ws.js +6 -9
  26. package/bin/bdui.js +1 -1
  27. package/docs/adr/001-push-only-lists.md +134 -0
  28. package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
  29. package/docs/architecture.md +34 -84
  30. package/docs/data-exchange-subscription-plan.md +198 -0
  31. package/docs/db-watching.md +2 -1
  32. package/docs/migration-v2.md +54 -0
  33. package/docs/protocol/issues-push-v2.md +179 -0
  34. package/docs/subscription-issue-store.md +112 -0
  35. package/package.json +4 -2
  36. package/server/bd.js +0 -2
  37. package/server/cli/commands.js +1 -2
  38. package/server/cli/daemon.js +12 -5
  39. package/server/cli/index.js +0 -2
  40. package/server/cli/usage.js +1 -1
  41. package/server/config.js +12 -6
  42. package/server/db.js +0 -1
  43. package/server/index.js +9 -5
  44. package/server/list-adapters.js +218 -0
  45. package/server/subscriptions.js +277 -0
  46. package/server/validators.js +111 -0
  47. package/server/watcher.js +5 -8
  48. package/server/ws.js +449 -230
package/app/views/list.js CHANGED
@@ -1,5 +1,6 @@
1
- /* global NodeListOf */
2
1
  import { html, render } from 'lit-html';
2
+ import { createListSelectors } from '../data/list-selectors.js';
3
+ import { cmpClosedDesc } from '../data/sort.js';
3
4
  import { ISSUE_TYPES, typeLabel } from '../utils/issue-type.js';
4
5
  import { issueHashFor } from '../utils/issue-url.js';
5
6
  // issueDisplayId not used directly in this file; rendered in shared row
@@ -9,7 +10,7 @@ import { createIssueRowRenderer } from './issue-row.js';
9
10
  // List view implementation; requires a transport send function.
10
11
 
11
12
  /**
12
- * @typedef {{ id: string, title?: string, status?: string, priority?: number, issue_type?: string, assignee?: string, labels?: string[] }} Issue
13
+ * @typedef {{ id: string, title?: string, status?: 'closed'|'open'|'in_progress', priority?: number, issue_type?: string, assignee?: string, labels?: string[] }} Issue
13
14
  */
14
15
 
15
16
  /**
@@ -18,9 +19,30 @@ import { createIssueRowRenderer } from './issue-row.js';
18
19
  * @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn - RPC transport.
19
20
  * @param {(hash: string) => void} [navigate_fn] - Navigation function (defaults to setting location.hash).
20
21
  * @param {{ getState: () => any, setState: (patch: any) => void, subscribe: (fn: (s:any)=>void)=>()=>void }} [store] - Optional state store.
22
+ * @param {{ selectors: { getIds: (client_id: string) => string[] } }} [_subscriptions]
23
+ * @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issueStores]
21
24
  * @returns {{ load: () => Promise<void>, destroy: () => void }} View API.
22
25
  */
23
- export function createListView(mount_element, sendFn, navigate_fn, store) {
26
+ /**
27
+ * Create the Issues List view.
28
+ * @param {HTMLElement} mount_element
29
+ * @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn
30
+ * @param {(hash: string) => void} [navigateFn]
31
+ * @param {{ getState: () => any, setState: (patch: any) => void, subscribe: (fn: (s:any)=>void)=>()=>void }} [store]
32
+ * @param {{ selectors: { getIds: (client_id: string) => string[] } }} [_subscriptions]
33
+ * @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issue_stores]
34
+ * @returns {{ load: () => Promise<void>, destroy: () => void }}
35
+ */
36
+ export function createListView(
37
+ mount_element,
38
+ sendFn,
39
+ navigateFn,
40
+ store,
41
+ _subscriptions = undefined,
42
+ issue_stores = undefined
43
+ ) {
44
+ // Touch unused param to satisfy lint rules without impacting behavior
45
+ /** @type {any} */ (void _subscriptions);
24
46
  /** @type {string} */
25
47
  let status_filter = 'all';
26
48
  /** @type {string} */
@@ -36,7 +58,7 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
36
58
  // Shared row renderer (used in template below)
37
59
  const row_renderer = createIssueRowRenderer({
38
60
  navigate: (id) => {
39
- const nav = navigate_fn || ((h) => (window.location.hash = h));
61
+ const nav = navigateFn || ((h) => (window.location.hash = h));
40
62
  /** @type {'issues'|'epics'|'board'} */
41
63
  const view = store ? store.getState().view : 'issues';
42
64
  nav(issueHashFor(view, id));
@@ -54,12 +76,11 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
54
76
  * @param {Event} ev
55
77
  */
56
78
  const onStatusChange = async (ev) => {
57
- /** @type {HTMLSelectElement} */
58
- const sel = /** @type {any} */ (ev.currentTarget);
79
+ const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
59
80
  status_filter = sel.value;
60
81
  if (store) {
61
82
  store.setState({
62
- filters: { status: /** @type {any} */ (status_filter) }
83
+ filters: { status: status_filter }
63
84
  });
64
85
  }
65
86
  // Always reload on status changes
@@ -73,8 +94,7 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
73
94
  * @param {Event} ev
74
95
  */
75
96
  const onSearchInput = (ev) => {
76
- /** @type {HTMLInputElement} */
77
- const input = /** @type {any} */ (ev.currentTarget);
97
+ const input = /** @type {HTMLInputElement} */ (ev.currentTarget);
78
98
  search_text = input.value;
79
99
  if (store) {
80
100
  store.setState({ filters: { search: search_text } });
@@ -87,8 +107,7 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
87
107
  * @param {Event} ev
88
108
  */
89
109
  const onTypeChange = (ev) => {
90
- /** @type {HTMLSelectElement} */
91
- const sel = /** @type {any} */ (ev.currentTarget);
110
+ const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
92
111
  type_filter = sel.value || '';
93
112
  if (store) {
94
113
  store.setState({ filters: { type: type_filter } });
@@ -106,12 +125,13 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
106
125
  }
107
126
  }
108
127
  // Initial values are reflected via bound `.value` in the template
128
+ // Compose helpers: centralize membership + entity selection + sorting
129
+ const selectors = issue_stores ? createListSelectors(issue_stores) : null;
109
130
 
110
131
  /**
111
132
  * Build lit-html template for the list view.
112
133
  */
113
134
  function template() {
114
- /** @type {Issue[]} */
115
135
  let filtered = issues_cache;
116
136
  if (status_filter !== 'all' && status_filter !== 'ready') {
117
137
  filtered = filtered.filter(
@@ -131,6 +151,10 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
131
151
  (it) => String(it.issue_type || '') === String(type_filter)
132
152
  );
133
153
  }
154
+ // Sorting: closed list is a special case → sort by closed_at desc only
155
+ if (status_filter === 'closed') {
156
+ filtered = filtered.slice().sort(cmpClosedDesc);
157
+ }
134
158
 
135
159
  return html`
136
160
  <div class="panel__header">
@@ -212,40 +236,55 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
212
236
  // no separate ready checkbox when using select option
213
237
 
214
238
  /**
215
- * Load issues from backend and re-render.
239
+ * Update minimal fields inline via ws mutations and refresh that row's data.
240
+ * @param {string} id
241
+ * @param {{ [k: string]: any }} patch
242
+ */
243
+ async function updateInline(id, patch) {
244
+ try {
245
+ // Dispatch specific mutations based on provided keys
246
+ if (typeof patch.title === 'string') {
247
+ await sendFn('edit-text', { id, field: 'title', value: patch.title });
248
+ }
249
+ if (typeof patch.assignee === 'string') {
250
+ await sendFn('update-assignee', { id, assignee: patch.assignee });
251
+ }
252
+ if (typeof patch.status === 'string') {
253
+ await sendFn('update-status', { id, status: patch.status });
254
+ }
255
+ if (typeof patch.priority === 'number') {
256
+ await sendFn('update-priority', { id, priority: patch.priority });
257
+ }
258
+ } catch {
259
+ // ignore failures; UI state remains as-is
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Load issues from local push stores and re-render.
216
265
  */
217
266
  async function load() {
218
267
  // Preserve scroll position to avoid jarring jumps on live refresh
219
- /** @type {HTMLElement|null} */
220
- const beforeEl = /** @type {any} */ (
268
+ const beforeEl = /** @type {HTMLElement|null} */ (
221
269
  mount_element.querySelector('#list-root')
222
270
  );
223
271
  const prevScroll = beforeEl ? beforeEl.scrollTop : 0;
224
- /** @type {any} */
225
- const filters = {};
226
- if (status_filter !== 'all' && status_filter !== 'ready') {
227
- filters.status = status_filter;
228
- }
229
- if (status_filter === 'ready') {
230
- filters.ready = true;
231
- }
232
- /** @type {unknown} */
233
- let result;
272
+ // Compose items from subscriptions membership and issues store entities
234
273
  try {
235
- result = await sendFn('list-issues', { filters });
274
+ if (selectors) {
275
+ issues_cache = /** @type {Issue[]} */ (
276
+ selectors.selectIssuesFor('tab:issues')
277
+ );
278
+ } else {
279
+ issues_cache = [];
280
+ }
236
281
  } catch {
237
- result = [];
238
- }
239
- if (!Array.isArray(result)) {
240
282
  issues_cache = [];
241
- } else {
242
- issues_cache = /** @type {Issue[]} */ (result);
243
283
  }
244
284
  doRender();
245
285
  // Restore scroll position if possible
246
286
  try {
247
- /** @type {HTMLElement|null} */
248
- const afterEl = /** @type {any} */ (
287
+ const afterEl = /** @type {HTMLElement|null} */ (
249
288
  mount_element.querySelector('#list-root')
250
289
  );
251
290
  if (afterEl && prevScroll > 0) {
@@ -262,12 +301,10 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
262
301
  // Grid cell Up/Down navigation when focus is inside the table and not within
263
302
  // an editable control (input/textarea/select). Preserves column position.
264
303
  if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
265
- /** @type {any} */
266
- const tgt = /** @type {any} */ (ev.target);
267
- /** @type {HTMLTableElement|null} */
304
+ const tgt = /** @type {HTMLElement} */ (ev.target);
268
305
  const table =
269
306
  tgt && typeof tgt.closest === 'function'
270
- ? /** @type {any} */ (tgt.closest('#list-root table.table'))
307
+ ? tgt.closest('#list-root table.table')
271
308
  : null;
272
309
  if (table) {
273
310
  // Do not intercept when inside native editable controls
@@ -279,34 +316,26 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
279
316
  tgt.closest('select'))
280
317
  );
281
318
  if (!in_editable) {
282
- /** @type {HTMLTableCellElement|null} */
283
319
  const cell =
284
- tgt && typeof tgt.closest === 'function'
285
- ? /** @type {any} */ (tgt.closest('td'))
286
- : null;
320
+ tgt && typeof tgt.closest === 'function' ? tgt.closest('td') : null;
287
321
  if (cell && cell.parentElement) {
288
- /** @type {HTMLTableRowElement} */
289
- const row = /** @type {any} */ (cell.parentElement);
290
- /** @type {HTMLTableSectionElement|null} */
291
- const tbody = /** @type {any} */ (row.parentElement);
322
+ const row = /** @type {HTMLTableRowElement} */ (cell.parentElement);
323
+ const tbody = /** @type {HTMLTableSectionElement|null} */ (
324
+ row.parentElement
325
+ );
292
326
  if (tbody && tbody.querySelectorAll) {
293
327
  const rows = Array.from(tbody.querySelectorAll('tr'));
294
328
  const row_idx = Math.max(0, rows.indexOf(row));
295
- const col_idx = /** @type {any} */ (cell).cellIndex || 0;
329
+ const col_idx = cell.cellIndex || 0;
296
330
  const next_idx =
297
331
  ev.key === 'ArrowDown'
298
332
  ? Math.min(row_idx + 1, rows.length - 1)
299
333
  : Math.max(row_idx - 1, 0);
300
- const next_row = /** @type {HTMLTableRowElement} */ (
301
- rows[next_idx]
302
- );
303
- /** @type {HTMLTableCellElement|null} */
304
- const next_cell = /** @type {any} */ (
305
- next_row && next_row.cells ? next_row.cells[col_idx] : null
306
- );
334
+ const next_row = rows[next_idx];
335
+ const next_cell =
336
+ next_row && next_row.cells ? next_row.cells[col_idx] : null;
307
337
  if (next_cell) {
308
- /** @type {HTMLElement|null} */
309
- const focusable = /** @type {any} */ (
338
+ const focusable = /** @type {HTMLElement|null} */ (
310
339
  next_cell.querySelector(
311
340
  'button:not([disabled]), [tabindex]:not([tabindex="-1"]), a[href], select:not([disabled]), input:not([disabled]):not([type="hidden"]), textarea:not([disabled])'
312
341
  )
@@ -323,14 +352,10 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
323
352
  }
324
353
  }
325
354
 
326
- /** @type {HTMLTableSectionElement|null} */
327
- const tbody = /** @type {any} */ (
355
+ const tbody = /** @type {HTMLTableSectionElement|null} */ (
328
356
  mount_element.querySelector('#list-root tbody')
329
357
  );
330
- /** @type {NodeListOf<HTMLTableRowElement>} */
331
- const items = tbody
332
- ? tbody.querySelectorAll('tr')
333
- : /** @type {any} */ ([]);
358
+ const items = tbody ? tbody.querySelectorAll('tr') : [];
334
359
  if (items.length === 0) {
335
360
  return;
336
361
  }
@@ -370,7 +395,7 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
370
395
  const current = items[idx];
371
396
  const id = current ? current.getAttribute('data-issue-id') : '';
372
397
  if (id) {
373
- const nav = navigate_fn || ((h) => (window.location.hash = h));
398
+ const nav = navigateFn || ((h) => (window.location.hash = h));
374
399
  /** @type {'issues'|'epics'|'board'} */
375
400
  const view = store ? store.getState().view : 'issues';
376
401
  nav(issueHashFor(view, id));
@@ -412,6 +437,20 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
412
437
  });
413
438
  }
414
439
 
440
+ // Live updates: recompose and re-render when issue stores change
441
+ if (selectors) {
442
+ selectors.subscribe(() => {
443
+ try {
444
+ issues_cache = /** @type {Issue[]} */ (
445
+ selectors.selectIssuesFor('tab:issues')
446
+ );
447
+ doRender();
448
+ } catch {
449
+ // ignore
450
+ }
451
+ });
452
+ }
453
+
415
454
  return {
416
455
  load,
417
456
  destroy() {
@@ -422,46 +461,4 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
422
461
  }
423
462
  }
424
463
  };
425
-
426
- /**
427
- * Update minimal fields inline via ws mutations and refresh that row's data.
428
- * @param {string} id
429
- * @param {{ [k: string]: any }} patch
430
- */
431
- async function updateInline(id, patch) {
432
- try {
433
- // Dispatch specific mutations based on provided keys
434
- if (typeof patch.title === 'string') {
435
- await sendFn('edit-text', { id, field: 'title', value: patch.title });
436
- }
437
- if (typeof patch.assignee === 'string') {
438
- await sendFn('update-assignee', { id, assignee: patch.assignee });
439
- }
440
- if (typeof patch.status === 'string') {
441
- await sendFn('update-status', { id, status: patch.status });
442
- }
443
- if (typeof patch.priority === 'number') {
444
- await sendFn('update-priority', { id, priority: patch.priority });
445
- }
446
- // Refresh the item from backend
447
- /** @type {any} */
448
- const full = await sendFn('show-issue', { id });
449
- // Replace in cache
450
- const idx = issues_cache.findIndex((x) => x.id === id);
451
- if (idx >= 0 && full && typeof full === 'object') {
452
- issues_cache[idx] = /** @type {Issue} */ ({
453
- id: full.id,
454
- title: full.title,
455
- status: full.status,
456
- priority: full.priority,
457
- issue_type: full.issue_type,
458
- assignee: full.assignee,
459
- labels: Array.isArray(full.labels) ? full.labels : []
460
- });
461
- }
462
- doRender();
463
- } catch {
464
- // ignore failures; UI state remains as-is
465
- }
466
- }
467
464
  }
@@ -10,8 +10,9 @@ import { priority_levels } from '../utils/priority.js';
10
10
  * @returns {{ open: () => void, close: () => void }}
11
11
  */
12
12
  export function createNewIssueDialog(mount_element, sendFn, router, store) {
13
- /** @type {HTMLDialogElement} */
14
- const dialog = /** @type {any} */ (document.createElement('dialog'));
13
+ const dialog = /** @type {HTMLDialogElement} */ (
14
+ document.createElement('dialog')
15
+ );
15
16
  dialog.id = 'new-issue-dialog';
16
17
  dialog.setAttribute('role', 'dialog');
17
18
  dialog.setAttribute('aria-modal', 'true');
@@ -52,32 +53,34 @@ export function createNewIssueDialog(mount_element, sendFn, router, store) {
52
53
 
53
54
  mount_element.appendChild(dialog);
54
55
 
55
- /** @type {HTMLFormElement} */
56
- const form = /** @type {any} */ (dialog.querySelector('#new-issue-form'));
57
- /** @type {HTMLInputElement} */
58
- const input_title = /** @type {any} */ (dialog.querySelector('#new-title'));
59
- /** @type {HTMLSelectElement} */
60
- const sel_type = /** @type {any} */ (dialog.querySelector('#new-type'));
61
- /** @type {HTMLSelectElement} */
62
- const sel_priority = /** @type {any} */ (
56
+ const form = /** @type {HTMLFormElement} */ (
57
+ dialog.querySelector('#new-issue-form')
58
+ );
59
+ const input_title = /** @type {HTMLInputElement} */ (
60
+ dialog.querySelector('#new-title')
61
+ );
62
+ const sel_type = /** @type {HTMLSelectElement} */ (
63
+ dialog.querySelector('#new-type')
64
+ );
65
+ const sel_priority = /** @type {HTMLSelectElement} */ (
63
66
  dialog.querySelector('#new-priority')
64
67
  );
65
- /** @type {HTMLInputElement} */
66
- const input_labels = /** @type {any} */ (dialog.querySelector('#new-labels'));
67
- /** @type {HTMLTextAreaElement} */
68
- const input_description = /** @type {any} */ (
68
+ const input_labels = /** @type {HTMLInputElement} */ (
69
+ dialog.querySelector('#new-labels')
70
+ );
71
+ const input_description = /** @type {HTMLTextAreaElement} */ (
69
72
  dialog.querySelector('#new-description')
70
73
  );
71
- /** @type {HTMLDivElement} */
72
- const error_box = /** @type {any} */ (
74
+ const error_box = /** @type {HTMLDivElement} */ (
73
75
  dialog.querySelector('#new-issue-error')
74
76
  );
75
- /** @type {HTMLButtonElement} */
76
- const btn_cancel = /** @type {any} */ (dialog.querySelector('#btn-cancel'));
77
- /** @type {HTMLButtonElement} */
78
- const btn_create = /** @type {any} */ (dialog.querySelector('#btn-create'));
79
- /** @type {HTMLButtonElement} */
80
- const btn_close = /** @type {any} */ (
77
+ const btn_cancel = /** @type {HTMLButtonElement} */ (
78
+ dialog.querySelector('#btn-cancel')
79
+ );
80
+ const btn_create = /** @type {HTMLButtonElement} */ (
81
+ dialog.querySelector('#btn-create')
82
+ );
83
+ const btn_close = /** @type {HTMLButtonElement} */ (
81
84
  dialog.querySelector('.new-issue__close')
82
85
  );
83
86
 
@@ -194,25 +197,20 @@ export function createNewIssueDialog(mount_element, sendFn, router, store) {
194
197
  */
195
198
  async function createNow() {
196
199
  clearError();
197
- /** @type {string} */
198
200
  const title = String(input_title.value || '').trim();
199
201
  if (title.length === 0) {
200
202
  setError('Title is required');
201
203
  input_title.focus();
202
204
  return;
203
205
  }
204
- /** @type {number} */
205
206
  const prio = Number(sel_priority.value || '2');
206
207
  if (!(prio >= 0 && prio <= 4)) {
207
208
  setError('Priority must be 0..4');
208
209
  sel_priority.focus();
209
210
  return;
210
211
  }
211
- /** @type {string} */
212
212
  const type = String(sel_type.value || '');
213
- /** @type {string} */
214
213
  const desc = String(input_description.value || '');
215
- /** @type {string[]} */
216
214
  const labels = String(input_labels.value || '')
217
215
  .split(',')
218
216
  .map((s) => s.trim())
@@ -251,10 +249,8 @@ export function createNewIssueDialog(mount_element, sendFn, router, store) {
251
249
  } catch {
252
250
  list = null;
253
251
  }
254
- /** @type {string} */
255
252
  let created_id = '';
256
253
  if (Array.isArray(list)) {
257
- /** @type {any[]} */
258
254
  const matches = list.filter((it) => String(it.title || '') === title);
259
255
  if (matches.length > 0) {
260
256
  /** @type {any} */
@@ -326,11 +322,8 @@ export function createNewIssueDialog(mount_element, sendFn, router, store) {
326
322
  clearError();
327
323
  loadDefaults();
328
324
  try {
329
- if (
330
- 'showModal' in dialog &&
331
- typeof (/** @type {any} */ (dialog).showModal) === 'function'
332
- ) {
333
- /** @type {any} */ (dialog).showModal();
325
+ if ('showModal' in dialog && typeof dialog.showModal === 'function') {
326
+ dialog.showModal();
334
327
  } else {
335
328
  dialog.setAttribute('open', '');
336
329
  }
package/app/ws.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * Usage:
10
10
  * const ws = createWsClient();
11
11
  * const data = await ws.send('list-issues', { filters: {} });
12
- * const off = ws.on('issues-changed', (payload) => { ... });
12
+ * const off = ws.on('snapshot', (payload) => { <push event> });
13
13
  */
14
14
  import { MESSAGE_TYPES, makeRequest, nextId } from './protocol.js';
15
15
 
@@ -123,8 +123,6 @@ export function createWsClient(options = {}) {
123
123
  state = 'open';
124
124
  notifyConnection(state);
125
125
  attempts = 0;
126
- // subscribe first
127
- sendRaw(makeRequest('subscribe-updates', {}));
128
126
  // flush queue
129
127
  while (queue.length) {
130
128
  const req = queue.shift();
@@ -193,16 +191,15 @@ export function createWsClient(options = {}) {
193
191
  }
194
192
  const url = resolveUrl();
195
193
  try {
196
- ws = /** @type {any} */ (new WebSocket(url));
194
+ ws = new WebSocket(url);
197
195
  state = 'connecting';
198
196
  notifyConnection(state);
199
- const s = /** @type {any} */ (ws);
200
- s.addEventListener('open', onOpen);
201
- s.addEventListener('message', onMessage);
202
- s.addEventListener('error', () => {
197
+ ws.addEventListener('open', onOpen);
198
+ ws.addEventListener('message', onMessage);
199
+ ws.addEventListener('error', () => {
203
200
  // let close handler handle reconnect
204
201
  });
205
- s.addEventListener('close', onClose);
202
+ ws.addEventListener('close', onClose);
206
203
  } catch (err) {
207
204
  logger.error('ws connect failed', err);
208
205
  scheduleReconnect();
package/bin/bdui.js CHANGED
@@ -10,7 +10,7 @@ const argv = process.argv.slice(2);
10
10
  try {
11
11
  const code = await main(argv);
12
12
  if (Number.isFinite(code)) {
13
- process.exitCode = /** @type {number} */ (code);
13
+ process.exitCode = code;
14
14
  }
15
15
  } catch (err) {
16
16
  console.error(String(/** @type {any} */ (err)?.message || err));
@@ -0,0 +1,134 @@
1
+ # ADR 001 — Push‑Only Lists (v2)
2
+
3
+ ```
4
+ Date: 2025-10-26
5
+ Status: Accepted (data‑flow details superseded by ADR 002)
6
+ Owner: agent
7
+ ```
8
+
9
+ ## Context
10
+
11
+ The UI currently mixes push updates with read RPCs like `list-issues` and
12
+ `epic-status`. This ADR establishes the push‑only direction for list data and
13
+ removing read RPCs in list views. It predated ADR 002 which later simplified the
14
+ data flow further (per‑subscription stores + full‑issue payloads).
15
+
16
+ - Push streams provide everything lists need to render. See
17
+ `docs/protocol/issues-push-v2.md` and ADR 002. Earlier iterations used a
18
+ central `issues` entity cache plus `list-delta` membership; this has been
19
+ replaced by per‑subscription stores receiving full issue payloads.
20
+
21
+ We want every list‑shaped view (Issues, Board, Epics → children) to render
22
+ exclusively from local push data. Reads remain only for mutations that return a
23
+ single updated entity (e.g. detail view refresh).
24
+
25
+ Related docs:
26
+
27
+ - Protocol: `docs/protocol/issues-push-v2.md`
28
+ - Server plan: `docs/data-exchange-subscription-plan.md`
29
+
30
+ ## Decision
31
+
32
+ - One active subscription per visible list. Examples (client ids):
33
+ - Issues tab: `tab:issues` with spec from filters via `computeIssuesSpec()`
34
+ - Board: `tab:board:ready|in-progress|closed|blocked`
35
+ - Epics list: `tab:epics` (for epic entities); children subscribe on expand as
36
+ `detail:{id}` with `{ type: 'issue-detail', params: { id } }`
37
+ - Rendering reads from two local stores only:
38
+ - `per‑subscription stores`: one store per active client subscription id.
39
+ Stores receive versioned `snapshot`/`upsert`/`delete` push envelopes with
40
+ full issue payloads and expose deterministic, sorted snapshots for the
41
+ owning view.
42
+ - `subscriptions`: manages subscription lifecycle and keys. Rendering reads
43
+ from per‑subscription stores, not from membership ids.
44
+ - Introduce a small selectors utility to apply view‑specific sort rules on store
45
+ snapshots (no composition from a central cache).
46
+ - Remove read RPCs used for lists: `list-issues`, `epic-status`. Keep mutation
47
+ RPCs and `show-issue` until detail view also reads from push cache.
48
+ - Tests drive views with push envelopes and `list-delta`; no RPC stubs for
49
+ reads.
50
+
51
+ ## API Shape (Client)
52
+
53
+ Subscriptions store (already implemented):
54
+
55
+ ```js
56
+ // app/data/subscriptions-store.js
57
+ createSubscriptionStore(send) -> {
58
+ wireEvents(on), subscribeList(client_id, spec) -> unsubscribe,
59
+ selectors: { getIds(client_id), has(client_id), count(client_id) }
60
+ }
61
+ ```
62
+
63
+ Selectors utility (implemented):
64
+
65
+ ```js
66
+ // app/data/list-selectors.js
67
+ /** Compose from per‑subscription store snapshots and apply stable sort. */
68
+ export function createListSelectors(issueStores) {
69
+ return {
70
+ selectIssuesFor(client_id) {},
71
+ selectBoardColumn(client_id, mode) {},
72
+ selectEpicChildren(epic_id) {},
73
+ subscribe(fn) {}
74
+ };
75
+ }
76
+ ```
77
+
78
+ Sorting rules:
79
+
80
+ - Issues list: priority asc (0..4), then `created_at` desc, then id asc.
81
+ - Board columns: preserve existing view rules (ready → priority asc, then
82
+ `updated_at` desc; in‑progress → `updated_at` desc; closed → `closed_at`
83
+ desc).
84
+ - Epics children: same as Issues list unless view specifies otherwise.
85
+
86
+ ## Consequences
87
+
88
+ Pros:
89
+
90
+ - Consistent, snappy UI with minimal fetch logic; views are pure derives.
91
+ - Server can batch and coalesce; client renders at most once per envelope.
92
+ - Clear separation: mutations via RPC, reads via push caches.
93
+
94
+ Cons / Risks:
95
+
96
+ - Initial implementation work in views and tests.
97
+ - Need disciplined subscription lifecycle on route/tab changes.
98
+ - Requires follow‑up to migrate detail view fully to the push cache.
99
+
100
+ ## Migration Checklist
101
+
102
+ Views
103
+
104
+ - [x] Issues view renders from per‑subscription stores; no `list-issues`.
105
+ - [x] Board renders from per‑subscription stores; no `get*` list reads.
106
+ - [x] Epics list/children render from per‑subscription stores; children use
107
+ `issue-detail` for the epic id; children come from `dependents`.
108
+
109
+ Client Data Layer
110
+
111
+ - [x] Add `app/data/list-selectors.js` with helpers listed above (UI-156).
112
+ - [x] Remove list read functions from `app/data/providers.js` (UI-159).
113
+ - [ ] Keep `getIssue` and all mutation helpers until detail view push migration
114
+ happens (follow‑up).
115
+
116
+ Tests
117
+
118
+ - [x] Update list/board/epics tests to use per‑subscription push envelopes
119
+ (UI-158).
120
+ - [x] Remove RPC read stubs from tests.
121
+
122
+ Docs
123
+
124
+ - [x] This ADR committed (UI-152).
125
+ - [x] Update protocol and architecture docs for push‑only model (UI-160).
126
+
127
+ ## Notes
128
+
129
+ - Client ids used in this repo today:
130
+ - `tab:issues` for the Issues view
131
+ - `tab:board:ready|in-progress|closed|blocked` for Board columns
132
+ - `tab:epics` for the Epics tab; `epic:${id}` for expanded children
133
+ - See `app/main.js` for current subscription wiring, filter → spec mapping, and
134
+ per‑subscription push routing.