beads-ui 0.1.1 → 0.2.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 (82) hide show
  1. package/CHANGES.md +27 -1
  2. package/README.md +39 -45
  3. package/app/data/providers.js +57 -26
  4. package/app/index.html +8 -0
  5. package/app/main.js +179 -33
  6. package/app/protocol.md +3 -4
  7. package/app/router.js +45 -9
  8. package/app/state.js +27 -11
  9. package/app/styles.css +170 -6
  10. package/app/utils/issue-id-renderer.js +71 -0
  11. package/app/utils/issue-url.js +9 -0
  12. package/app/utils/toast.js +35 -0
  13. package/app/views/board.js +347 -17
  14. package/app/views/detail.js +292 -92
  15. package/app/views/epics.js +2 -2
  16. package/app/views/issue-dialog.js +170 -0
  17. package/app/views/issue-row.js +9 -8
  18. package/app/views/list.js +85 -11
  19. package/app/views/new-issue-dialog.js +352 -0
  20. package/app/ws.js +30 -0
  21. package/docs/architecture.md +1 -1
  22. package/package.json +17 -1
  23. package/server/cli/commands.js +11 -3
  24. package/server/cli/index.js +35 -4
  25. package/server/cli/usage.js +1 -1
  26. package/server/watcher.js +3 -3
  27. package/server/ws.js +39 -19
  28. package/.beads/issues.jsonl +0 -107
  29. package/.editorconfig +0 -10
  30. package/.eslintrc.json +0 -36
  31. package/.github/workflows/ci.yml +0 -38
  32. package/.prettierignore +0 -5
  33. package/AGENTS.md +0 -85
  34. package/app/data/providers.test.js +0 -126
  35. package/app/main.board-switch.test.js +0 -94
  36. package/app/main.deep-link.test.js +0 -64
  37. package/app/main.live-updates.test.js +0 -229
  38. package/app/main.test.js +0 -17
  39. package/app/main.theme.test.js +0 -41
  40. package/app/main.view-sync.test.js +0 -54
  41. package/app/protocol.test.js +0 -57
  42. package/app/router.test.js +0 -34
  43. package/app/state.test.js +0 -21
  44. package/app/utils/markdown.test.js +0 -103
  45. package/app/utils/type-badge.test.js +0 -30
  46. package/app/views/board.test.js +0 -184
  47. package/app/views/detail.acceptance-notes.test.js +0 -67
  48. package/app/views/detail.assignee.test.js +0 -161
  49. package/app/views/detail.deps.test.js +0 -97
  50. package/app/views/detail.edits.test.js +0 -146
  51. package/app/views/detail.labels.test.js +0 -73
  52. package/app/views/detail.priority.test.js +0 -86
  53. package/app/views/detail.test.js +0 -188
  54. package/app/views/detail.ui47.test.js +0 -78
  55. package/app/views/epics.test.js +0 -283
  56. package/app/views/list.inline-edits.test.js +0 -84
  57. package/app/views/list.test.js +0 -479
  58. package/app/views/nav.test.js +0 -43
  59. package/app/ws.test.js +0 -168
  60. package/docs/quickstart.md +0 -142
  61. package/eslint.config.js +0 -59
  62. package/media/bdui-board.png +0 -0
  63. package/media/bdui-epics.png +0 -0
  64. package/media/bdui-issues.png +0 -0
  65. package/prettier.config.js +0 -13
  66. package/server/app.test.js +0 -29
  67. package/server/bd.test.js +0 -93
  68. package/server/cli/cli.test.js +0 -109
  69. package/server/cli/commands.integration.test.js +0 -155
  70. package/server/cli/commands.unit.test.js +0 -94
  71. package/server/cli/open.test.js +0 -26
  72. package/server/db.test.js +0 -70
  73. package/server/protocol.test.js +0 -87
  74. package/server/watcher.test.js +0 -100
  75. package/server/ws.handlers.test.js +0 -174
  76. package/server/ws.labels.test.js +0 -95
  77. package/server/ws.mutations.test.js +0 -261
  78. package/server/ws.subscriptions.test.js +0 -116
  79. package/server/ws.test.js +0 -52
  80. package/test/setup-vitest.js +0 -12
  81. package/tsconfig.json +0 -23
  82. package/vitest.config.mjs +0 -14
