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.
- package/CHANGES.md +5 -0
- package/README.md +4 -4
- package/app/data/list-selectors.js +98 -0
- package/app/data/providers.js +5 -138
- package/app/data/sort.js +45 -0
- package/app/data/subscription-issue-store.js +161 -0
- package/app/data/subscription-issue-stores.js +102 -0
- package/app/data/subscriptions-store.js +219 -0
- package/app/main.js +342 -66
- package/app/protocol.js +10 -14
- package/app/protocol.md +18 -15
- package/app/styles.css +222 -197
- package/app/utils/markdown.js +15 -194
- package/app/utils/priority-badge.js +0 -2
- package/app/utils/status-badge.js +0 -1
- package/app/utils/toast.js +0 -1
- package/app/utils/type-badge.js +0 -3
- package/app/views/board.js +166 -144
- package/app/views/detail.js +76 -66
- package/app/views/epics.js +126 -74
- package/app/views/issue-dialog.js +8 -15
- package/app/views/issue-row.js +1 -3
- package/app/views/list.js +101 -104
- package/app/views/new-issue-dialog.js +27 -34
- package/app/ws.js +6 -9
- package/bin/bdui.js +1 -1
- package/docs/adr/001-push-only-lists.md +134 -0
- package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
- package/docs/architecture.md +34 -84
- package/docs/data-exchange-subscription-plan.md +198 -0
- package/docs/db-watching.md +2 -1
- package/docs/migration-v2.md +54 -0
- package/docs/protocol/issues-push-v2.md +179 -0
- package/docs/subscription-issue-store.md +112 -0
- package/package.json +4 -2
- package/server/bd.js +0 -2
- package/server/cli/commands.js +1 -2
- package/server/cli/daemon.js +12 -5
- package/server/cli/index.js +0 -2
- package/server/cli/usage.js +1 -1
- package/server/config.js +12 -6
- package/server/db.js +0 -1
- package/server/index.js +9 -5
- package/server/list-adapters.js +218 -0
- package/server/subscriptions.js +277 -0
- package/server/validators.js +111 -0
- package/server/watcher.js +5 -8
- 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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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(
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
//
|
|
268
|
-
//
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
294
|
-
if (
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
//
|
|
305
|
-
if (
|
|
306
|
-
if (!
|
|
307
|
-
|
|
308
|
-
|
|
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., `
|
|
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
|
-
/** @
|
|
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 =
|
|
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 (
|
|
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.,
|
|
17
|
-
ReplyEnvelope shape with `ok: true` and
|
|
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`
|
|
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`
|
|
32
|
-
|
|
33
|
-
|
|
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` →
|
|
44
|
-
|
|
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`.
|