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/main.js CHANGED
@@ -1,5 +1,11 @@
1
+ /**
2
+ * @import { MessageType } from './protocol.js'
3
+ */
1
4
  import { html, render } from 'lit-html';
5
+ import { createListSelectors } from './data/list-selectors.js';
2
6
  import { createDataLayer } from './data/providers.js';
7
+ import { createSubscriptionIssueStores } from './data/subscription-issue-stores.js';
8
+ import { createSubscriptionStore } from './data/subscriptions-store.js';
3
9
  import { createHashRouter, parseHash } from './router.js';
4
10
  import { createStore } from './state.js';
5
11
  import { showToast } from './utils/toast.js';
@@ -43,10 +49,55 @@ export function bootstrap(root_element) {
43
49
  const detail_mount = document.getElementById('detail-panel');
44
50
  if (list_mount && issues_root && epics_root && board_root && detail_mount) {
45
51
  const client = createWsClient();
52
+ // Subscriptions: wire client events and expose subscribe/unsubscribe helpers
53
+ const subscriptions = createSubscriptionStore((type, payload) =>
54
+ client.send(type, payload)
55
+ );
56
+ // Per-subscription stores (source of truth)
57
+ const sub_issue_stores = createSubscriptionIssueStores();
58
+ // Route per-subscription push envelopes to the owning store
59
+ client.on('snapshot', (payload) => {
60
+ const p = /** @type {any} */ (payload);
61
+ const id = p && typeof p.id === 'string' ? p.id : '';
62
+ const store = id ? sub_issue_stores.getStore(id) : null;
63
+ if (store && p && p.type === 'snapshot') {
64
+ try {
65
+ store.applyPush(p);
66
+ } catch {
67
+ // ignore
68
+ }
69
+ }
70
+ });
71
+ client.on('upsert', (payload) => {
72
+ const p = /** @type {any} */ (payload);
73
+ const id = p && typeof p.id === 'string' ? p.id : '';
74
+ const store = id ? sub_issue_stores.getStore(id) : null;
75
+ if (store && p && p.type === 'upsert') {
76
+ try {
77
+ store.applyPush(p);
78
+ } catch {
79
+ // ignore
80
+ }
81
+ }
82
+ });
83
+ client.on('delete', (payload) => {
84
+ const p = /** @type {any} */ (payload);
85
+ const id = p && typeof p.id === 'string' ? p.id : '';
86
+ const store = id ? sub_issue_stores.getStore(id) : null;
87
+ if (store && p && p.type === 'delete') {
88
+ try {
89
+ store.applyPush(p);
90
+ } catch {
91
+ // ignore
92
+ }
93
+ }
94
+ });
95
+ // Derived list selectors: render from per-subscription snapshots
96
+ const listSelectors = createListSelectors(sub_issue_stores);
46
97
  // Show toasts for WebSocket connectivity changes
47
98
  /** @type {boolean} */
48
99
  let had_disconnect = false;
49
- if (typeof (/** @type {any} */ (client).onConnection) === 'function') {
100
+ if (typeof client.onConnection === 'function') {
50
101
  /** @type {(s: 'connecting'|'open'|'closed'|'reconnecting') => void} */
51
102
  const onConn = (s) => {
52
103
  if (s === 'reconnecting' || s === 'closed') {
@@ -57,11 +108,11 @@ export function bootstrap(root_element) {
57
108
  showToast('Reconnected', 'success', 2200);
58
109
  }
59
110
  };
60
- /** @type {any} */ (client).onConnection(onConn);
111
+ client.onConnection(onConn);
61
112
  }
62
113
  // Load persisted filters (status/search/type) from localStorage
63
114
  /** @type {{ status: 'all'|'open'|'in_progress'|'closed'|'ready', search: string, type: string }} */
64
- let persistedFilters = { status: 'all', search: '', type: '' };
115
+ let persisted_filters = { status: 'all', search: '', type: '' };
65
116
  try {
66
117
  const raw = window.localStorage.getItem('beads-ui.filters');
67
118
  if (raw) {
@@ -82,7 +133,7 @@ export function bootstrap(root_element) {
82
133
  }
83
134
  parsed_type = first_valid;
84
135
  }
85
- persistedFilters = {
136
+ persisted_filters = {
86
137
  status: ['all', 'open', 'in_progress', 'closed', 'ready'].includes(
87
138
  obj.status
88
139
  )
@@ -121,7 +172,7 @@ export function bootstrap(root_element) {
121
172
  if (obj && typeof obj === 'object') {
122
173
  const cf = String(obj.closed_filter || 'today');
123
174
  if (cf === 'today' || cf === '3' || cf === '7') {
124
- persistedBoard.closed_filter = /** @type {any} */ (cf);
175
+ persistedBoard.closed_filter = cf;
125
176
  }
126
177
  }
127
178
  }
@@ -130,7 +181,7 @@ export function bootstrap(root_element) {
130
181
  }
131
182
 
132
183
  const store = createStore({
133
- filters: persistedFilters,
184
+ filters: persisted_filters,
134
185
  view: last_view,
135
186
  board: persistedBoard
136
187
  });
@@ -142,7 +193,7 @@ export function bootstrap(root_element) {
142
193
  */
143
194
  const transport = async (type, payload) => {
144
195
  try {
145
- return await client.send(/** @type {any} */ (type), payload);
196
+ return await client.send(/** @type {MessageType} */ (type), payload);
146
197
  } catch {
147
198
  return [];
148
199
  }
@@ -155,7 +206,7 @@ export function bootstrap(root_element) {
155
206
  // Global New Issue dialog (UI-106) mounted at root so it is always visible
156
207
  const new_issue_dialog = createNewIssueDialog(
157
208
  root_element,
158
- (type, payload) => client.send(/** @type {any} */ (type), payload),
209
+ (type, payload) => client.send(type, payload),
159
210
  router,
160
211
  store
161
212
  );
@@ -171,16 +222,35 @@ export function bootstrap(root_element) {
171
222
  // ignore missing header
172
223
  }
173
224
 
225
+ // Local transport shim: for list-issues, serve from local listSelectors;
226
+ // otherwise forward to ws transport for mutations/show.
227
+ /**
228
+ * @param {MessageType} type
229
+ * @param {unknown} payload
230
+ */
231
+ const listTransport = async (type, payload) => {
232
+ if (type === 'list-issues') {
233
+ try {
234
+ return listSelectors.selectIssuesFor('tab:issues');
235
+ } catch {
236
+ return [];
237
+ }
238
+ }
239
+ return transport(type, payload);
240
+ };
241
+
174
242
  const issues_view = createListView(
175
243
  list_mount,
176
- transport,
244
+ /** @type {any} */ (listTransport),
177
245
  (hash) => {
178
246
  const id = parseHash(hash);
179
247
  if (id) {
180
248
  router.gotoIssue(id);
181
249
  }
182
250
  },
183
- store
251
+ store,
252
+ subscriptions,
253
+ sub_issue_stores
184
254
  );
185
255
  // Persist filter changes to localStorage
186
256
  store.subscribe((s) => {
@@ -225,12 +295,17 @@ export function bootstrap(root_element) {
225
295
  /** @type {ReturnType<typeof createDetailView> | null} */
226
296
  let detail = null;
227
297
  // Mount details into the dialog body only
228
- detail = createDetailView(dialog.getMount(), transport, (hash) => {
229
- const id = parseHash(hash);
230
- if (id) {
231
- router.gotoIssue(id);
232
- }
233
- });
298
+ detail = createDetailView(
299
+ dialog.getMount(),
300
+ transport,
301
+ (hash) => {
302
+ const id = parseHash(hash);
303
+ if (id) {
304
+ router.gotoIssue(id);
305
+ }
306
+ },
307
+ sub_issue_stores
308
+ );
234
309
 
235
310
  // If router already set a selected id (deep-link), open dialog now
236
311
  const initial_id = store.getState().selected_id;
@@ -240,9 +315,21 @@ export function bootstrap(root_element) {
240
315
  if (detail) {
241
316
  void detail.load(initial_id);
242
317
  }
318
+ // Ensure detail subscription is active on initial deep-link
319
+ const client_id = `detail:${initial_id}`;
320
+ const spec = { type: 'issue-detail', params: { id: initial_id } };
321
+ // Register store first to avoid dropping the initial snapshot
322
+ try {
323
+ sub_issue_stores.register(client_id, spec);
324
+ } catch {
325
+ // ignore
326
+ }
327
+ void subscriptions.subscribeList(client_id, spec).catch(() => {});
243
328
  }
244
329
 
245
330
  // Open/close dialog based on selected_id (always dialog; no page variant)
331
+ /** @type {null | (() => Promise<void>)} */
332
+ let unsub_detail = null;
246
333
  store.subscribe((s) => {
247
334
  const id = s.selected_id;
248
335
  if (id) {
@@ -251,6 +338,26 @@ export function bootstrap(root_element) {
251
338
  if (detail) {
252
339
  void detail.load(id);
253
340
  }
341
+ // Wire per-issue subscription for detail
342
+ const client_id = `detail:${id}`;
343
+ const spec = { type: 'issue-detail', params: { id } };
344
+ // Ensure per-subscription issue store exists before subscribing
345
+ try {
346
+ sub_issue_stores.register(client_id, spec);
347
+ } catch {
348
+ // ignore
349
+ }
350
+ // Subscribe server-side
351
+ void subscriptions
352
+ .subscribeList(client_id, spec)
353
+ .then((unsub) => {
354
+ // Unsubscribe previous if any
355
+ if (unsub_detail) {
356
+ void unsub_detail().catch(() => {});
357
+ }
358
+ unsub_detail = unsub;
359
+ })
360
+ .catch(() => {});
254
361
  } else {
255
362
  try {
256
363
  dialog.close();
@@ -261,69 +368,234 @@ export function bootstrap(root_element) {
261
368
  detail.clear();
262
369
  }
263
370
  detail_mount.hidden = true;
371
+ if (unsub_detail) {
372
+ void unsub_detail().catch(() => {});
373
+ unsub_detail = null;
374
+ }
264
375
  }
265
376
  });
266
377
 
267
- // Refresh views on push updates (target minimally and avoid flicker)
268
- // UI-114: Coalesce near-simultaneous events. When an ID-scoped update
269
- // arrives, suppress a trailing watcher-only full refresh for a short
270
- // window to avoid duplicate work and flicker.
271
- /** @type {number} */
272
- let suppress_full_until = 0;
273
- client.on('issues-changed', (payload) => {
274
- const s = store.getState();
275
- const hint_ids =
276
- payload && payload.hint && Array.isArray(payload.hint.ids)
277
- ? /** @type {string[]} */ (payload.hint.ids)
278
- : null;
378
+ // Removed: issues-changed handling. All views re-render from
379
+ // per-subscription stores which are updated by snapshot/upsert/delete.
279
380
 
280
- const now = Date.now();
281
- if (!hint_ids || hint_ids.length === 0) {
282
- if (now <= suppress_full_until) {
283
- // Drop redundant full refresh that follows a targeted update.
284
- return;
285
- }
286
- } else {
287
- // Prefer ID-scoped updates for a brief window.
288
- suppress_full_until = now + 500;
381
+ // Toggle route shells on view/detail change and persist
382
+ const data = createDataLayer(transport);
383
+ const epics_view = createEpicsView(
384
+ epics_root,
385
+ data,
386
+ (id) => router.gotoIssue(id),
387
+ subscriptions,
388
+ sub_issue_stores
389
+ );
390
+ const board_view = createBoardView(
391
+ board_root,
392
+ data,
393
+ (id) => router.gotoIssue(id),
394
+ store,
395
+ subscriptions,
396
+ sub_issue_stores
397
+ );
398
+ // Preload epics when switching to view
399
+ /**
400
+ * @param {{ selected_id: string | null, view: 'issues'|'epics'|'board', filters: any }} s
401
+ */
402
+ // --- Subscriptions: tab-level management and filter-driven updates ---
403
+ /** @type {null | (() => Promise<void>)} */
404
+ let unsub_issues_tab = null;
405
+ /** @type {null | (() => Promise<void>)} */
406
+ let unsub_epics_tab = null;
407
+ /** @type {null | (() => Promise<void>)} */
408
+ let unsub_board_ready = null;
409
+ /** @type {null | (() => Promise<void>)} */
410
+ let unsub_board_in_progress = null;
411
+ /** @type {null | (() => Promise<void>)} */
412
+ let unsub_board_closed = null;
413
+ /** @type {null | (() => Promise<void>)} */
414
+ let unsub_board_blocked = null;
415
+
416
+ /**
417
+ * Compute subscription spec for Issues tab based on filters.
418
+ * @param {{ status?: string }} filters
419
+ * @returns {{ type: string, params?: Record<string, string|number|boolean> }}
420
+ */
421
+ function computeIssuesSpec(filters) {
422
+ const st = String(filters?.status || 'all');
423
+ if (st === 'ready') {
424
+ return { type: 'ready-issues' };
425
+ }
426
+ if (st === 'in_progress') {
427
+ return { type: 'in-progress-issues' };
289
428
  }
429
+ if (st === 'closed') {
430
+ return { type: 'closed-issues' };
431
+ }
432
+ // "all" and "open" map to all-issues; client filters apply locally
433
+ return { type: 'all-issues' };
434
+ }
290
435
 
291
- const showing_detail = Boolean(s.selected_id);
436
+ /** @type {string|null} */
437
+ let last_issues_spec_key = null;
438
+ /**
439
+ * Ensure only the active tab has subscriptions; clean up previous.
440
+ * @param {{ view: 'issues'|'epics'|'board', filters: any }} s
441
+ */
442
+ function ensureTabSubscriptions(s) {
443
+ // Issues tab
444
+ if (s.view === 'issues') {
445
+ const spec = computeIssuesSpec(s.filters || {});
446
+ const key = JSON.stringify(spec);
447
+ // Register store first to capture the initial snapshot
448
+ try {
449
+ sub_issue_stores.register('tab:issues', spec);
450
+ } catch {
451
+ // ignore
452
+ }
453
+ // Only (re)subscribe if not yet subscribed or the spec changed
454
+ if (!unsub_issues_tab || key !== last_issues_spec_key) {
455
+ void subscriptions
456
+ .subscribeList('tab:issues', spec)
457
+ .then((unsub) => {
458
+ unsub_issues_tab = unsub;
459
+ last_issues_spec_key = key;
460
+ })
461
+ .catch(() => {
462
+ // ignore transport errors; retry on next change
463
+ });
464
+ }
465
+ } else if (unsub_issues_tab) {
466
+ void unsub_issues_tab().catch(() => {});
467
+ unsub_issues_tab = null;
468
+ last_issues_spec_key = null;
469
+ try {
470
+ sub_issue_stores.unregister('tab:issues');
471
+ } catch {
472
+ // ignore
473
+ }
474
+ }
292
475
 
293
- // If a top-level view is visible (and not detail), refresh that view
294
- if (!showing_detail) {
295
- if (s.view === 'issues') {
296
- void issues_view.load();
297
- } else if (s.view === 'epics') {
298
- void epics_view.load();
299
- } else if (s.view === 'board') {
300
- void board_view.load();
476
+ // Epics tab
477
+ if (s.view === 'epics') {
478
+ // Register store first to avoid race with initial snapshot
479
+ try {
480
+ sub_issue_stores.register('tab:epics', { type: 'epics' });
481
+ } catch {
482
+ // ignore
483
+ }
484
+ void subscriptions
485
+ .subscribeList('tab:epics', { type: 'epics' })
486
+ .then((unsub) => {
487
+ unsub_epics_tab = unsub;
488
+ })
489
+ .catch(() => {});
490
+ } else if (unsub_epics_tab) {
491
+ void unsub_epics_tab().catch(() => {});
492
+ unsub_epics_tab = null;
493
+ try {
494
+ sub_issue_stores.unregister('tab:epics');
495
+ } catch {
496
+ // ignore
301
497
  }
302
498
  }
303
499
 
304
- // If a detail is visible, re-fetch it when relevant or when hints are absent
305
- if (showing_detail && s.selected_id) {
306
- if (!hint_ids || hint_ids.includes(s.selected_id)) {
307
- if (detail) {
308
- void detail.load(s.selected_id);
500
+ // Board tab subscribes to lists used by columns
501
+ if (s.view === 'board') {
502
+ if (!unsub_board_ready) {
503
+ try {
504
+ sub_issue_stores.register('tab:board:ready', {
505
+ type: 'ready-issues'
506
+ });
507
+ } catch {
508
+ // ignore
509
+ }
510
+ void subscriptions
511
+ .subscribeList('tab:board:ready', { type: 'ready-issues' })
512
+ .then((u) => (unsub_board_ready = u))
513
+ .catch(() => {});
514
+ }
515
+ if (!unsub_board_in_progress) {
516
+ try {
517
+ sub_issue_stores.register('tab:board:in-progress', {
518
+ type: 'in-progress-issues'
519
+ });
520
+ } catch {
521
+ // ignore
522
+ }
523
+ void subscriptions
524
+ .subscribeList('tab:board:in-progress', {
525
+ type: 'in-progress-issues'
526
+ })
527
+ .then((u) => (unsub_board_in_progress = u))
528
+ .catch(() => {});
529
+ }
530
+ if (!unsub_board_closed) {
531
+ try {
532
+ sub_issue_stores.register('tab:board:closed', {
533
+ type: 'closed-issues'
534
+ });
535
+ } catch {
536
+ // ignore
537
+ }
538
+ void subscriptions
539
+ .subscribeList('tab:board:closed', { type: 'closed-issues' })
540
+ .then((u) => (unsub_board_closed = u))
541
+ .catch(() => {});
542
+ }
543
+ if (!unsub_board_blocked) {
544
+ try {
545
+ sub_issue_stores.register('tab:board:blocked', {
546
+ type: 'blocked-issues'
547
+ });
548
+ } catch {
549
+ // ignore
550
+ }
551
+ void subscriptions
552
+ .subscribeList('tab:board:blocked', { type: 'blocked-issues' })
553
+ .then((u) => (unsub_board_blocked = u))
554
+ .catch(() => {});
555
+ }
556
+ } else {
557
+ // Unsubscribe all board lists when leaving the board view
558
+ if (unsub_board_ready) {
559
+ void unsub_board_ready().catch(() => {});
560
+ unsub_board_ready = null;
561
+ try {
562
+ sub_issue_stores.unregister('tab:board:ready');
563
+ } catch {
564
+ // ignore
565
+ }
566
+ }
567
+ if (unsub_board_in_progress) {
568
+ void unsub_board_in_progress().catch(() => {});
569
+ unsub_board_in_progress = null;
570
+ try {
571
+ sub_issue_stores.unregister('tab:board:in-progress');
572
+ } catch {
573
+ // ignore
574
+ }
575
+ }
576
+ if (unsub_board_closed) {
577
+ void unsub_board_closed().catch(() => {});
578
+ unsub_board_closed = null;
579
+ try {
580
+ sub_issue_stores.unregister('tab:board:closed');
581
+ } catch {
582
+ // ignore
583
+ }
584
+ }
585
+ if (unsub_board_blocked) {
586
+ void unsub_board_blocked().catch(() => {});
587
+ unsub_board_blocked = null;
588
+ try {
589
+ sub_issue_stores.unregister('tab:board:blocked');
590
+ } catch {
591
+ // ignore
309
592
  }
310
593
  }
311
594
  }
312
- });
595
+ }
313
596
 
314
- // Toggle route shells on view/detail change and persist
315
- const data = createDataLayer(/** @type {any} */ (transport), client.on);
316
- const epics_view = createEpicsView(epics_root, data, (id) =>
317
- router.gotoIssue(id)
318
- );
319
- const board_view = createBoardView(
320
- board_root,
321
- data,
322
- (id) => router.gotoIssue(id),
323
- store
324
- );
325
- // Preload epics when switching to view
326
597
  /**
598
+ * Manage route visibility and list subscriptions per view.
327
599
  * @param {{ selected_id: string | null, view: 'issues'|'epics'|'board', filters: any }} s
328
600
  */
329
601
  const onRouteChange = (s) => {
@@ -334,6 +606,9 @@ export function bootstrap(root_element) {
334
606
  board_root.hidden = s.view !== 'board';
335
607
  // detail_mount visibility handled in subscription above
336
608
  }
609
+ // Ensure subscriptions for the active tab before loading the view to
610
+ // avoid empty initial renders due to racing list-delta.
611
+ ensureTabSubscriptions(s);
337
612
  if (!s.selected_id && s.view === 'epics') {
338
613
  void epics_view.load();
339
614
  }
@@ -350,12 +625,13 @@ export function bootstrap(root_element) {
350
625
  // Ensure initial state is reflected (fixes reload on #/epics)
351
626
  onRouteChange(store.getState());
352
627
 
628
+ // Removed redundant filter-change subscription: handled by ensureTabSubscriptions
629
+
353
630
  // Keyboard shortcuts: Ctrl/Cmd+N opens new issue; Ctrl/Cmd+Enter submits inside dialog
354
631
  window.addEventListener('keydown', (ev) => {
355
632
  const is_modifier = ev.ctrlKey || ev.metaKey;
356
633
  const key = String(ev.key || '').toLowerCase();
357
- /** @type {HTMLElement} */
358
- const target = /** @type {any} */ (ev.target);
634
+ const target = /** @type {HTMLElement} */ (ev.target);
359
635
  const tag =
360
636
  target && target.tagName ? String(target.tagName).toLowerCase() : '';
361
637
  const is_editable =
package/app/protocol.js CHANGED
@@ -6,17 +6,10 @@
6
6
  * - Client → Server uses RequestEnvelope.
7
7
  * - Server → Client uses ReplyEnvelope.
8
8
  * - Every request is correlated by `id` in replies.
9
- * - Server can also send unsolicited events (e.g., `issues-changed`).
10
- *
11
- * Versioning
12
- * - Increment `PROTOCOL_VERSION` on breaking changes.
13
- * - Add new message types without breaking existing ones when possible.
9
+ * - Server can also send unsolicited events (e.g., subscription `snapshot`).
14
10
  */
15
11
 
16
- /** @constant {string} */
17
- export const PROTOCOL_VERSION = '1.0.0';
18
-
19
- /** @typedef {'list-issues'|'show-issue'|'update-status'|'edit-text'|'update-priority'|'create-issue'|'list-ready'|'subscribe-updates'|'issues-changed'|'dep-add'|'dep-remove'|'epic-status'|'update-assignee'|'label-add'|'label-remove'} MessageType */
12
+ /** @typedef {'list-issues'|'update-status'|'edit-text'|'update-priority'|'create-issue'|'list-ready'|'dep-add'|'dep-remove'|'epic-status'|'update-assignee'|'label-add'|'label-remove'|'subscribe-list'|'unsubscribe-list'|'snapshot'|'upsert'|'delete'} MessageType */
20
13
 
21
14
  /**
22
15
  * @typedef {Object} RequestEnvelope
@@ -44,20 +37,23 @@ export const PROTOCOL_VERSION = '1.0.0';
44
37
  /** @type {MessageType[]} */
45
38
  export const MESSAGE_TYPES = /** @type {const} */ ([
46
39
  'list-issues',
47
- 'show-issue',
48
40
  'update-status',
49
41
  'edit-text',
50
42
  'update-priority',
51
43
  'create-issue',
52
44
  'list-ready',
53
- 'subscribe-updates',
54
- 'issues-changed',
55
45
  'dep-add',
56
46
  'dep-remove',
57
47
  'epic-status',
58
48
  'update-assignee',
59
49
  'label-add',
60
- 'label-remove'
50
+ 'label-remove',
51
+ 'subscribe-list',
52
+ 'unsubscribe-list',
53
+ // vNext per-subscription full-issue push events
54
+ 'snapshot',
55
+ 'upsert',
56
+ 'delete'
61
57
  ]);
62
58
 
63
59
  /**
@@ -162,7 +158,7 @@ export function isReply(value) {
162
158
  return false;
163
159
  }
164
160
  if (value.ok === false) {
165
- const err = /** @type {any} */ (value).error;
161
+ const err = value.error;
166
162
  if (
167
163
  !isRecord(err) ||
168
164
  typeof err.code !== 'string' ||
package/app/protocol.md CHANGED
@@ -1,4 +1,12 @@
1
- # beads-ui WebSocket Protocol (v1.0.0)
1
+ # beads-ui WebSocket Protocol (v2.0.0)
2
+
3
+ Note (2025-10-26)
4
+
5
+ - The server no longer implements legacy read RPCs `list-issues` and
6
+ `epic-status`. Clients must use the push-only protocol described in
7
+ `docs/protocol/issues-push-v2.md` (subscribe-list with per-subscription events
8
+ snapshot/upsert/delete). The shapes below are retained for historical
9
+ reference of v1.
2
10
 
3
11
  This document defines the JSON messages exchanged between the browser client and
4
12
  the local server.
@@ -13,13 +21,13 @@ the local server.
13
21
  - ReplyEnvelope:
14
22
  `{ id: string, ok: boolean, type: string, payload?: any, error?: { code: string, message: string, details?: any } }`
15
23
 
16
- Server may send unsolicited events (e.g., `issues-changed`) using the
17
- ReplyEnvelope shape with `ok: true` and a generated `id`.
24
+ Server may send unsolicited events (e.g., subscription
25
+ `snapshot`/`upsert`/`delete`) using the ReplyEnvelope shape with `ok: true` and
26
+ a generated `id`.
18
27
 
19
28
  ## Message Types
20
29
 
21
- - `list-issues` payload: `{ filters?: { status?: string, priority?: number } }`
22
- - `show-issue` payload: `{ id: string }`
30
+ - Removed in v2: `list-issues` (use subscriptions + push stores)
23
31
  - `update-status` payload:
24
32
  `{ id: string, status: 'open'|'in_progress'|'closed' }`
25
33
  - `edit-text` payload:
@@ -28,9 +36,9 @@ ReplyEnvelope shape with `ok: true` and a generated `id`.
28
36
  - `create-issue` payload:
29
37
  `{ title: string, type?: 'bug'|'feature'|'task'|'epic'|'chore', priority?: 0|1|2|3|4, description?: string }`
30
38
  - `list-ready` payload: `{}`
31
- - `subscribe-updates` payload: `{}` (server responds with `ok` and begins
32
- emitting events)
33
- - `issues-changed` payload: `{ ts: number, hint?: { ids?: string[] } }`
39
+ - Removed in v2: `subscribe-updates` and the `issues-changed` event. All list
40
+ and detail updates flow via per-subscription push envelopes
41
+ (`snapshot`/`upsert`/`delete`).
34
42
  - `dep-add` payload: `{ a: string, b: string, view_id?: string }` where `a`
35
43
  depends on `b` (i.e., `a` is blocked by `b`). Reply payload is the updated
36
44
  issue for `view_id` (or `a` when omitted).
@@ -40,8 +48,8 @@ ReplyEnvelope shape with `ok: true` and a generated `id`.
40
48
 
41
49
  ## Mapping to `bd` CLI
42
50
 
43
- - `list-issues` → `bd list --json [--status <s>] [--priority <n>]`
44
- - `show-issue` → `bd show <id> --json`
51
+ - Removed in v2: `list-issues` → use subscriptions and push
52
+ (`docs/protocol/issues-push-v2.md`)
45
53
  - `update-status` → `bd update <id> --status <status>`
46
54
  - `edit-text` → `bd update <id> --title <t>` or `--description <d>` or
47
55
  `--acceptance-criteria <a>` or `--notes <n>` or `--design <z>`
@@ -56,8 +64,3 @@ Errors follow the shape `{ code, message, details? }`. Common codes:
56
64
  - `bad_request` – malformed payload or unknown type
57
65
  - `not_found` – entity not found (e.g., issue id)
58
66
  - `bd_error` – underlying `bd` command failed
59
-
60
- ## Versioning
61
-
62
- Breaking changes to shapes or semantics increment `PROTOCOL_VERSION` in
63
- `app/protocol.js`.