beads-ui 0.1.2 → 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 (54) hide show
  1. package/CHANGES.md +29 -2
  2. package/README.md +39 -45
  3. package/app/data/list-selectors.js +98 -0
  4. package/app/data/providers.js +25 -127
  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/index.html +8 -0
  10. package/app/main.js +483 -61
  11. package/app/protocol.js +10 -14
  12. package/app/protocol.md +21 -19
  13. package/app/router.js +45 -9
  14. package/app/state.js +27 -11
  15. package/app/styles.css +373 -184
  16. package/app/utils/issue-id-renderer.js +71 -0
  17. package/app/utils/issue-url.js +9 -0
  18. package/app/utils/markdown.js +15 -194
  19. package/app/utils/priority-badge.js +0 -2
  20. package/app/utils/status-badge.js +0 -1
  21. package/app/utils/toast.js +34 -0
  22. package/app/utils/type-badge.js +0 -3
  23. package/app/views/board.js +439 -87
  24. package/app/views/detail.js +364 -154
  25. package/app/views/epics.js +128 -76
  26. package/app/views/issue-dialog.js +163 -0
  27. package/app/views/issue-row.js +10 -11
  28. package/app/views/list.js +164 -93
  29. package/app/views/new-issue-dialog.js +345 -0
  30. package/app/ws.js +36 -9
  31. package/bin/bdui.js +1 -1
  32. package/docs/adr/001-push-only-lists.md +134 -0
  33. package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
  34. package/docs/architecture.md +35 -85
  35. package/docs/data-exchange-subscription-plan.md +198 -0
  36. package/docs/db-watching.md +2 -1
  37. package/docs/migration-v2.md +54 -0
  38. package/docs/protocol/issues-push-v2.md +179 -0
  39. package/docs/subscription-issue-store.md +112 -0
  40. package/package.json +11 -3
  41. package/server/bd.js +0 -2
  42. package/server/cli/commands.js +12 -5
  43. package/server/cli/daemon.js +12 -5
  44. package/server/cli/index.js +34 -5
  45. package/server/cli/usage.js +2 -2
  46. package/server/config.js +12 -6
  47. package/server/db.js +0 -1
  48. package/server/index.js +9 -5
  49. package/server/list-adapters.js +218 -0
  50. package/server/subscriptions.js +277 -0
  51. package/server/validators.js +111 -0
  52. package/server/watcher.js +6 -9
  53. package/server/ws.js +466 -227
  54. package/docs/quickstart.md +0 -142
package/app/main.js CHANGED
@@ -1,12 +1,21 @@
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';
3
- import { createHashRouter } from './router.js';
7
+ import { createSubscriptionIssueStores } from './data/subscription-issue-stores.js';
8
+ import { createSubscriptionStore } from './data/subscriptions-store.js';
9
+ import { createHashRouter, parseHash } from './router.js';
4
10
  import { createStore } from './state.js';
11
+ import { showToast } from './utils/toast.js';
5
12
  import { createBoardView } from './views/board.js';
6
13
  import { createDetailView } from './views/detail.js';
7
14
  import { createEpicsView } from './views/epics.js';
15
+ import { createIssueDialog } from './views/issue-dialog.js';
8
16
  import { createListView } from './views/list.js';
9
17
  import { createTopNav } from './views/nav.js';
18
+ import { createNewIssueDialog } from './views/new-issue-dialog.js';
10
19
  import { createWsClient } from './ws.js';
11
20
 
12
21
  /**
@@ -40,22 +49,81 @@ export function bootstrap(root_element) {
40
49
  const detail_mount = document.getElementById('detail-panel');
41
50
  if (list_mount && issues_root && epics_root && board_root && detail_mount) {
42
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);
97
+ // Show toasts for WebSocket connectivity changes
98
+ /** @type {boolean} */
99
+ let had_disconnect = false;
100
+ if (typeof client.onConnection === 'function') {
101
+ /** @type {(s: 'connecting'|'open'|'closed'|'reconnecting') => void} */
102
+ const onConn = (s) => {
103
+ if (s === 'reconnecting' || s === 'closed') {
104
+ had_disconnect = true;
105
+ showToast('Connection lost. Reconnecting…', 'error', 4000);
106
+ } else if (s === 'open' && had_disconnect) {
107
+ had_disconnect = false;
108
+ showToast('Reconnected', 'success', 2200);
109
+ }
110
+ };
111
+ client.onConnection(onConn);
112
+ }
43
113
  // Load persisted filters (status/search/type) from localStorage
