beads-ui 0.1.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 (98) hide show
  1. package/.beads/issues.jsonl +107 -0
  2. package/.editorconfig +10 -0
  3. package/.eslintrc.json +36 -0
  4. package/.github/workflows/ci.yml +38 -0
  5. package/.prettierignore +5 -0
  6. package/AGENTS.md +85 -0
  7. package/CHANGES.md +5 -0
  8. package/LICENSE +22 -0
  9. package/README.md +75 -0
  10. package/app/data/providers.js +178 -0
  11. package/app/data/providers.test.js +126 -0
  12. package/app/index.html +29 -0
  13. package/app/main.board-switch.test.js +94 -0
  14. package/app/main.deep-link.test.js +64 -0
  15. package/app/main.js +280 -0
  16. package/app/main.live-updates.test.js +229 -0
  17. package/app/main.test.js +17 -0
  18. package/app/main.theme.test.js +41 -0
  19. package/app/main.view-sync.test.js +54 -0
  20. package/app/protocol.js +200 -0
  21. package/app/protocol.md +64 -0
  22. package/app/protocol.test.js +57 -0
  23. package/app/router.js +78 -0
  24. package/app/router.test.js +34 -0
  25. package/app/state.js +87 -0
  26. package/app/state.test.js +21 -0
  27. package/app/styles.css +1343 -0
  28. package/app/utils/issue-id.js +10 -0
  29. package/app/utils/issue-type.js +27 -0
  30. package/app/utils/markdown.js +201 -0
  31. package/app/utils/markdown.test.js +103 -0
  32. package/app/utils/priority-badge.js +49 -0
  33. package/app/utils/priority.js +1 -0
  34. package/app/utils/status-badge.js +33 -0
  35. package/app/utils/status.js +23 -0
  36. package/app/utils/type-badge.js +36 -0
  37. package/app/utils/type-badge.test.js +30 -0
  38. package/app/views/board.js +183 -0
  39. package/app/views/board.test.js +184 -0
  40. package/app/views/detail.acceptance-notes.test.js +67 -0
  41. package/app/views/detail.assignee.test.js +161 -0
  42. package/app/views/detail.deps.test.js +97 -0
  43. package/app/views/detail.edits.test.js +146 -0
  44. package/app/views/detail.js +1039 -0
  45. package/app/views/detail.labels.test.js +73 -0
  46. package/app/views/detail.priority.test.js +86 -0
  47. package/app/views/detail.test.js +188 -0
  48. package/app/views/detail.ui47.test.js +78 -0
  49. package/app/views/epics.js +228 -0
  50. package/app/views/epics.test.js +283 -0
  51. package/app/views/issue-row.js +191 -0
  52. package/app/views/list.inline-edits.test.js +84 -0
  53. package/app/views/list.js +393 -0
  54. package/app/views/list.test.js +479 -0
  55. package/app/views/nav.js +67 -0
  56. package/app/views/nav.test.js +43 -0
  57. package/app/ws.js +252 -0
  58. package/app/ws.test.js +168 -0
  59. package/bin/bdui.js +18 -0
  60. package/docs/architecture.md +244 -0
  61. package/docs/db-watching.md +29 -0
  62. package/docs/quickstart.md +142 -0
  63. package/eslint.config.js +59 -0
  64. package/media/bdui-board.png +0 -0
  65. package/media/bdui-epics.png +0 -0
  66. package/media/bdui-issues.png +0 -0
  67. package/package.json +48 -0
  68. package/prettier.config.js +13 -0
  69. package/server/app.js +80 -0
  70. package/server/app.test.js +29 -0
  71. package/server/bd.js +125 -0
  72. package/server/bd.test.js +93 -0
  73. package/server/cli/cli.test.js +109 -0
  74. package/server/cli/commands.integration.test.js +155 -0
  75. package/server/cli/commands.js +91 -0
  76. package/server/cli/commands.unit.test.js +94 -0
  77. package/server/cli/daemon.js +239 -0
  78. package/server/cli/index.js +74 -0
  79. package/server/cli/open.js +96 -0
  80. package/server/cli/open.test.js +26 -0
  81. package/server/cli/usage.js +22 -0
  82. package/server/config.js +29 -0
  83. package/server/db.js +100 -0
  84. package/server/db.test.js +70 -0
  85. package/server/index.js +29 -0
  86. package/server/protocol.js +3 -0
  87. package/server/protocol.test.js +87 -0
  88. package/server/watcher.js +107 -0
  89. package/server/watcher.test.js +100 -0
  90. package/server/ws.handlers.test.js +174 -0
  91. package/server/ws.js +784 -0
  92. package/server/ws.labels.test.js +95 -0
  93. package/server/ws.mutations.test.js +261 -0
  94. package/server/ws.subscriptions.test.js +116 -0
  95. package/server/ws.test.js +52 -0
  96. package/test/setup-vitest.js +12 -0
  97. package/tsconfig.json +23 -0
  98. package/vitest.config.mjs +14 -0