@@ -0,0 +1,352 @@
1
+ import { ISSUE_TYPES, typeLabel } from '../utils/issue-type.js';
2
+ import { priority_levels } from '../utils/priority.js';
3
+
4
+ /**
5
+ * Create and manage the New Issue dialog (native <dialog>).
6
+ * @param {HTMLElement} mount_element - Container to attach dialog (e.g., main#app)
7
+ * @param {(type: import('../protocol.js').MessageType, payload?: unknown) => Promise<unknown>} sendFn - Transport function
8
+ * @param {{ gotoIssue: (id: string) => void }} router - Router for opening details after create
9
+ * @param {{ setState: (patch: any) => void, getState: () => any }} [store]
10
+ * @returns {{ open: () => void, close: () => void }}
11
+ */
12
+ export function createNewIssueDialog(mount_element, sendFn, router, store) {
13
+ /** @type {HTMLDialogElement} */
14
+ const dialog = /** @type {any} */ (document.createElement('dialog'));
15
+ dialog.id = 'new-issue-dialog';
16
+ dialog.setAttribute('role', 'dialog');
17
+ dialog.setAttribute('aria-modal', 'true');
18
+
19
+ dialog.innerHTML = `
20
+ <div class="new-issue__container" part="container">
21
+ <header class="new-issue__header">
22
+ <div class="new-issue__title">New Issue</div>
23
+ <button type="button" class="new-issue__close" aria-label="Close">×</button>
24
+ </header>
25
+ <div class="new-issue__body">
26
+ <form id="new-issue-form" class="new-issue__form">
27
+ <label for="new-title">Title</label>
28
+ <input id="new-title" name="title" type="text" required placeholder="Short summary" />
29
+
30
+ <label for="new-type">Type</label>
31
+ <select id="new-type" name="type" aria-label="Issue type"></select>
32
+
33
+ <label for="new-priority">Priority</label>
34
+ <select id="new-priority" name="priority" aria-label="Priority"></select>
35
+
36
+ <label for="new-labels">Labels</label>
37
+ <input id="new-labels" name="labels" type="text" placeholder="comma,separated" />
38
+
39
+ <label for="new-description">Description</label>
40
+ <textarea id="new-description" name="description" rows="6" placeholder="Optional markdown description"></textarea>
41
+
42
+ <div aria-live="polite" role="status" class="new-issue__error" id="new-issue-error"></div>
43
+
44
+ <div class="new-issue__actions" style="grid-column: 1 / -1">
45
+ <button type="button" id="btn-cancel">Cancel (Esc)</button>
46
+ <button type="submit" id="btn-create">Create</button>
47
+ </div>
48
+ </form>
49
+ </div>
50
+ </div>
51
+ `;
52
+
53
+ mount_element.appendChild(dialog);
54
+
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} */ (
63
+ dialog.querySelector('#new-priority')
64
+ );
65
+ /** @type {HTMLInputElement} */
66
+ const input_labels = /** @type {any} */ (dialog.querySelector('#new-labels'));
67
+ /** @type {HTMLTextAreaElement} */
68
+ const input_description = /** @type {any} */ (
69
+ dialog.querySelector('#new-description')
70
+ );
71
+ /** @type {HTMLDivElement} */
72
+ const error_box = /** @type {any} */ (
73
+ dialog.querySelector('#new-issue-error')
74
+ );
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} */ (
81
+ dialog.querySelector('.new-issue__close')
82
+ );
83
+
84
+ // Populate selects
85
+ function populateSelects() {
86
+ sel_type.replaceChildren();
87
+ // Empty option to allow leaving type unspecified
88
+ const optEmpty = document.createElement('option');
89
+ optEmpty.value = '';
90
+ optEmpty.textContent = '— Select —';
91
+ sel_type.appendChild(optEmpty);
92
+ for (const t of ISSUE_TYPES) {
93
+ const o = document.createElement('option');
94
+ o.value = t;
95
+ o.textContent = typeLabel(t);
96
+ sel_type.appendChild(o);
97
+ }
98
+
99
+ sel_priority.replaceChildren();
100
+ for (let i = 0; i <= 4; i += 1) {
101
+ const o = document.createElement('option');
102
+ o.value = String(i);
103
+ const label = priority_levels[i] || 'Medium';
104
+ o.textContent = `${i} – ${label}`;
105
+ sel_priority.appendChild(o);
106
+ }
107
+ }
108
+ populateSelects();
109
+
110
+ function requestClose() {
111
+ try {
112
+ if (typeof dialog.close === 'function') {
113
+ dialog.close();
114
+ } else {
115
+ dialog.removeAttribute('open');
116
+ }
117
+ } catch {
118
+ dialog.removeAttribute('open');
119
+ }
120
+ }
121
+
122
+ /**
123
+ * @param {boolean} is_busy
124
+ */
125
+ function setBusy(is_busy) {
126
+ input_title.disabled = is_busy;
127
+ sel_type.disabled = is_busy;
128
+ sel_priority.disabled = is_busy;
129
+ input_labels.disabled = is_busy;
130
+ input_description.disabled = is_busy;
131
+ btn_cancel.disabled = is_busy;
132
+ btn_create.disabled = is_busy;
133
+ btn_create.textContent = is_busy ? 'Creating…' : 'Create';
134
+ }
135
+
136
+ function clearError() {
137
+ error_box.textContent = '';
138
+ }
139
+
140
+ /**
141
+ * @param {string} msg
142
+ */
143
+ function setError(msg) {
144
+ error_box.textContent = msg;
145
+ }
146
+
147
+ function loadDefaults() {
148
+ try {
149
+ const t = window.localStorage.getItem('beads-ui.new.type');
150
+ if (t) {
151
+ sel_type.value = t;
152
+ } else {
153
+ sel_type.value = '';
154
+ }
155
+ const p = window.localStorage.getItem('beads-ui.new.priority');
156
+ if (p && /^\d$/.test(p)) {
157
+ sel_priority.value = p;
158
+ } else {
159
+ sel_priority.value = '2';
160
+ }
161
+ } catch {
162
+ sel_type.value = '';
163
+ sel_priority.value = '2';
164
+ }
165
+ }
166
+
167
+ function saveDefaults() {
168
+ try {
169
+ const t = sel_type.value || '';
170
+ const p = sel_priority.value || '';
171
+ if (t.length > 0) {
172
+ window.localStorage.setItem('beads-ui.new.type', t);
173
+ }
174
+ if (p.length > 0) {
175
+ window.localStorage.setItem('beads-ui.new.priority', p);
176
+ }
177
+ } catch {
178
+ // ignore persistence errors
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Extract numeric suffix from an id like "UI-123"; return -1 when absent.
184
+ * @param {string} id
185
+ */
186
+ function idNumeric(id) {
187
+ const m = /-(\d+)$/.exec(String(id || ''));
188
+ return m && m[1] ? Number(m[1]) : -1;
189
+ }
190
+
191
+ /**
192
+ * Submit handler: validate, create, then open the created issue details.
193
+ * @returns {Promise<void>}
194
+ */
195
+ async function createNow() {
196
+ clearError();
197
+ /** @type {string} */
198
+ const title = String(input_title.value || '').trim();
199
+ if (title.length === 0) {
200
+ setError('Title is required');
201
+ input_title.focus();
202
+ return;
203
+ }
204
+ /** @type {number} */
205
+ const prio = Number(sel_priority.value || '2');
206
+ if (!(prio >= 0 && prio <= 4)) {
207
+ setError('Priority must be 0..4');
208
+ sel_priority.focus();
209
+ return;
210
+ }
211
+ /** @type {string} */
212
+ const type = String(sel_type.value || '');
213
+ /** @type {string} */
214
+ const desc = String(input_description.value || '');
215
+ /** @type {string[]} */
216
+ const labels = String(input_labels.value || '')
217
+ .split(',')
218
+ .map((s) => s.trim())
219
+ .filter((s) => s.length > 0);
220
+
221
+ /** @type {{ title: string, type?: string, priority?: number, description?: string }} */
222
+ const payload = { title };
223
+ if (type.length > 0) {
224
+ payload.type = type;
225
+ }
226
+ if (String(prio).length > 0) {
227
+ payload.priority = prio;
228
+ }
229
+ if (desc.length > 0) {
230
+ payload.description = desc;
231
+ }
232
+
233
+ setBusy(true);
234
+ try {
235
+ await sendFn('create-issue', payload);
236
+ } catch {
237
+ setBusy(false);
238
+ setError('Failed to create issue');
239
+ return;
240
+ }
241
+
242
+ saveDefaults();
243
+
244
+ // Best-effort: find the created id by matching title among open issues and picking the highest numeric id
245
+ /** @type {any} */
246
+ let list = null;
247
+ try {
248
+ list = await sendFn('list-issues', {
249
+ filters: { status: 'open', limit: 50 }
250
+ });
251
+ } catch {
252
+ list = null;
253
+ }
254
+ /** @type {string} */
255
+ let created_id = '';
256
+ if (Array.isArray(list)) {
257
+ /** @type {any[]} */
258
+ const matches = list.filter((it) => String(it.title || '') === title);
259
+ if (matches.length > 0) {
260
+ /** @type {any} */
261
+ let best = matches[0];
262
+ for (const it of matches) {
263
+ const ai = idNumeric(best.id || '');
264
+ const bi = idNumeric(it.id || '');
265
+ if (bi > ai) {
266
+ best = it;
267
+ }
268
+ }
269
+ created_id = String(best.id || '');
270
+ }
271
+ }
272
+
273
+ // Apply labels if any
274
+ if (created_id && labels.length > 0) {
275
+ for (const label of labels) {
276
+ try {
277
+ await sendFn('label-add', { id: created_id, label });
278
+ } catch {
279
+ // ignore label failures
280
+ }
281
+ }
282
+ }
283
+
284
+ // Navigate to created issue if found
285
+ if (created_id) {
286
+ try {
287
+ router.gotoIssue(created_id);
288
+ } catch {
289
+ // ignore routing errors
290
+ }
291
+ // Also set state directly to ensure dialog opens even if hash routing is suppressed in tests
292
+ try {
293
+ if (store) {
294
+ store.setState({ selected_id: created_id });
295
+ }
296
+ } catch {
297
+ // ignore
298
+ }
299
+ }
300
+
301
+ setBusy(false);
302
+ requestClose();
303
+ }
304
+
305
+ // Events
306
+ dialog.addEventListener('cancel', (ev) => {
307
+ ev.preventDefault();
308
+ requestClose();
309
+ });
310
+ btn_close.addEventListener('click', () => requestClose());
311
+ btn_cancel.addEventListener('click', () => requestClose());
312
+ dialog.addEventListener('keydown', (ev) => {
313
+ if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) {
314
+ ev.preventDefault();
315
+ void createNow();
316
+ }
317
+ });
318
+ form.addEventListener('submit', (ev) => {
319
+ ev.preventDefault();
320
+ void createNow();
321
+ });
322
+
323
+ return {
324
+ open() {
325
+ form.reset();
326
+ clearError();
327
+ loadDefaults();
328
+ try {
329
+ if (
330
+ 'showModal' in dialog &&
331
+ typeof (/** @type {any} */ (dialog).showModal) === 'function'
332
+ ) {
333
+ /** @type {any} */ (dialog).showModal();
334
+ } else {
335
+ dialog.setAttribute('open', '');
336
+ }
337
+ } catch {
338
+ dialog.setAttribute('open', '');
339
+ }
340
+ setTimeout(() => {
341
+ try {
342
+ input_title.focus();
343
+ } catch {
344
+ // ignore
345
+ }
346
+ }, 0);
347
+ },
348
+ close() {
349
+ requestClose();
350
+ }
351
+ };
352
+ }
package/app/ws.js CHANGED
@@ -73,12 +73,28 @@ export function createWsClient(options = {}) {
73
73
  const queue = [];
74
74
  /** @type {Map<string, Set<(payload: any) => void>>} */
75
75
  const handlers = new Map();
76
+ /** @type {Set<(s: ConnectionState) => void>} */
77
+ const connection_handlers = new Set();
78
+
79
+ /**
80
+ * @param {ConnectionState} s
81
+ */
82
+ function notifyConnection(s) {
83
+ for (const fn of Array.from(connection_handlers)) {
84
+ try {
85
+ fn(s);
86
+ } catch {
87
+ // ignore listener errors
88
+ }
89
+ }
90
+ }
76
91
 
77
92
  function scheduleReconnect() {
78
93
  if (!should_reconnect || reconnect_timer) {
79
94
  return;
80
95
  }
81
96
  state = 'reconnecting';
97
+ notifyConnection(state);
82
98
  const base = Math.min(
83
99
  backoff.maxMs || 0,
84
100
  (backoff.initialMs || 0) * Math.pow(backoff.factor || 1, attempts)
@@ -105,6 +121,7 @@ export function createWsClient(options = {}) {
105
121
 
106
122
  function onOpen() {
107
123
  state = 'open';
124
+ notifyConnection(state);
108
125
  attempts = 0;
109
126
  // subscribe first
110
127
  sendRaw(makeRequest('subscribe-updates', {}));
@@ -160,6 +177,7 @@ export function createWsClient(options = {}) {
160
177
 
161
178
  function onClose() {
162
179
  state = 'closed';
180
+ notifyConnection(state);
163
181
  // fail all pending
164
182
  for (const [id, p] of pending.entries()) {
165
183
  p.reject(new Error('ws disconnected'));
@@ -177,6 +195,7 @@ export function createWsClient(options = {}) {
177
195
  try {
178
196
  ws = /** @type {any} */ (new WebSocket(url));
179
197
  state = 'connecting';
198
+ notifyConnection(state);
180
199
  const s = /** @type {any} */ (ws);
181
200
  s.addEventListener('open', onOpen);
182
201
  s.addEventListener('message', onMessage);
@@ -231,6 +250,17 @@ export function createWsClient(options = {}) {
231
250
  set?.delete(handler);
232
251
  };
233
252
  },
253
+ /**
254
+ * Subscribe to connection state changes.
255
+ * @param {(state: ConnectionState) => void} handler
256
+ * @returns {() => void}
257
+ */
258
+ onConnection(handler) {
259
+ connection_handlers.add(handler);
260
+ return () => {
261
+ connection_handlers.delete(handler);
262
+ };
263
+ },
234
264
  /** Close and stop reconnecting. */
235
265
  close() {
236
266
  should_reconnect = false;
@@ -147,7 +147,7 @@ Error reply
147
147
  - Update status: `bd update <id> --status <open|in_progress|closed>`
148
148
  - Update priority: `bd update <id> --priority <0..4>`
149
149
  - Edit title: `bd update <id> --title <text>`
150
- - Edit description: not supported (immutable after create)
150
+ - Edit description: `bd update <id> --description <text>`
151
151
  - Edit acceptance: `bd update <id> --acceptance-criteria <text>`
152
152
  - Link dependency: `bd dep add <a> <b>` (a depends on b)
153
153
  - Unlink dependency: `bd dep remove <a> <b>`
package/package.json CHANGED
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "name": "beads-ui",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
+ "description": "Local‑first UI for Beads — a fast issue tracker for your coding agent.",
5
+ "homepage": "https://github.com/mantoni/beads-ui",
4
6
  "type": "module",
5
7
  "bin": {
6
8
  "bdui": "bin/bdui.js"
@@ -44,5 +46,19 @@
44
46
  "express": "^5.1.0",
45
47
  "lit-html": "^3.3.1",
46
48
  "ws": "^8.18.3"
49
+ },
50
+ "files": [
51
+ "app",
52
+ "bin",
53
+ "docs",
54
+ "server",
55
+ "CHANGES.md",
56
+ "LICENSE",
57
+ "README.md",
58
+ "!**/*.test.js"
59
+ ],
60
+ "repository": {
61
+ "type": "git",
62
+ "url": "https://github.com/mantoni/beads-ui.git"
47
63
  }
48
64
  }
@@ -19,7 +19,8 @@ import { openUrl, waitForServer } from './open.js';
19
19
  * @param {{ no_open?: boolean }} [options]
20
20
  */
21
21
  export async function handleStart(options) {
22
- const no_open = options?.no_open === true;
22
+ // Default behavior: do not open a browser unless explicitly requested.
23
+ const no_open = options?.no_open !== false;
23
24
  const existing_pid = readPidFile();
24
25
  if (existing_pid && isProcessRunning(existing_pid)) {
25
26
  printServerUrl();
@@ -80,12 +81,19 @@ export async function handleStop() {
80
81
  * Handle `restart` command: stop (ignore not-running) then start.
81
82
  * @returns {Promise<number>} Exit code (0 on success)
82
83
  */
83
- export async function handleRestart() {
84
+ /**
85
+ * Handle `restart` command: stop (ignore not-running) then start.
86
+ * Accepts the same options as `handleStart` and passes them through,
87
+ * so restart only opens a browser when `no_open` is explicitly false.
88
+ * @param {{ no_open?: boolean }} [options]
89
+ * @returns {Promise<number>}
90
+ */
91
+ export async function handleRestart(options) {
84
92
  const stop_code = await handleStop();
85
93
  // 0 = stopped, 2 = not running; both are acceptable to proceed
86
94
  if (stop_code !== 0 && stop_code !== 2) {
87
95
  return 1;
88
96
  }
89
- const start_code = await handleStart();
97
+ const start_code = await handleStart(options);
90
98
  return start_code === 0 ? 0 : 1;
91
99
  }
@@ -17,6 +17,10 @@ export function parseArgs(args) {
17
17
  flags.push('help');
18
18
  continue;
19
19
  }
20
+ if (token === '--open') {
21
+ flags.push('open');
22
+ continue;
23
+ }
20
24
  if (token === '--no-open') {
21
25
  flags.push('no-open');
22
26
  continue;
@@ -53,19 +57,46 @@ export async function main(args) {
53
57
  }
54
58
 
55
59
  if (command === 'start') {
60
+ /**
61
+ * Default behavior: do NOT open a browser.
62
+ * `--open` explicitly opens, overriding env/config; `--no-open` forces closed.
63
+ */
56
64
  /** @type {{ no_open: boolean }} */
57
65
  const options = {
58
- no_open:
59
- flags.includes('no-open') ||
60
- String(process.env.BDUI_NO_OPEN || '') === '1'
66
+ no_open: true
61
67
  };
68
+
69
+ const has_open = flags.includes('open');
70
+ const has_no_open = flags.includes('no-open');
71
+ const env_no_open = String(process.env.BDUI_NO_OPEN || '') === '1';
72
+
73
+ if (has_open) {
74
+ options.no_open = false;
75
+ } else if (has_no_open) {
76
+ options.no_open = true;
77
+ } else if (env_no_open) {
78
+ options.no_open = true;
79
+ }
62
80
  return await handleStart(options);
63
81
  }
64
82
  if (command === 'stop') {
65
83
  return await handleStop();
66
84
  }
67
85
  if (command === 'restart') {
68
- return await handleRestart();
86
+ /** @type {{ no_open: boolean }} */
87
+ const options = { no_open: true };
88
+ const has_open = flags.includes('open');
89
+ const has_no_open = flags.includes('no-open');
90
+ const env_no_open = String(process.env.BDUI_NO_OPEN || '') === '1';
91
+
92
+ if (has_open) {
93
+ options.no_open = false;
94
+ } else if (has_no_open) {
95
+ options.no_open = true;
96
+ } else if (env_no_open) {
97
+ options.no_open = true;
98
+ }
99
+ return await handleRestart(options);
69
100
  }
70
101
 
71
102
  // Unknown command path (should not happen due to parseArgs guard)
@@ -13,7 +13,7 @@ export function printUsage(out_stream) {
13
13
  '',
14
14
  'Options:',
15
15
  ' -h, --help Show this help message',
16
- ' --no-open Do not open the browser on start',
16
+ ' --open Open the browser after start/restart',
17
17
  ''
18
18
  ];
19
19
  for (const line of lines) {
package/server/watcher.js CHANGED
@@ -6,11 +6,11 @@ import { resolveDbPath } from './db.js';
6
6
  * Watch the resolved beads SQLite DB file and invoke a callback after a debounce window.
7
7
  * The DB path is resolved following beads precedence and can be overridden via options.
8
8
  * @param {string} root_dir - Project root directory (starting point for resolution).
9
- * @param {(payload: { ts: number }) => void} on_change - Called when changes are detected.
9
+ * @param {(payload: { ts: number }) => void} onChange - Called when changes are detected.
10
10
  * @param {{ debounce_ms?: number, explicit_db?: string }} [options]
11
11
  * @returns {{ close: () => void, rebind: (opts?: { root_dir?: string, explicit_db?: string }) => void, path: string }}
12
12
  */
13
- export function watchDb(root_dir, on_change, options = {}) {
13
+ export function watchDb(root_dir, onChange, options = {}) {
14
14
  const debounce_ms = options.debounce_ms ?? 250;
15
15
 
16
16
  /** @type {ReturnType<typeof setTimeout> | undefined} */
@@ -29,7 +29,7 @@ export function watchDb(root_dir, on_change, options = {}) {
29
29
  clearTimeout(timer);
30
30
  }
31
31
  timer = setTimeout(() => {
32
- on_change({ ts: Date.now() });
32
+ onChange({ ts: Date.now() });
33
33
  }, debounce_ms);
34
34
  timer.unref?.();
35
35
  };
package/server/ws.js CHANGED
@@ -10,7 +10,7 @@ import { isRequest, makeError, makeOk } from './protocol.js';
10
10
  /**
11
11
  * @typedef {{
12
12
  * subscribed: boolean,
13
- * list_filters?: { status?: 'open'|'in_progress'|'closed', ready?: boolean, limit?: number },
13
+ * list_filters?: { status?: 'open'|'in_progress'|'closed', ready?: boolean, blocked?: boolean, limit?: number },
14
14
  * show_id?: string | null
15
15
  * }} ConnectionSubs
16
16
  */
@@ -74,8 +74,8 @@ export function notifyIssuesChanged(payload, options = {}) {
74
74
  continue;
75
75
  }
76
76
  if (s.list_filters) {
77
- // Ready lists are conservatively invalidated on any change
78
- if (s.list_filters.ready === true) {
77
+ // Ready/Blocked lists are conservatively invalidated on any change
78
+ if (s.list_filters.ready === true || s.list_filters.blocked === true) {
79
79
  recipients.add(ws);
80
80
  continue;
81
81
  }
@@ -268,6 +268,25 @@ export async function handleMessage(ws, data) {
268
268
  return;
269
269
  }
270
270
 
271
+ // When "blocked" is requested, use the dedicated bd subcommand
272
+ if (filters && typeof filters === 'object' && filters.blocked === true) {
273
+ const res = await runBdJson(['blocked', '--json']);
274
+ if (res.code !== 0) {
275
+ const err = makeError(req, 'bd_error', res.stderr || 'bd failed');
276
+ ws.send(JSON.stringify(err));
277
+ return;
278
+ }
279
+ // Remember subscription scope for this connection
280
+ try {
281
+ const s = getSubs(ws);
282
+ s.list_filters = { blocked: true };
283
+ } catch {
284
+ // ignore tracking errors
285
+ }
286
+ ws.send(JSON.stringify(makeOk(req, res.stdoutJson)));
287
+ return;
288
+ }
289
+
271
290
  /** @type {string[]} */
272
291
  const args = ['list', '--json'];
273
292
  if (filters && typeof filters === 'object') {
@@ -498,7 +517,9 @@ export async function handleMessage(ws, data) {
498
517
  id.length === 0 ||
499
518
  (field !== 'title' &&
500
519
  field !== 'description' &&
501
- field !== 'acceptance') ||
520
+ field !== 'acceptance' &&
521
+ field !== 'notes' &&
522
+ field !== 'design') ||
502
523
  typeof value !== 'string'
503
524
  ) {
504
525
  ws.send(
@@ -506,20 +527,7 @@ export async function handleMessage(ws, data) {
506
527
  makeError(
507
528
  req,
508
529
  'bad_request',
509
- "payload requires { id: string, field: 'title'|'description'|'acceptance', value: string }"
510
- )
511
- )
512
- );
513
- return;
514
- }
515
- // Description updates are currently not supported by bd
516
- if (field === 'description') {
517
- ws.send(
518
- JSON.stringify(
519
- makeError(
520
- req,
521
- 'bd_error',
522
- 'editing description is not supported by bd'
530
+ "payload requires { id: string, field: 'title'|'description'|'acceptance'|'notes'|'design', value: string }"
523
531
  )
524
532
  )
525
533
  );
@@ -527,8 +535,20 @@ export async function handleMessage(ws, data) {
527
535
  }
528
536
  // Map UI fields to bd CLI flags
529
537
  // title → --title
538
+ // description → --description
530
539
  // acceptance → --acceptance-criteria
531
- const flag = field === 'title' ? '--title' : '--acceptance-criteria';
540
+ // notes → --notes
541
+ // design → --design
542
+ const flag =
543
+ field === 'title'
544
+ ? '--title'
545
+ : field === 'description'
546
+ ? '--description'
547
+ : field === 'acceptance'
548
+ ? '--acceptance-criteria'
549
+ : field === 'notes'
550
+ ? '--notes'
551
+ : '--design';
532
552
  const res = await runBd(['update', id, flag, value]);
533
553
  if (res.code !== 0) {
534
554
  ws.send(