44
114
  /** @type {{ status: 'all'|'open'|'in_progress'|'closed'|'ready', search: string, type: string }} */
45
- let persistedFilters = { status: 'all', search: '', type: '' };
115
+ let persisted_filters = { status: 'all', search: '', type: '' };
46
116
  try {
47
117
  const raw = window.localStorage.getItem('beads-ui.filters');
48
118
  if (raw) {
49
119
  const obj = JSON.parse(raw);
50
120
  if (obj && typeof obj === 'object') {
51
121
  const ALLOWED = ['bug', 'feature', 'task', 'epic', 'chore'];
52
- /** @type {string} */
53
122
  let parsed_type = '';
54
123
  if (typeof obj.type === 'string' && ALLOWED.includes(obj.type)) {
55
124
  parsed_type = obj.type;
56
125
  } else if (Array.isArray(obj.types)) {
57
126
  // Backwards compatibility: pick first valid from previous array format
58
- /** @type {string} */
59
127
  let first_valid = '';
60
128
  for (const it of obj.types) {
61
129
  if (ALLOWED.includes(String(it))) {
@@ -65,7 +133,7 @@ export function bootstrap(root_element) {
65
133
  }
66
134
  parsed_type = first_valid;
67
135
  }
68
- persistedFilters = {
136
+ persisted_filters = {
69
137
  status: ['all', 'open', 'in_progress', 'closed', 'ready'].includes(
70
138
  obj.status
71
139
  )
@@ -94,7 +162,29 @@ export function bootstrap(root_element) {
94
162
  } catch {
95
163
  // ignore
96
164
  }
97
- const store = createStore({ filters: persistedFilters, view: last_view });
165
+ // Load board preferences
166
+ /** @type {{ closed_filter: 'today'|'3'|'7' }} */
167
+ let persistedBoard = { closed_filter: 'today' };
168
+ try {
169
+ const raw_board = window.localStorage.getItem('beads-ui.board');
170
+ if (raw_board) {
171
+ const obj = JSON.parse(raw_board);
172
+ if (obj && typeof obj === 'object') {
173
+ const cf = String(obj.closed_filter || 'today');
174
+ if (cf === 'today' || cf === '3' || cf === '7') {
175
+ persistedBoard.closed_filter = cf;
176
+ }
177
+ }
178
+ }
179
+ } catch {
180
+ // ignore parse errors
181
+ }
182
+
183
+ const store = createStore({
184
+ filters: persisted_filters,
185
+ view: last_view,
186
+ board: persistedBoard
187
+ });
98
188
  const router = createHashRouter(store);
99
189
  router.start();
100
190
  /**
@@ -103,7 +193,7 @@ export function bootstrap(root_element) {
103
193
  */
104
194
  const transport = async (type, payload) => {
105
195
  try {
106
- return await client.send(/** @type {any} */ (type), payload);
196
+ return await client.send(/** @type {MessageType} */ (type), payload);
107
197
  } catch {
108
198
  return [];
109
199
  }
@@ -113,16 +203,54 @@ export function bootstrap(root_element) {
113
203
  createTopNav(nav_mount, store, router);
114
204
  }
115
205
 
206
+ // Global New Issue dialog (UI-106) mounted at root so it is always visible
207
+ const new_issue_dialog = createNewIssueDialog(
208
+ root_element,
209
+ (type, payload) => client.send(type, payload),
210
+ router,
211
+ store
212
+ );
213
+ // Header button
214
+ try {
215
+ const btn_new = /** @type {HTMLButtonElement|null} */ (
216
+ document.getElementById('new-issue-btn')
217
+ );
218
+ if (btn_new) {
219
+ btn_new.addEventListener('click', () => new_issue_dialog.open());
220
+ }
221
+ } catch {
222
+ // ignore missing header
223
+ }
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
+
116
242
  const issues_view = createListView(
117
243
  list_mount,
118
- transport,
244
+ /** @type {any} */ (listTransport),
119
245
  (hash) => {
120
- const id = hash.replace('#/issue/', '');
246
+ const id = parseHash(hash);
121
247
  if (id) {
122
248
  router.gotoIssue(id);
123
249
  }
124
250
  },
125
- store
251
+ store,
252
+ subscriptions,
253
+ sub_issue_stores
126
254
  );
127
255
  // Persist filter changes to localStorage
128
256
  store.subscribe((s) => {
@@ -137,85 +265,354 @@ export function bootstrap(root_element) {
137
265
  // ignore
138
266
  }
139
267
  });
268
+ // Persist board preferences
269
+ store.subscribe((s) => {
270
+ try {
271
+ window.localStorage.setItem(
272
+ 'beads-ui.board',
273
+ JSON.stringify({ closed_filter: s.board.closed_filter })
274
+ );
275
+ } catch {
276
+ // ignore
277
+ }
278
+ });
140
279
  void issues_view.load();
141
- const detail = createDetailView(detail_mount, transport, (hash) => {
142
- const id = hash.replace('#/issue/', '');
143
- if (id) {
144
- router.gotoIssue(id);
280
+
281
+ // Dialog for issue details (UI-104)
282
+ const dialog = createIssueDialog(detail_mount, store, () => {
283
+ // Close: clear selection and return to current view
284
+ const s = store.getState();
285
+ store.setState({ selected_id: null });
286
+ try {
287
+ /** @type {'issues'|'epics'|'board'} */
288
+ const v = s.view || 'issues';
289
+ router.gotoView(v);
290
+ } catch {
291
+ // ignore
145
292
  }
146
293
  });
147
294
 
148
- // React to selectedId changes -> show detail page full-width
295
+ /** @type {ReturnType<typeof createDetailView> | null} */
296
+ let detail = null;
297
+ // Mount details into the dialog body only
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
+ );
309
+
310
+ // If router already set a selected id (deep-link), open dialog now
311
+ const initial_id = store.getState().selected_id;
312
+ if (initial_id) {
313
+ detail_mount.hidden = false;
314
+ dialog.open(initial_id);
315
+ if (detail) {
316
+ void detail.load(initial_id);
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(() => {});
328
+ }
329
+
330
+ // Open/close dialog based on selected_id (always dialog; no page variant)
331
+ /** @type {null | (() => Promise<void>)} */
332
+ let unsub_detail = null;
149
333
  store.subscribe((s) => {
150
334
  const id = s.selected_id;
151
335
  if (id) {
152
- void detail.load(id);
336
+ detail_mount.hidden = false;
337
+ dialog.open(id);
338
+ if (detail) {
339
+ void detail.load(id);
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(() => {});
153
361
  } else {
154
- detail.clear();
362
+ try {
363
+ dialog.close();
364
+ } catch {
365
+ // ignore
366
+ }
367
+ if (detail) {
368
+ detail.clear();
369
+ }
370
+ detail_mount.hidden = true;
371
+ if (unsub_detail) {
372
+ void unsub_detail().catch(() => {});
373
+ unsub_detail = null;
374
+ }
155
375
  }
156
376
  });
157
377
 
158
- // Initial deep-link: if router set a selectedId before subscription, load it now
159
- const initialId = store.getState().selected_id;
160
- if (initialId) {
161
- void detail.load(initialId);
162
- } else {
163
- detail.clear();
164
- }
378
+ // Removed: issues-changed handling. All views re-render from
379
+ // per-subscription stores which are updated by snapshot/upsert/delete.
165
380
 
166
- // Refresh views on push updates (target minimally and avoid flicker)
167
- client.on('issues-changed', (payload) => {
168
- const s = store.getState();
169
- const hintIds =
170
- payload && payload.hint && Array.isArray(payload.hint.ids)
171
- ? /** @type {string[]} */ (payload.hint.ids)
172
- : null;
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' };
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
+ }
173
435
 
174
- const showingDetail = 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
+ }
175
475
 
176
- // If a top-level view is visible (and not detail), refresh that view
177
- if (!showingDetail) {
178
- if (s.view === 'issues') {
179
- void issues_view.load();
180
- } else if (s.view === 'epics') {
181
- void epics_view.load();
182
- } else if (s.view === 'board') {
183
- 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
184
497
  }
185
498
  }
186
499
 
187
- // If a detail is visible, re-fetch it when relevant or when hints are absent
188
- if (showingDetail && s.selected_id) {
189
- if (!hintIds || hintIds.includes(s.selected_id)) {
190
- 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
592
+ }
191
593
  }
192
594
  }
193
- });
595
+ }
194
596
 
195
- // Toggle route shells on view/detail change and persist
196
- const data = createDataLayer(/** @type {any} */ (transport), client.on);
197
- const epics_view = createEpicsView(epics_root, data, (id) =>
198
- router.gotoIssue(id)
199
- );
200
- const board_view = createBoardView(board_root, data, (id) =>
201
- router.gotoIssue(id)
202
- );
203
- // Preload epics when switching to view
204
597
  /**
598
+ * Manage route visibility and list subscriptions per view.
205
599
  * @param {{ selected_id: string | null, view: 'issues'|'epics'|'board', filters: any }} s
206
600
  */
207
601
  const onRouteChange = (s) => {
208
- const showDetail = Boolean(s.selected_id);
209
602
  if (issues_root && epics_root && board_root && detail_mount) {
210
- issues_root.hidden = showDetail || s.view !== 'issues';
211
- epics_root.hidden = showDetail || s.view !== 'epics';
212
- board_root.hidden = showDetail || s.view !== 'board';
213
- detail_mount.hidden = !showDetail;
603
+ // Underlying route visibility is controlled only by selected view
604
+ issues_root.hidden = s.view !== 'issues';
605
+ epics_root.hidden = s.view !== 'epics';
606
+ board_root.hidden = s.view !== 'board';
607
+ // detail_mount visibility handled in subscription above
214
608
  }
215
- if (!showDetail && s.view === 'epics') {
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);
612
+ if (!s.selected_id && s.view === 'epics') {
216
613
  void epics_view.load();
217
614
  }
218
- if (!showDetail && s.view === 'board') {
615
+ if (!s.selected_id && s.view === 'board') {
219
616
  void board_view.load();
220
617
  }
221
618
  try {
@@ -227,6 +624,31 @@ export function bootstrap(root_element) {
227
624
  store.subscribe(onRouteChange);
228
625
  // Ensure initial state is reflected (fixes reload on #/epics)
229
626
  onRouteChange(store.getState());
627
+
628
+ // Removed redundant filter-change subscription: handled by ensureTabSubscriptions
629
+
630
+ // Keyboard shortcuts: Ctrl/Cmd+N opens new issue; Ctrl/Cmd+Enter submits inside dialog
631
+ window.addEventListener('keydown', (ev) => {
632
+ const is_modifier = ev.ctrlKey || ev.metaKey;
633
+ const key = String(ev.key || '').toLowerCase();
634
+ const target = /** @type {HTMLElement} */ (ev.target);
635
+ const tag =
636
+ target && target.tagName ? String(target.tagName).toLowerCase() : '';
637
+ const is_editable =
638
+ tag === 'input' ||
639
+ tag === 'textarea' ||
640
+ tag === 'select' ||
641
+ (target &&
642
+ typeof target.isContentEditable === 'boolean' &&
643
+ target.isContentEditable);
644
+ if (is_modifier && key === 'n') {
645
+ // Do not hijack when typing in inputs; common UX
646
+ if (!is_editable) {
647
+ ev.preventDefault();
648
+ new_issue_dialog.open();
649
+ }
650
+ }
651
+ });
230
652
  }
231
653
  }
232
654