@@ -0,0 +1,1039 @@
1
+ // Issue Detail view implementation (lit-html based)
2
+ import { html, render } from 'lit-html';
3
+ import { issueDisplayId } from '../utils/issue-id.js';
4
+ import { renderMarkdown } from '../utils/markdown.js';
5
+ import { emojiForPriority } from '../utils/priority-badge.js';
6
+ import { priority_levels } from '../utils/priority.js';
7
+ import { statusLabel } from '../utils/status.js';
8
+ import { createTypeBadge } from '../utils/type-badge.js';
9
+
10
+ /**
11
+ * @typedef {Object} Dependency
12
+ * @property {string} id
13
+ * @property {string} [title]
14
+ * @property {string} [status]
15
+ * @property {number} [priority]
16
+ * @property {string} [issue_type]
17
+ */
18
+
19
+ /**
20
+ * @typedef {Object} IssueDetail
21
+ * @property {string} id
22
+ * @property {string} [title]
23
+ * @property {string} [description]
24
+ * @property {string} [acceptance]
25
+ * @property {string} [notes]
26
+ * @property {string} [status]
27
+ * @property {number} [priority]
28
+ * @property {string[]} [labels]
29
+ * @property {Dependency[]} [dependencies]
30
+ * @property {Dependency[]} [dependents]
31
+ */
32
+
33
+ /**
34
+ * @param {string} hash
35
+ */
36
+ function defaultNavigateFn(hash) {
37
+ window.location.hash = hash;
38
+ }
39
+
40
+ /**
41
+ * Create the Issue Detail view.
42
+ * @param {HTMLElement} mount_element - Element to render into.
43
+ * @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn - RPC transport.
44
+ * @param {(hash: string) => void} [navigateFn] - Navigation function; defaults to setting location.hash.
45
+ * @returns {{ load: (id: string) => Promise<void>, clear: () => void, destroy: () => void }} View API.
46
+ */
47
+ export function createDetailView(
48
+ mount_element,
49
+ sendFn,
50
+ navigateFn = defaultNavigateFn
51
+ ) {
52
+ /** @type {IssueDetail | null} */
53
+ let current = null;
54
+ /** @type {boolean} */
55
+ let pending = false;
56
+ /** @type {boolean} */
57
+ let edit_title = false;
58
+ /** @type {boolean} */
59
+ let edit_desc = false;
60
+ /** @type {boolean} */
61
+ let edit_accept = false;
62
+ /** @type {boolean} */
63
+ let edit_assignee = false;
64
+ /** @type {string} */
65
+ let new_label_text = '';
66
+
67
+ /**
68
+ * Show a transient toast message.
69
+ * @param {string} text
70
+ */
71
+ function showToast(text) {
72
+ /** @type {HTMLDivElement} */
73
+ const toast = document.createElement('div');
74
+ toast.className = 'toast';
75
+ toast.textContent = text;
76
+ toast.style.position = 'absolute';
77
+ toast.style.right = '12px';
78
+ toast.style.bottom = '12px';
79
+ toast.style.background = 'rgba(0,0,0,0.8)';
80
+ toast.style.color = '#fff';
81
+ toast.style.padding = '8px 10px';
82
+ toast.style.borderRadius = '4px';
83
+ toast.style.fontSize = '12px';
84
+ mount_element.appendChild(toast);
85
+ setTimeout(() => {
86
+ try {
87
+ toast.remove();
88
+ } catch {
89
+ /* ignore */
90
+ }
91
+ }, 2800);
92
+ }
93
+
94
+ /** @param {string} id */
95
+ function issueHref(id) {
96
+ return `#/issue/${id}`;
97
+ }
98
+
99
+ /**
100
+ * @param {string} message
101
+ */
102
+ function renderPlaceholder(message) {
103
+ render(
104
+ html`
105
+ <div class="panel__body" id="detail-root">
106
+ <p class="muted">${message}</p>
107
+ </div>
108
+ `,
109
+ mount_element
110
+ );
111
+ }
112
+
113
+ // Handlers
114
+ const onTitleSpanClick = () => {
115
+ edit_title = true;
116
+ doRender();
117
+ };
118
+ /**
119
+ * @param {KeyboardEvent} ev
120
+ */
121
+ const onTitleKeydown = (ev) => {
122
+ if (ev.key === 'Enter') {
123
+ edit_title = true;
124
+ doRender();
125
+ } else if (ev.key === 'Escape') {
126
+ edit_title = false;
127
+ doRender();
128
+ }
129
+ };
130
+ const onTitleSave = async () => {
131
+ if (!current || pending) {
132
+ return;
133
+ }
134
+ /** @type {HTMLInputElement|null} */
135
+ const input = /** @type {any} */ (mount_element.querySelector('h2 input'));
136
+ const prev = current.title || '';
137
+ const next = input ? input.value : '';
138
+ if (next === prev) {
139
+ edit_title = false;
140
+ doRender();
141
+ return;
142
+ }
143
+ pending = true;
144
+ if (input) {
145
+ input.disabled = true;
146
+ }
147
+ try {
148
+ const updated = await sendFn('edit-text', {
149
+ id: current.id,
150
+ field: 'title',
151
+ value: next
152
+ });
153
+ if (updated && typeof updated === 'object') {
154
+ current = /** @type {IssueDetail} */ (updated);
155
+ edit_title = false;
156
+ doRender();
157
+ }
158
+ } catch {
159
+ current.title = prev;
160
+ edit_title = false;
161
+ doRender();
162
+ showToast('Failed to save title');
163
+ } finally {
164
+ pending = false;
165
+ }
166
+ };
167
+ const onTitleCancel = () => {
168
+ edit_title = false;
169
+ doRender();
170
+ };
171
+ // Assignee inline edit handlers
172
+ const onAssigneeSpanClick = () => {
173
+ edit_assignee = true;
174
+ doRender();
175
+ };
176
+ /**
177
+ * @param {KeyboardEvent} ev
178
+ */
179
+ const onAssigneeKeydown = (ev) => {
180
+ if (ev.key === 'Enter') {
181
+ ev.preventDefault();
182
+ edit_assignee = true;
183
+ doRender();
184
+ } else if (ev.key === 'Escape') {
185
+ ev.preventDefault();
186
+ edit_assignee = false;
187
+ doRender();
188
+ }
189
+ };
190
+ const onAssigneeSave = async () => {
191
+ if (!current || pending) {
192
+ return;
193
+ }
194
+ /** @type {HTMLInputElement|null} */
195
+ const input = /** @type {any} */ (
196
+ mount_element.querySelector('#detail-root .prop.assignee input')
197
+ );
198
+ const prev = String(
199
+ (current && /** @type {any} */ (current).assignee) || ''
200
+ );
201
+ const next = input ? String(input.value || '') : '';
202
+ if (next === prev) {
203
+ edit_assignee = false;
204
+ doRender();
205
+ return;
206
+ }
207
+ pending = true;
208
+ if (input) {
209
+ input.disabled = true;
210
+ }
211
+ try {
212
+ const updated = await sendFn('update-assignee', {
213
+ id: current.id,
214
+ assignee: next
215
+ });
216
+ if (updated && typeof updated === 'object') {
217
+ current = /** @type {IssueDetail} */ (updated);
218
+ edit_assignee = false;
219
+ doRender();
220
+ }
221
+ } catch {
222
+ // revert visually
223
+ /** @type {any} */ (current).assignee = prev;
224
+ edit_assignee = false;
225
+ doRender();
226
+ showToast('Failed to update assignee');
227
+ } finally {
228
+ pending = false;
229
+ }
230
+ };
231
+ const onAssigneeCancel = () => {
232
+ edit_assignee = false;
233
+ doRender();
234
+ };
235
+
236
+ // Labels handlers
237
+ /**
238
+ * @param {Event} ev
239
+ */
240
+ const onLabelInput = (ev) => {
241
+ /** @type {HTMLInputElement} */
242
+ const el = /** @type {any} */ (ev.currentTarget);
243
+ new_label_text = el.value || '';
244
+ };
245
+ /**
246
+ * @param {KeyboardEvent} e
247
+ */
248
+ function onLabelKeydown(e) {
249
+ if (e.key === 'Enter') {
250
+ e.preventDefault();
251
+ void onAddLabel();
252
+ }
253
+ }
254
+ async function onAddLabel() {
255
+ if (!current || pending) {
256
+ return;
257
+ }
258
+ const text = new_label_text.trim();
259
+ if (!text) {
260
+ return;
261
+ }
262
+ pending = true;
263
+ try {
264
+ const updated = await sendFn('label-add', {
265
+ id: current.id,
266
+ label: text
267
+ });
268
+ if (updated && typeof updated === 'object') {
269
+ current = /** @type {IssueDetail} */ (updated);
270
+ new_label_text = '';
271
+ doRender();
272
+ }
273
+ } catch {
274
+ showToast('Failed to add label');
275
+ } finally {
276
+ pending = false;
277
+ }
278
+ }
279
+ /**
280
+ * @param {string} label
281
+ */
282
+ async function onRemoveLabel(label) {
283
+ if (!current || pending) {
284
+ return;
285
+ }
286
+ pending = true;
287
+ try {
288
+ const updated = await sendFn('label-remove', {
289
+ id: current.id,
290
+ label
291
+ });
292
+ if (updated && typeof updated === 'object') {
293
+ current = /** @type {IssueDetail} */ (updated);
294
+ doRender();
295
+ }
296
+ } catch {
297
+ showToast('Failed to remove label');
298
+ } finally {
299
+ pending = false;
300
+ }
301
+ }
302
+ /**
303
+ * @param {Event} ev
304
+ */
305
+ const onStatusChange = async (ev) => {
306
+ if (!current || pending) {
307
+ doRender();
308
+ return;
309
+ }
310
+ /** @type {HTMLSelectElement} */
311
+ const sel = /** @type {any} */ (ev.currentTarget);
312
+ const prev = current.status || 'open';
313
+ const next = sel.value;
314
+ if (next === prev) {
315
+ return;
316
+ }
317
+ pending = true;
318
+ current.status = next;
319
+ doRender();
320
+ try {
321
+ const updated = await sendFn('update-status', {
322
+ id: current.id,
323
+ status: next
324
+ });
325
+ if (updated && typeof updated === 'object') {
326
+ current = /** @type {IssueDetail} */ (updated);
327
+ doRender();
328
+ }
329
+ } catch {
330
+ current.status = prev;
331
+ doRender();
332
+ showToast('Failed to update status');
333
+ } finally {
334
+ pending = false;
335
+ }
336
+ };
337
+ /**
338
+ * @param {Event} ev
339
+ */
340
+ const onPriorityChange = async (ev) => {
341
+ if (!current || pending) {
342
+ doRender();
343
+ return;
344
+ }
345
+ /** @type {HTMLSelectElement} */
346
+ const sel = /** @type {any} */ (ev.currentTarget);
347
+ const prev = typeof current.priority === 'number' ? current.priority : 2;
348
+ const next = Number(sel.value);
349
+ if (next === prev) {
350
+ return;
351
+ }
352
+ pending = true;
353
+ current.priority = next;
354
+ doRender();
355
+ try {
356
+ const updated = await sendFn('update-priority', {
357
+ id: current.id,
358
+ priority: next
359
+ });
360
+ if (updated && typeof updated === 'object') {
361
+ current = /** @type {IssueDetail} */ (updated);
362
+ doRender();
363
+ }
364
+ } catch {
365
+ current.priority = prev;
366
+ doRender();
367
+ showToast('Failed to update priority');
368
+ } finally {
369
+ pending = false;
370
+ }
371
+ };
372
+
373
+ const onDescEdit = () => {
374
+ edit_desc = true;
375
+ doRender();
376
+ };
377
+ /**
378
+ * @param {KeyboardEvent} ev
379
+ */
380
+ const onDescKeydown = (ev) => {
381
+ if (ev.key === 'Escape') {
382
+ edit_desc = false;
383
+ doRender();
384
+ } else if (
385
+ ev.key === 'Enter' &&
386
+ /** @type {KeyboardEvent} */ (ev).ctrlKey
387
+ ) {
388
+ const btn = /** @type {HTMLButtonElement|null} */ (
389
+ mount_element.querySelector('#detail-root .editable-actions button')
390
+ );
391
+ if (btn) {
392
+ btn.click();
393
+ }
394
+ }
395
+ };
396
+ const onDescSave = async () => {
397
+ if (!current || pending) {
398
+ return;
399
+ }
400
+ /** @type {HTMLTextAreaElement|null} */
401
+ const ta = /** @type {any} */ (
402
+ mount_element.querySelector('#detail-root textarea')
403
+ );
404
+ const prev = current.description || '';
405
+ const next = ta ? ta.value : '';
406
+ if (next === prev) {
407
+ edit_desc = false;
408
+ doRender();
409
+ return;
410
+ }
411
+ pending = true;
412
+ if (ta) {
413
+ ta.disabled = true;
414
+ }
415
+ try {
416
+ const updated = await sendFn('edit-text', {
417
+ id: current.id,
418
+ field: 'description',
419
+ value: next
420
+ });
421
+ if (updated && typeof updated === 'object') {
422
+ current = /** @type {IssueDetail} */ (updated);
423
+ edit_desc = false;
424
+ doRender();
425
+ }
426
+ } catch {
427
+ current.description = prev;
428
+ edit_desc = false;
429
+ doRender();
430
+ showToast('Failed to save description');
431
+ } finally {
432
+ pending = false;
433
+ }
434
+ };
435
+ const onDescCancel = () => {
436
+ edit_desc = false;
437
+ doRender();
438
+ };
439
+
440
+ const onAcceptEdit = () => {
441
+ edit_accept = true;
442
+ doRender();
443
+ };
444
+ /**
445
+ * @param {KeyboardEvent} ev
446
+ */
447
+ const onAcceptKeydown = (ev) => {
448
+ if (ev.key === 'Escape') {
449
+ edit_accept = false;
450
+ doRender();
451
+ } else if (
452
+ ev.key === 'Enter' &&
453
+ /** @type {KeyboardEvent} */ (ev).ctrlKey
454
+ ) {
455
+ const btn = /** @type {HTMLButtonElement|null} */ (
456
+ mount_element.querySelector(
457
+ '#detail-root .acceptance .editable-actions button'
458
+ )
459
+ );
460
+ if (btn) {
461
+ btn.click();
462
+ }
463
+ }
464
+ };
465
+ const onAcceptSave = async () => {
466
+ if (!current || pending) {
467
+ return;
468
+ }
469
+ /** @type {HTMLTextAreaElement|null} */
470
+ const ta = /** @type {any} */ (
471
+ mount_element.querySelector('#detail-root .acceptance textarea')
472
+ );
473
+ const prev = current.acceptance || '';
474
+ const next = ta ? ta.value : '';
475
+ if (next === prev) {
476
+ edit_accept = false;
477
+ doRender();
478
+ return;
479
+ }
480
+ pending = true;
481
+ if (ta) {
482
+ ta.disabled = true;
483
+ }
484
+ try {
485
+ const updated = await sendFn('edit-text', {
486
+ id: current.id,
487
+ field: 'acceptance',
488
+ value: next
489
+ });
490
+ if (updated && typeof updated === 'object') {
491
+ current = /** @type {IssueDetail} */ (updated);
492
+ edit_accept = false;
493
+ doRender();
494
+ }
495
+ } catch {
496
+ current.acceptance = prev;
497
+ edit_accept = false;
498
+ doRender();
499
+ showToast('Failed to save acceptance');
500
+ } finally {
501
+ pending = false;
502
+ }
503
+ };
504
+ const onAcceptCancel = () => {
505
+ edit_accept = false;
506
+ doRender();
507
+ };
508
+
509
+ /**
510
+ * @param {'Dependencies'|'Dependents'} title
511
+ * @param {Dependency[]} items
512
+ */
513
+ function depsSection(title, items) {
514
+ const test_id =
515
+ title === 'Dependencies' ? 'add-dependency' : 'add-dependent';
516
+ return html`
517
+ <div class="props-card">
518
+ <div>
519
+ <div class="props-card__title">${title}</div>
520
+ </div>
521
+ <ul>
522
+ ${!items || items.length === 0
523
+ ? null
524
+ : items.map((dep) => {
525
+ const did = dep.id;
526
+ const href = issueHref(did);
527
+ return html` <li
528
+ style="display:grid;grid-template-columns:auto auto 1fr auto;gap:6px;align-items:center;padding:2px 0;cursor:pointer;"
529
+ @click=${() => navigateFn(href)}
530
+ >
531
+ <a href=${href} @click=${makeDepLinkClick(href)}
532
+ >${issueDisplayId(did)}</a
533
+ >
534
+ ${createTypeBadge(dep.issue_type || '')}
535
+ <span class="text-truncate">${dep.title || ''}</span>
536
+ <button
537
+ aria-label=${`Remove dependency ${issueDisplayId(did)}`}
538
+ @click=${makeDepRemoveClick(did, title)}
539
+ >
540
+ ×
541
+ </button>
542
+ </li>`;
543
+ })}
544
+ </ul>
545
+ <div class="props-card__footer">
546
+ <input type="text" placeholder="Issue ID" data-testid=${test_id} />
547
+ <button @click=${makeDepAddClick(items, title)}>Add</button>
548
+ </div>
549
+ </div>
550
+ `;
551
+ }
552
+
553
+ /**
554
+ * @param {IssueDetail} issue
555
+ */
556
+ function detailTemplate(issue) {
557
+ const title_zone = edit_title
558
+ ? html`<div class="detail-title">
559
+ <h2>
560
+ <input
561
+ type="text"
562
+ aria-label="Edit title"
563
+ .value=${issue.title || ''}
564
+ @keydown=${onTitleInputKeydown}
565
+ style="width:100%;font-size:inherit;line-height:inherit;"
566
+ />
567
+ <button style="margin-left:6px" @click=${onTitleSave}>Save</button>
568
+ <button style="margin-left:6px" @click=${onTitleCancel}>
569
+ Cancel
570
+ </button>
571
+ </h2>
572
+ <span class="mono detail-id">${issueDisplayId(issue.id)}</span>
573
+ </div>`
574
+ : html`<div class="detail-title">
575
+ <h2>
576
+ <span
577
+ class="editable"
578
+ tabindex="0"
579
+ role="button"
580
+ aria-label="Edit title"
581
+ @click=${onTitleSpanClick}
582
+ @keydown=${onTitleKeydown}
583
+ >${issue.title || ''}</span
584
+ >
585
+ </h2>
586
+ <span class="mono detail-id">${issueDisplayId(issue.id)}</span>
587
+ </div>`;
588
+
589
+ const status_select = html`<select
590
+ class=${`badge-select badge--status is-${issue.status || 'open'}`}
591
+ @change=${onStatusChange}
592
+ .value=${issue.status || 'open'}
593
+ ?disabled=${pending}
594
+ >
595
+ ${(() => {
596
+ const cur = String(issue.status || 'open');
597
+ return ['open', 'in_progress', 'closed'].map(
598
+ (s) =>
599
+ html`<option value=${s} ?selected=${cur === s}>
600
+ ${statusLabel(s)}
601
+ </option>`
602
+ );
603
+ })()}
604
+ </select>`;
605
+
606
+ const priority_select = html`<select
607
+ class=${`badge-select badge--priority is-p${String(
608
+ typeof issue.priority === 'number' ? issue.priority : 2
609
+ )}`}
610
+ @change=${onPriorityChange}
611
+ .value=${String(typeof issue.priority === 'number' ? issue.priority : 2)}
612
+ ?disabled=${pending}
613
+ >
614
+ ${(() => {
615
+ const cur = String(
616
+ typeof issue.priority === 'number' ? issue.priority : 2
617
+ );
618
+ return priority_levels.map(
619
+ (p, i) =>
620
+ html`<option value=${String(i)} ?selected=${cur === String(i)}>
621
+ ${emojiForPriority(i)} ${p}
622
+ </option>`
623
+ );
624
+ })()}
625
+ </select>`;
626
+
627
+ const desc_block = edit_desc
628
+ ? html`<div class="description">
629
+ <textarea
630
+ @keydown=${onDescKeydown}
631
+ .value=${issue.description || ''}
632
+ rows="8"
633
+ style="width:100%"
634
+ ></textarea>
635
+ <div class="editable-actions">
636
+ <button @click=${onDescSave}>Save</button>
637
+ <button @click=${onDescCancel}>Cancel</button>
638
+ </div>
639
+ </div>`
640
+ : html`<div
641
+ class="md editable"
642
+ tabindex="0"
643
+ role="button"
644
+ aria-label="Edit description"
645
+ @click=${onDescEdit}
646
+ @keydown=${onDescEditableKeydown}
647
+ >
648
+ ${(() => {
649
+ const text = issue.description || '';
650
+ if (text.trim() === '') {
651
+ return html`<div class="muted">Description</div>`;
652
+ }
653
+ return renderMarkdown(text);
654
+ })()}
655
+ </div>`;
656
+
657
+ // Normalize acceptance text: prefer issue.acceptance, fallback to acceptance_criteria from bd
658
+ const acceptance_text = (() => {
659
+ /** @type {any} */
660
+ const any_issue = issue;
661
+ const raw = String(
662
+ issue.acceptance || any_issue.acceptance_criteria || ''
663
+ );
664
+ return raw;
665
+ })();
666
+
667
+ const accept_block = edit_accept
668
+ ? html`<div class="acceptance">
669
+ ${acceptance_text.trim().length > 0
670
+ ? html`<div class="props-card__title">Acceptance</div>`
671
+ : ''}
672
+ <textarea
673
+ @keydown=${onAcceptKeydown}
674
+ .value=${acceptance_text}
675
+ rows="6"
676
+ style="width:100%"
677
+ ></textarea>
678
+ <div class="editable-actions">
679
+ <button @click=${onAcceptSave}>Save</button>
680
+ <button @click=${onAcceptCancel}>Cancel</button>
681
+ </div>
682
+ </div>`
683
+ : html`<div class="acceptance">
684
+ ${acceptance_text.trim().length > 0
685
+ ? html`<div class="props-card__title">Acceptance</div>
686
+ <div
687
+ class="md editable"
688
+ tabindex="0"
689
+ role="button"
690
+ aria-label="Edit acceptance"
691
+ @click=${onAcceptEdit}
692
+ @keydown=${onAcceptEditableKeydown}
693
+ >
694
+ ${renderMarkdown(acceptance_text)}
695
+ </div>`
696
+ : ''}
697
+ </div>`;
698
+
699
+ // Notes (read-only): show heading only if there is content
700
+ const notes_text = String(issue.notes || '');
701
+ const notes_block = html`<div class="notes">
702
+ ${notes_text.trim().length > 0
703
+ ? html`<div class="props-card__title">Notes</div>
704
+ <div class="md">${renderMarkdown(notes_text)}</div>`
705
+ : ''}
706
+ </div>`;
707
+
708
+ // Labels section
709
+ const labels = Array.isArray(issue.labels) ? issue.labels : [];
710
+ const labels_block = html`<div class="prop labels">
711
+ <div class="label">Labels</div>
712
+ <div class="value">
713
+ <div>
714
+ ${labels.map(
715
+ (l) =>
716
+ html`<span class="badge" title=${l}
717
+ >${l}
718
+ <button
719
+ class="icon-button"
720
+ title="Remove label"
721
+ aria-label=${'Remove label ' + l}
722
+ @click=${() => onRemoveLabel(l)}
723
+ style="margin-left:6px"
724
+ >
725
+ ×
726
+ </button></span
727
+ >`
728
+ )}
729
+ <input
730
+ type="text"
731
+ aria-label="Add label"
732
+ placeholder="Add label…"
733
+ .value=${new_label_text}
734
+ @input=${onLabelInput}
735
+ @keydown=${onLabelKeydown}
736
+ size=${Math.max(12, Math.min(28, new_label_text.length + 3))}
737
+ />
738
+ </div>
739
+ </div>
740
+ </div>`;
741
+
742
+ return html`
743
+ <div class="panel__body" id="detail-root">
744
+ <div style="position:relative">
745
+ <div class="detail-layout">
746
+ <div class="detail-main">
747
+ ${title_zone} ${desc_block} ${notes_block} ${accept_block}
748
+ </div>
749
+ <div class="detail-side">
750
+ <div class="props-card">
751
+ <div class="props-card__title">Properties</div>
752
+ <div class="prop">
753
+ <div class="label">Type</div>
754
+ <div class="value">
755
+ ${createTypeBadge(/** @type {any} */ (issue).issue_type)}
756
+ </div>
757
+ </div>
758
+ <div class="prop">
759
+ <div class="label">Status</div>
760
+ <div class="value">${status_select}</div>
761
+ </div>
762
+ <div class="prop">
763
+ <div class="label">Priority</div>
764
+ <div class="value">${priority_select}</div>
765
+ </div>
766
+ <div class="prop assignee">
767
+ <div class="label">Assignee</div>
768
+ <div class="value">
769
+ ${edit_assignee
770
+ ? html`<input
771
+ type="text"
772
+ aria-label="Edit assignee"
773
+ .value=${/** @type {any} */ (issue).assignee || ''}
774
+ size=${Math.min(
775
+ 40,
776
+ Math.max(
777
+ 12,
778
+ String(
779
+ /** @type {any} */ (issue).assignee || ''
780
+ ).length + 3
781
+ )
782
+ )}
783
+ @keydown=${
784
+ /** @param {KeyboardEvent} e */ (e) => {
785
+ if (e.key === 'Escape') {
786
+ e.preventDefault();
787
+ onAssigneeCancel();
788
+ } else if (e.key === 'Enter') {
789
+ e.preventDefault();
790
+ onAssigneeSave();
791
+ }
792
+ }
793
+ }
794
+ />
795
+ <button
796
+ class="btn"
797
+ style="margin-left:6px"
798
+ @click=${onAssigneeSave}
799
+ >
800
+ Save
801
+ </button>
802
+ <button
803
+ class="btn"
804
+ style="margin-left:6px"
805
+ @click=${onAssigneeCancel}
806
+ >
807
+ Cancel
808
+ </button>`
809
+ : html`${(() => {
810
+ const raw = String(
811
+ /** @type {any} */ (issue).assignee || ''
812
+ );
813
+ const has = raw.trim().length > 0;
814
+ const text = has ? raw : 'Unassigned';
815
+ const cls = has ? 'editable' : 'editable muted';
816
+ return html`<span
817
+ class=${cls}
818
+ tabindex="0"
819
+ role="button"
820
+ aria-label="Edit assignee"
821
+ @click=${onAssigneeSpanClick}
822
+ @keydown=${onAssigneeKeydown}
823
+ >${text}</span
824
+ >`;
825
+ })()}`}
826
+ </div>
827
+ </div>
828
+ ${labels_block}
829
+ </div>
830
+ ${depsSection('Dependencies', issue.dependencies || [])}
831
+ ${depsSection('Dependents', issue.dependents || [])}
832
+ </div>
833
+ </div>
834
+ </div>
835
+ </div>
836
+ `;
837
+ }
838
+
839
+ function doRender() {
840
+ if (!current) {
841
+ renderPlaceholder('No issue selected');
842
+ return;
843
+ }
844
+ render(detailTemplate(current), mount_element);
845
+ // panel header removed for detail view; ID is shown inline with title
846
+ }
847
+
848
+ /**
849
+ * Create an anchor click handler for dependency links.
850
+ * @param {string} href
851
+ * @returns {(ev: Event) => void}
852
+ */
853
+ function makeDepLinkClick(href) {
854
+ return (ev) => {
855
+ ev.preventDefault();
856
+ /** @type {Event} */
857
+ const e = ev;
858
+ // stop bubbling to the li row click
859
+ e.stopPropagation();
860
+ navigateFn(href);
861
+ };
862
+ }
863
+
864
+ /**
865
+ * Create a click handler for the remove button of a dependency row.
866
+ * @param {string} did
867
+ * @param {'Dependencies'|'Dependents'} title
868
+ * @returns {(ev: Event) => Promise<void>}
869
+ */
870
+ function makeDepRemoveClick(did, title) {
871
+ return async (ev) => {
872
+ /** @type {Event} */
873
+ const e = ev;
874
+ e.stopPropagation();
875
+ if (!current || pending) {
876
+ return;
877
+ }
878
+ pending = true;
879
+ try {
880
+ if (title === 'Dependencies') {
881
+ const updated = await sendFn('dep-remove', {
882
+ a: current.id,
883
+ b: did,
884
+ view_id: current.id
885
+ });
886
+ if (updated && typeof updated === 'object') {
887
+ current = /** @type {IssueDetail} */ (updated);
888
+ doRender();
889
+ }
890
+ } else {
891
+ const updated = await sendFn('dep-remove', {
892
+ a: did,
893
+ b: current.id,
894
+ view_id: current.id
895
+ });
896
+ if (updated && typeof updated === 'object') {
897
+ current = /** @type {IssueDetail} */ (updated);
898
+ doRender();
899
+ }
900
+ }
901
+ } catch {
902
+ // ignore
903
+ } finally {
904
+ pending = false;
905
+ }
906
+ };
907
+ }
908
+
909
+ /**
910
+ * Create a click handler for the Add button in a dependency section.
911
+ * @param {Dependency[]} items
912
+ * @param {'Dependencies'|'Dependents'} title
913
+ * @returns {(ev: Event) => Promise<void>}
914
+ */
915
+ function makeDepAddClick(items, title) {
916
+ return async (ev) => {
917
+ if (!current || pending) {
918
+ return;
919
+ }
920
+ /** @type {HTMLButtonElement} */
921
+ const btn = /** @type {any} */ (ev.currentTarget);
922
+ /** @type {HTMLInputElement|null} */
923
+ const input = /** @type {any} */ (btn.previousElementSibling);
924
+ const target = input ? input.value.trim() : '';
925
+ if (!target || target === current.id) {
926
+ showToast('Enter a different issue id');
927
+ return;
928
+ }
929
+ const set = new Set((items || []).map((d) => d.id));
930
+ if (set.has(target)) {
931
+ showToast('Link already exists');
932
+ return;
933
+ }
934
+ pending = true;
935
+ if (btn) {
936
+ btn.disabled = true;
937
+ }
938
+ if (input) {
939
+ input.disabled = true;
940
+ }
941
+ try {
942
+ if (title === 'Dependencies') {
943
+ const updated = await sendFn('dep-add', {
944
+ a: current.id,
945
+ b: target,
946
+ view_id: current.id
947
+ });
948
+ if (updated && typeof updated === 'object') {
949
+ current = /** @type {IssueDetail} */ (updated);
950
+ doRender();
951
+ }
952
+ } else {
953
+ const updated = await sendFn('dep-add', {
954
+ a: target,
955
+ b: current.id,
956
+ view_id: current.id
957
+ });
958
+ if (updated && typeof updated === 'object') {
959
+ current = /** @type {IssueDetail} */ (updated);
960
+ doRender();
961
+ }
962
+ }
963
+ } catch {
964
+ showToast('Failed to add dependency');
965
+ } finally {
966
+ pending = false;
967
+ }
968
+ };
969
+ }
970
+ /**
971
+ * @param {KeyboardEvent} ev
972
+ */
973
+ function onTitleInputKeydown(ev) {
974
+ if (ev.key === 'Escape') {
975
+ edit_title = false;
976
+ doRender();
977
+ } else if (ev.key === 'Enter') {
978
+ ev.preventDefault();
979
+ onTitleSave();
980
+ }
981
+ }
982
+
983
+ /**
984
+ * @param {KeyboardEvent} ev
985
+ */
986
+ function onDescEditableKeydown(ev) {
987
+ if (ev.key === 'Enter') {
988
+ onDescEdit();
989
+ }
990
+ }
991
+
992
+ /**
993
+ * @param {KeyboardEvent} ev
994
+ */
995
+ function onAcceptEditableKeydown(ev) {
996
+ if (ev.key === 'Enter') {
997
+ onAcceptEdit();
998
+ }
999
+ }
1000
+
1001
+ return {
1002
+ async load(id) {
1003
+ if (!id) {
1004
+ renderPlaceholder('No issue selected');
1005
+ return;
1006
+ }
1007
+ /** @type {unknown} */
1008
+ let result;
1009
+ try {
1010
+ result = await sendFn('show-issue', { id });
1011
+ } catch {
1012
+ result = null;
1013
+ }
1014
+ if (!result || typeof result !== 'object') {
1015
+ renderPlaceholder('Issue not found');
1016
+ return;
1017
+ }
1018
+ const issue = /** @type {IssueDetail} */ (result);
1019
+ // Some backends may normalize ID casing (e.g., UI-1 vs ui-1).
1020
+ // Treat IDs case-insensitively to avoid false negatives on deep links.
1021
+ if (
1022
+ !issue ||
1023
+ String(issue.id || '').toLowerCase() !== String(id || '').toLowerCase()
1024
+ ) {
1025
+ renderPlaceholder('Issue not found');
1026
+ return;
1027
+ }
1028
+ current = issue;
1029
+ pending = false;
1030
+ doRender();
1031
+ },
1032
+ clear() {
1033
+ renderPlaceholder('Select an issue to view details');
1034
+ },
1035
+ destroy() {
1036
+ mount_element.replaceChildren();
1037
+ }
1038
+ };
1039
+ }