@starlove/orbit 1.0.1

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 (86) hide show
  1. package/.dockerignore +13 -0
  2. package/Dockerfile +34 -0
  3. package/README.md +216 -0
  4. package/SKILL-ORBIT.md +94 -0
  5. package/build.mjs +204 -0
  6. package/dist/full/apple-touch-icon.png +0 -0
  7. package/dist/full/cursors/live_arrow.cur +0 -0
  8. package/dist/full/cursors/live_link.cur +0 -0
  9. package/dist/full/cursors/live_move.cur +0 -0
  10. package/dist/full/cursors/live_select.cur +0 -0
  11. package/dist/full/cursors/live_unavail.cur +0 -0
  12. package/dist/full/favicon-32x32.png +0 -0
  13. package/dist/full/index.html +314 -0
  14. package/dist/full/js/app.70be7d06.js +554 -0
  15. package/dist/full/js/app.70be7d06.js.map +7 -0
  16. package/dist/full/orbits-favicon.png +0 -0
  17. package/dist/full/orbits-logo-bg.png +0 -0
  18. package/dist/full/orbits-logo.png +0 -0
  19. package/dist/full/styles.c2e3d20a.css +4892 -0
  20. package/dist/full/version.json +6 -0
  21. package/docs/AGENT_PROTOCOL.md +379 -0
  22. package/docs/MCP_SETUP.md +49 -0
  23. package/docs/mcp-client.example.json +13 -0
  24. package/package.json +62 -0
  25. package/pnpm-workspace.yaml +2 -0
  26. package/public/apple-touch-icon.png +0 -0
  27. package/public/cursors/live_arrow.cur +0 -0
  28. package/public/cursors/live_link.cur +0 -0
  29. package/public/cursors/live_move.cur +0 -0
  30. package/public/cursors/live_select.cur +0 -0
  31. package/public/cursors/live_unavail.cur +0 -0
  32. package/public/favicon-32x32.png +0 -0
  33. package/public/index.html +314 -0
  34. package/public/js/api.js +35 -0
  35. package/public/js/app.js +35 -0
  36. package/public/js/board-menu.js +145 -0
  37. package/public/js/config.js +19 -0
  38. package/public/js/create-card.js +153 -0
  39. package/public/js/dom.js +19 -0
  40. package/public/js/drawer.js +137 -0
  41. package/public/js/format.js +108 -0
  42. package/public/js/kanban.js +475 -0
  43. package/public/js/local-backend.js +934 -0
  44. package/public/js/main.js +142 -0
  45. package/public/js/modal.js +31 -0
  46. package/public/js/router.js +114 -0
  47. package/public/js/search.js +53 -0
  48. package/public/js/settings-tabs.js +7 -0
  49. package/public/js/settings.js +931 -0
  50. package/public/js/sse.js +156 -0
  51. package/public/js/starscape.js +315 -0
  52. package/public/js/state.js +54 -0
  53. package/public/js/ticket-detail.js +766 -0
  54. package/public/js/toast.js +74 -0
  55. package/public/js/transport.js +22 -0
  56. package/public/js/unread.js +121 -0
  57. package/public/orbits-favicon.png +0 -0
  58. package/public/orbits-logo-bg.png +0 -0
  59. package/public/orbits-logo.png +0 -0
  60. package/public/styles.css +4892 -0
  61. package/src/cli/orbit.js +438 -0
  62. package/src/core/agent.js +262 -0
  63. package/src/core/auth.js +49 -0
  64. package/src/core/backups.js +136 -0
  65. package/src/core/board.js +112 -0
  66. package/src/core/boards.js +310 -0
  67. package/src/core/bootstrap.js +162 -0
  68. package/src/core/db.js +237 -0
  69. package/src/core/delete-board.js +53 -0
  70. package/src/core/events.js +314 -0
  71. package/src/core/http.js +81 -0
  72. package/src/core/paths.js +25 -0
  73. package/src/core/provision-repo-board.js +113 -0
  74. package/src/core/queries.js +246 -0
  75. package/src/core/registry.js +207 -0
  76. package/src/core/relations.js +163 -0
  77. package/src/core/router.js +414 -0
  78. package/src/core/search.js +70 -0
  79. package/src/core/seed.js +226 -0
  80. package/src/core/snapshots.js +255 -0
  81. package/src/core/states.js +167 -0
  82. package/src/core/system-picker.js +93 -0
  83. package/src/core/tickets.js +344 -0
  84. package/src/core/util.js +105 -0
  85. package/src/mcp-server.js +773 -0
  86. package/src/server.js +48 -0
@@ -0,0 +1,766 @@
1
+ // Ticket detail drawer: renders the full ticket pane (header meta, AI plan,
2
+ // status history, comments) and wires every editable surface — inline title
3
+ // and description editors, meta selects, label management, parent epic, and
4
+ // related cards. Also owns the small render helpers (status history,
5
+ // comments, detail-card) that are only used inside the drawer.
6
+
7
+ import { state, ticketsForProject, statesForProject, boardLabelCatalog, currentBoard } from "./state.js";
8
+ import { drawerInner, drawer, $ } from "./dom.js";
9
+ import {
10
+ escapeHtml,
11
+ formatDate,
12
+ formatDateDetail,
13
+ ticketLabel,
14
+ canonicalTicketType,
15
+ typeLabel,
16
+ typeLabelLong,
17
+ priorityLabel,
18
+ priorityKeyFor
19
+ } from "./format.js";
20
+ import { renderTypeIcon, renderPriorityPill } from "./kanban.js";
21
+ import { renderDrawerShell, openDrawer, closeDrawer } from "./drawer.js";
22
+ import { navigate } from "./router.js";
23
+ import { api, withBoardQuery } from "./api.js";
24
+ import { toast } from "./toast.js";
25
+ import { load } from "./app.js";
26
+ import { renderProjectDetail } from "./settings.js";
27
+ import { unreadCount, markRead } from "./unread.js";
28
+
29
+ /**
30
+ * PATCH a ticket then reload + re-render the drawer in ticket mode. Lives
31
+ * here because every caller is a drawer interaction; api.js stays I/O-only.
32
+ */
33
+ async function patchTicket(ticketId, body) {
34
+ await api(withBoardQuery(`/api/tickets/${ticketId}`), { method: "PATCH", body });
35
+ await load();
36
+ state.selectedTicketId = ticketId;
37
+ state.detailMode = "ticket";
38
+ await renderDetail();
39
+ }
40
+
41
+ /**
42
+ * Replace a static node with an inline editor; commit on Enter / blur, cancel on Esc.
43
+ * For multi-line fields, Ctrl/Cmd+Enter commits and plain Enter inserts a newline.
44
+ */
45
+ function startInlineEdit(node, field, initialValue, ticketId) {
46
+ if (!node || node.dataset.editing === "true") return;
47
+ node.dataset.editing = "true";
48
+
49
+ const isTextarea = field === "description";
50
+ const editor = document.createElement(isTextarea ? "textarea" : "input");
51
+ editor.className = isTextarea ? "inline-desc-editor" : "inline-title-editor";
52
+ editor.value = initialValue;
53
+ editor.setAttribute("data-editing-field", field);
54
+ if (!isTextarea) {
55
+ editor.type = "text";
56
+ editor.maxLength = 500;
57
+ } else {
58
+ editor.rows = Math.max(3, String(initialValue).split("\n").length + 1);
59
+ }
60
+
61
+ node.replaceWith(editor);
62
+ editor.focus();
63
+ if (!isTextarea) editor.select();
64
+ else editor.setSelectionRange(editor.value.length, editor.value.length);
65
+
66
+ let done = false;
67
+
68
+ const commit = async () => {
69
+ if (done) return;
70
+ done = true;
71
+ const next = editor.value;
72
+ if (String(next) === String(initialValue)) {
73
+ await renderDetail();
74
+ return;
75
+ }
76
+ if (field === "title" && !next.trim()) {
77
+ toast.warning("Title cannot be empty");
78
+ await renderDetail();
79
+ return;
80
+ }
81
+ await patchTicket(ticketId, { [field]: next });
82
+ };
83
+
84
+ const cancel = async () => {
85
+ if (done) return;
86
+ done = true;
87
+ await renderDetail();
88
+ };
89
+
90
+ editor.addEventListener("blur", commit);
91
+ editor.addEventListener("keydown", (event) => {
92
+ if (event.key === "Escape") {
93
+ event.preventDefault();
94
+ void cancel();
95
+ return;
96
+ }
97
+ if (event.key === "Enter") {
98
+ if (!isTextarea) {
99
+ event.preventDefault();
100
+ void commit();
101
+ } else if (event.metaKey || event.ctrlKey) {
102
+ event.preventDefault();
103
+ void commit();
104
+ }
105
+ }
106
+ });
107
+ }
108
+
109
+ /** Wire inline editors on the ticket detail header (meta fields, labels). */
110
+ function wireTicketDetailEditors(ticket) {
111
+ const ticketId = ticket.id;
112
+
113
+ const titleEl = drawer.querySelector('[data-edit-field="title"]');
114
+ if (titleEl) {
115
+ titleEl.addEventListener("click", () => {
116
+ startInlineEdit(titleEl, "title", ticket.title || "", ticketId);
117
+ });
118
+ titleEl.addEventListener("keydown", (event) => {
119
+ if (event.key === "Enter" || event.key === " ") {
120
+ event.preventDefault();
121
+ startInlineEdit(titleEl, "title", ticket.title || "", ticketId);
122
+ }
123
+ });
124
+ }
125
+
126
+ const descEl = drawerInner.querySelector('[data-edit-field="description"]');
127
+ if (descEl) {
128
+ descEl.addEventListener("click", () => {
129
+ startInlineEdit(descEl, "description", ticket.description || "", ticketId);
130
+ });
131
+ descEl.addEventListener("keydown", (event) => {
132
+ if (event.key === "Enter" || event.key === " ") {
133
+ event.preventDefault();
134
+ startInlineEdit(descEl, "description", ticket.description || "", ticketId);
135
+ }
136
+ });
137
+ }
138
+
139
+ drawerInner.querySelectorAll(".meta-select[data-meta-field]").forEach((select) => {
140
+ select.addEventListener("change", async () => {
141
+ const field = select.dataset.metaField;
142
+ if (field === "type") {
143
+ await patchTicket(ticketId, { type: select.value });
144
+ return;
145
+ }
146
+ if (field === "state_id") {
147
+ await patchTicket(ticketId, { state_id: select.value });
148
+ return;
149
+ }
150
+ if (field === "parent_ticket_id") {
151
+ await patchTicket(ticketId, { parent_ticket_id: select.value || null });
152
+ return;
153
+ }
154
+ if (field === "priority") {
155
+ await patchTicket(ticketId, { priority: Number(select.value) });
156
+ }
157
+ });
158
+ });
159
+
160
+ const applyLabelPatch = async (nextNames) => {
161
+ await patchTicket(ticketId, { labels: nextNames });
162
+ };
163
+
164
+ drawerInner.querySelectorAll("[data-remove-label]").forEach((btn) => {
165
+ btn.addEventListener("click", async () => {
166
+ const removeName = btn.getAttribute("data-remove-label");
167
+ const names = (ticket.labels || []).map((l) => l.name).filter((n) => n !== removeName);
168
+ await applyLabelPatch(names);
169
+ });
170
+ });
171
+
172
+ const addLabel = async () => {
173
+ const input = drawerInner.querySelector("[data-label-input]");
174
+ if (!input) return;
175
+ const name = input.value.trim();
176
+ if (!name) return;
177
+ const names = (ticket.labels || []).map((l) => l.name);
178
+ if (names.includes(name)) {
179
+ input.value = "";
180
+ return;
181
+ }
182
+ names.push(name);
183
+ input.value = "";
184
+ await applyLabelPatch(names);
185
+ };
186
+
187
+ drawerInner.querySelector("[data-label-add]")?.addEventListener("click", () => {
188
+ void addLabel();
189
+ });
190
+ drawerInner.querySelector("[data-label-input]")?.addEventListener("keydown", (event) => {
191
+ if (event.key === "Enter") {
192
+ event.preventDefault();
193
+ void addLabel();
194
+ }
195
+ });
196
+ }
197
+
198
+ export async function renderDetail() {
199
+ if (state.detailMode === "settings") {
200
+ await renderProjectDetail();
201
+ openDrawer();
202
+ return;
203
+ }
204
+
205
+ if (!state.selectedTicketId) {
206
+ closeDrawer();
207
+ return;
208
+ }
209
+
210
+ const [context, statusHistory] = await Promise.all([
211
+ api(withBoardQuery(`/api/tickets/${state.selectedTicketId}/context?depth=1`)),
212
+ api(withBoardQuery(`/api/tickets/${state.selectedTicketId}/history`))
213
+ ]);
214
+ const ticket = context.ticket;
215
+ // Acknowledge the read receipt: clear the unread dot on this card.
216
+ // Also wipe the dot from the already-rendered card in the board so the
217
+ // user doesn't see it lingering until the next full re-render.
218
+ markRead(ticket.id, ticket.updated_at);
219
+ document.querySelector(`.card[data-ticket-id="${CSS.escape(ticket.id)}"] .card-unread-dot`)?.remove();
220
+ const states = statesForProject();
221
+
222
+ // Ordered by the canonical hierarchy so the dropdown visually reinforces scale:
223
+ // Epic (biggest) → Feature → Task → Bug.
224
+ const typeOptions = ["epic", "feature", "task", "bug"]
225
+ .map(
226
+ (value) =>
227
+ `<option value="${escapeHtml(value)}" ${canonicalTicketType(ticket.type) === value ? "selected" : ""}>${escapeHtml(typeLabelLong(value))}</option>`
228
+ )
229
+ .join("");
230
+
231
+ const stateOptions = states
232
+ .map(
233
+ (s) =>
234
+ `<option value="${escapeHtml(s.id)}" ${s.id === ticket.state_id ? "selected" : ""}>${escapeHtml(s.name)}</option>`
235
+ )
236
+ .join("");
237
+
238
+ const priorityOptions = [0, 1, 2, 3, 4]
239
+ .map(
240
+ (p) =>
241
+ `<option value="${p}" ${Number(ticket.priority) === p ? "selected" : ""}>${escapeHtml(priorityLabel(p))}</option>`
242
+ )
243
+ .join("");
244
+
245
+ const labelPills = (ticket.labels || [])
246
+ .map(
247
+ (label) => `
248
+ <span class="label-pill-removable" style="--label-color: ${escapeHtml(label.color)}">
249
+ ${escapeHtml(label.name)}
250
+ <button type="button" class="label-pill-remove" data-remove-label="${escapeHtml(label.name)}" title="Remove label">×</button>
251
+ </span>`
252
+ )
253
+ .join("");
254
+
255
+ const catalogNames = boardLabelCatalog()
256
+ .map((l) => l.name)
257
+ .filter((name) => !ticket.labels?.some((tl) => tl.name === name));
258
+
259
+ const datalistOptions = catalogNames.map((name) => `<option value="${escapeHtml(name)}"></option>`).join("");
260
+
261
+ const detailCanonicalType = canonicalTicketType(ticket.type);
262
+ const detailTypeText = typeLabel(ticket.type).toUpperCase();
263
+ const detailSubtitleHtml = `
264
+ <span class="ticket-number">${escapeHtml(ticketLabel(ticket))}</span>
265
+ <div class="card-type-id type-pill-${escapeHtml(detailCanonicalType)}">
266
+ ${renderTypeIcon(ticket.type)}
267
+ <span class="card-type-label">${escapeHtml(detailTypeText)}</span>
268
+ </div>
269
+ ${renderPriorityPill(ticket.priority)}
270
+ `;
271
+
272
+ renderDrawerShell({
273
+ title: ticket.title,
274
+ titleAttrs: {
275
+ class: "editable-field",
276
+ "data-edit-field": "title",
277
+ tabindex: "0",
278
+ title: "Click to edit"
279
+ },
280
+ subtitleHtml: detailSubtitleHtml,
281
+ body: `
282
+ <div class="detail-head">
283
+ <p class="detail-updated" title="Last saved change">Updated ${escapeHtml(formatDateDetail(ticket.updated_at))}</p>
284
+ <p class="description editable-field ${ticket.description ? "" : "is-placeholder"}" data-edit-field="description" tabindex="0" title="Click to edit">${escapeHtml(ticket.description || "No description yet.")}</p>
285
+ <dl class="ticket-meta" id="ticketMetaGrid" data-ticket-id="${escapeHtml(ticket.id)}">
286
+ <div class="ticket-meta-row">
287
+ <dt>State</dt>
288
+ <dd><select class="meta-select meta-inline" data-meta-field="state_id" aria-label="Lane / state">${stateOptions}</select></dd>
289
+ </div>
290
+ <div class="ticket-meta-row">
291
+ <dt>Type</dt>
292
+ <dd><select class="meta-select meta-inline" data-meta-field="type" aria-label="Ticket type">${typeOptions}</select></dd>
293
+ </div>
294
+ <div class="ticket-meta-row">
295
+ <dt>Priority</dt>
296
+ <dd><select class="meta-select meta-inline" data-meta-field="priority" aria-label="Priority">${priorityOptions}</select></dd>
297
+ </div>
298
+ <div class="ticket-meta-row ticket-meta-row--stack">
299
+ <dt>Labels</dt>
300
+ <dd>
301
+ <div class="label-pills-row" data-other-labels>${labelPills}</div>
302
+ <div class="label-add-row">
303
+ <input type="text" class="label-add-input meta-inline-input" data-label-input list="board-label-suggestions" placeholder="Add label…" aria-label="Add label" />
304
+ <datalist id="board-label-suggestions">${datalistOptions}</datalist>
305
+ <button type="button" class="ghost meta-add-btn" data-label-add>Add</button>
306
+ </div>
307
+ </dd>
308
+ </div>
309
+ </dl>
310
+ </div>
311
+
312
+ ${renderHierarchySection(ticket, context)}
313
+
314
+ ${renderParentEpicSection(ticket, context)}
315
+
316
+ <div class="section">
317
+ <h3>Related</h3>
318
+ ${renderRelated(context.relations, ticket)}
319
+ </div>
320
+
321
+ ${currentBoard()?.ai_enabled !== 0 ? `
322
+ <details class="section ai-fields" data-ai-plan-toggle data-ticket-id="${escapeHtml(ticket.id)}"${aiPlanOpen(ticket.id) ? " open" : ""}>
323
+ <summary><h3>AI Plan / Implementation Record</h3></summary>
324
+ <form id="aiFieldsForm" class="field-form">
325
+ <label>
326
+ <span>AI-Written Plan</span>
327
+ <textarea name="ai_plan" placeholder="Paste or let an AI write the plan...">${escapeHtml(ticket.ai_plan || "")}</textarea>
328
+ </label>
329
+ <label>
330
+ <span>Implementation Summary</span>
331
+ <textarea name="implementation_summary" placeholder="What changed, what shipped, what remains...">${escapeHtml(ticket.implementation_summary || "")}</textarea>
332
+ </label>
333
+ <label>
334
+ <span>Implementation Updates / Lessons</span>
335
+ <textarea name="implementation_updates" placeholder="Progress notes, mistakes to avoid, discoveries...">${escapeHtml(ticket.implementation_updates || "")}</textarea>
336
+ </label>
337
+ <button type="submit">Save Fields</button>
338
+ </form>
339
+ </details>` : ""}
340
+
341
+ <div class="section">
342
+ <h3>Status History</h3>
343
+ ${renderStatusHistory(statusHistory)}
344
+ </div>
345
+
346
+ <div class="section">
347
+ <h3>Comments</h3>
348
+ ${context.comments.map(renderComment).join("") || `<p class="description">No comments yet.</p>`}
349
+ <form class="comment-form" id="detailCommentForm">
350
+ <textarea name="body" placeholder="Add a comment, decision, or instruction..." required></textarea>
351
+ <button type="submit">Comment</button>
352
+ </form>
353
+ </div>
354
+ `
355
+ });
356
+
357
+ wireTicketDetailEditors(ticket);
358
+
359
+ $("#aiFieldsForm")?.addEventListener("submit", async (event) => {
360
+ event.preventDefault();
361
+ const form = new FormData(event.currentTarget);
362
+ await api(withBoardQuery(`/api/tickets/${ticket.id}`), {
363
+ method: "PATCH",
364
+ body: {
365
+ ai_plan: form.get("ai_plan"),
366
+ implementation_summary: form.get("implementation_summary"),
367
+ implementation_updates: form.get("implementation_updates")
368
+ }
369
+ });
370
+ await load();
371
+ state.selectedTicketId = ticket.id;
372
+ state.detailMode = "ticket";
373
+ await renderDetail();
374
+ toast.success("AI fields saved");
375
+ });
376
+
377
+ $("#detailCommentForm").addEventListener("submit", async (event) => {
378
+ event.preventDefault();
379
+ const body = new FormData(event.currentTarget).get("body").trim();
380
+ if (!body) return;
381
+ await api(withBoardQuery(`/api/tickets/${ticket.id}/comments`), {
382
+ method: "POST",
383
+ body: { body }
384
+ });
385
+ await load();
386
+ state.selectedTicketId = ticket.id;
387
+ state.detailMode = "ticket";
388
+ await renderDetail();
389
+ });
390
+
391
+ drawerInner.querySelectorAll("[data-open-ticket]").forEach((card) => {
392
+ card.addEventListener("click", async (event) => {
393
+ if (event.target.closest("[data-detach-child],[data-detach-parent],[data-detach-relation]")) return;
394
+ navigate({
395
+ boardId: state.boardId,
396
+ view: "ticket",
397
+ ticketId: card.dataset.openTicket
398
+ });
399
+ });
400
+ });
401
+
402
+ drawerInner.querySelectorAll("[data-detach-child]").forEach((btn) => {
403
+ btn.addEventListener("click", async (event) => {
404
+ event.stopPropagation();
405
+ const childId = btn.dataset.detachChild;
406
+ try {
407
+ await api(withBoardQuery(`/api/tickets/${childId}`), {
408
+ method: "PATCH",
409
+ body: { parent_ticket_id: null }
410
+ });
411
+ } catch (err) {
412
+ toast.error(err.message || "Failed to remove child");
413
+ return;
414
+ }
415
+ await load();
416
+ state.selectedTicketId = ticket.id;
417
+ state.detailMode = "ticket";
418
+ await renderDetail();
419
+ toast.success("Removed from epic");
420
+ });
421
+ });
422
+
423
+ drawerInner.querySelectorAll("[data-detach-parent]").forEach((btn) => {
424
+ btn.addEventListener("click", async (event) => {
425
+ event.stopPropagation();
426
+ try {
427
+ await api(withBoardQuery(`/api/tickets/${ticket.id}`), {
428
+ method: "PATCH",
429
+ body: { parent_ticket_id: null }
430
+ });
431
+ } catch (err) {
432
+ toast.error(err.message || "Failed to remove parent");
433
+ return;
434
+ }
435
+ await load();
436
+ state.selectedTicketId = ticket.id;
437
+ state.detailMode = "ticket";
438
+ await renderDetail();
439
+ toast.success("Parent epic removed");
440
+ });
441
+ });
442
+
443
+ drawerInner.querySelectorAll("[data-detach-relation]").forEach((btn) => {
444
+ btn.addEventListener("click", async (event) => {
445
+ event.stopPropagation();
446
+ const relationId = btn.dataset.detachRelation;
447
+ try {
448
+ await api(withBoardQuery(`/api/relations/${relationId}`), { method: "DELETE" });
449
+ } catch (err) {
450
+ toast.error(err.message || "Failed to remove relation");
451
+ return;
452
+ }
453
+ await load();
454
+ state.selectedTicketId = ticket.id;
455
+ state.detailMode = "ticket";
456
+ await renderDetail();
457
+ toast.success("Relation removed");
458
+ });
459
+ });
460
+
461
+ const aiPlanEl = drawerInner.querySelector("[data-ai-plan-toggle]");
462
+ if (aiPlanEl) {
463
+ aiPlanEl.addEventListener("toggle", () => {
464
+ const id = aiPlanEl.dataset.ticketId;
465
+ if (aiPlanEl.open) localStorage.setItem(`mab_ai_plan_open:${id}`, "1");
466
+ else localStorage.removeItem(`mab_ai_plan_open:${id}`);
467
+ });
468
+ }
469
+
470
+ const parentEpicForm = drawerInner.querySelector("#parentEpicForm");
471
+ if (parentEpicForm) {
472
+ const parentInput = parentEpicForm.querySelector("[name=target]");
473
+ const parentClear = parentEpicForm.querySelector("[data-parent-epic-clear]");
474
+ const syncParentClear = () => {
475
+ parentEpicForm.classList.toggle("has-value", parentInput.value.length > 0);
476
+ };
477
+ parentInput.addEventListener("input", syncParentClear);
478
+ syncParentClear();
479
+ parentClear.addEventListener("click", () => {
480
+ parentInput.value = "";
481
+ syncParentClear();
482
+ parentInput.focus();
483
+ });
484
+ parentEpicForm.addEventListener("submit", async (event) => {
485
+ event.preventDefault();
486
+ const targetLabel = String(new FormData(event.currentTarget).get("target") || "").trim();
487
+ if (!targetLabel) return;
488
+ const targetId = resolveTicketIdFromLabel(targetLabel, ticket.id);
489
+ if (!targetId) {
490
+ toast.warning("Pick an epic from the suggestions");
491
+ return;
492
+ }
493
+ const target = ticketsForProject().find((t) => t.id === targetId);
494
+ if (!target || canonicalTicketType(target.type) !== "epic") {
495
+ toast.warning("Parent must be an epic");
496
+ return;
497
+ }
498
+ try {
499
+ await api(withBoardQuery(`/api/tickets/${ticket.id}`), {
500
+ method: "PATCH",
501
+ body: { parent_ticket_id: targetId }
502
+ });
503
+ } catch (err) {
504
+ toast.error(err.message || "Failed to set parent epic");
505
+ return;
506
+ }
507
+ await load();
508
+ state.selectedTicketId = ticket.id;
509
+ state.detailMode = "ticket";
510
+ await renderDetail();
511
+ toast.success("Parent epic set");
512
+ });
513
+ }
514
+
515
+ const relatedAddForm = drawerInner.querySelector("#relatedAddForm");
516
+ if (relatedAddForm) {
517
+ const relatedInput = relatedAddForm.querySelector("[name=target]");
518
+ const relatedClear = relatedAddForm.querySelector("[data-related-clear]");
519
+ const syncRelatedClear = () => {
520
+ relatedAddForm.classList.toggle("has-value", relatedInput.value.length > 0);
521
+ };
522
+ relatedInput.addEventListener("input", syncRelatedClear);
523
+ syncRelatedClear();
524
+ relatedClear.addEventListener("click", () => {
525
+ relatedInput.value = "";
526
+ syncRelatedClear();
527
+ relatedInput.focus();
528
+ });
529
+ relatedAddForm.addEventListener("submit", async (event) => {
530
+ event.preventDefault();
531
+ const form = new FormData(event.currentTarget);
532
+ const targetLabel = String(form.get("target") || "").trim();
533
+ const relationType = String(form.get("type") || "relates_to");
534
+ if (!targetLabel) return;
535
+ const targetId = resolveTicketIdFromLabel(targetLabel, ticket.id);
536
+ if (!targetId) {
537
+ toast.warning("Pick a ticket from the suggestions");
538
+ return;
539
+ }
540
+ try {
541
+ await api(withBoardQuery(`/api/relations`), {
542
+ method: "POST",
543
+ body: { source_ticket_id: ticket.id, target_ticket_id: targetId, type: relationType }
544
+ });
545
+ } catch (err) {
546
+ toast.error(err.message || "Failed to add relation");
547
+ return;
548
+ }
549
+ await load();
550
+ state.selectedTicketId = ticket.id;
551
+ state.detailMode = "ticket";
552
+ await renderDetail();
553
+ });
554
+ }
555
+
556
+ openDrawer();
557
+ }
558
+
559
+ function aiPlanOpen(ticketId) {
560
+ return localStorage.getItem(`mab_ai_plan_open:${ticketId}`) === "1";
561
+ }
562
+
563
+ function resolveTicketIdFromLabel(label, selfId) {
564
+ const trimmed = label.trim();
565
+ const match = trimmed.match(/^#?(\d+)\b/);
566
+ const tickets = ticketsForProject().filter((t) => t.id !== selfId);
567
+ if (match) {
568
+ const n = Number(match[1]);
569
+ const hit = tickets.find((t) => t.number === n);
570
+ if (hit) return hit.id;
571
+ }
572
+ const exact = tickets.find((t) => `#${t.number} — ${t.title}` === trimmed);
573
+ return exact ? exact.id : "";
574
+ }
575
+
576
+ function renderStatusHistory(history) {
577
+ if (!history || history.length === 0) {
578
+ return `<p class="description">No status changes yet.</p>`;
579
+ }
580
+ return history
581
+ .map((event) => {
582
+ const body = event.body;
583
+ const from = body.from ? escapeHtml(body.from) : "?";
584
+ const to = escapeHtml(body.to || body.state || body.next_state || "?");
585
+ const actorType = body.actor_type || "human";
586
+ const actorName = escapeHtml(event.actor);
587
+ const actorId = body.actor_id && body.actor_id !== event.actor ? escapeHtml(body.actor_id) : null;
588
+ const byLine =
589
+ actorType === "agent"
590
+ ? `agent · ${actorId ? `${actorName} (${actorId})` : actorName}`
591
+ : `you`;
592
+ return `<div class="comment status-change">
593
+ <div class="comment-meta">
594
+ <span class="status-route">${from} → ${to}</span>
595
+ <span>${byLine} · ${formatDate(event.created_at)}</span>
596
+ </div>
597
+ </div>`;
598
+ })
599
+ .join("");
600
+ }
601
+
602
+ function renderComment(comment) {
603
+ return `
604
+ <div class="comment ${comment.kind === "checkpoint" ? "checkpoint" : ""}">
605
+ <div class="comment-meta">
606
+ <strong>${escapeHtml(comment.author)}</strong>
607
+ <span>${comment.kind === "human_comment" ? "" : `${escapeHtml(comment.kind)} - `}${formatDate(comment.created_at)}</span>
608
+ </div>
609
+ <div class="comment-body">${escapeHtml(comment.body)}</div>
610
+ </div>
611
+ `;
612
+ }
613
+
614
+ function renderRelated(relations = [], ticket) {
615
+ const relatedIds = new Set(relations.map((r) => r.other_ticket.id));
616
+ const hierarchyIds = new Set();
617
+ if (ticket?.parent_ticket_id) hierarchyIds.add(ticket.parent_ticket_id);
618
+ if (ticket?.id) {
619
+ for (const t of ticketsForProject()) {
620
+ if (t.parent_ticket_id === ticket.id) hierarchyIds.add(t.id);
621
+ }
622
+ }
623
+ const candidates = ticketsForProject().filter(
624
+ (t) => t.id !== ticket?.id && !relatedIds.has(t.id) && !hierarchyIds.has(t.id)
625
+ );
626
+ const datalistOptions = candidates
627
+ .map((t) => `<option value="#${t.number} — ${escapeHtml(t.title)}"></option>`)
628
+ .join("");
629
+
630
+ const list = relations.length
631
+ ? `<div class="detail-card-grid">${relations.map((r) => renderRelatedCard(r)).join("")}</div>`
632
+ : "";
633
+
634
+ const form = `
635
+ <form id="relatedAddForm" class="related-add-row">
636
+ <div class="related-add-input-wrap">
637
+ <input type="text" name="target" class="related-add-input meta-inline-input" list="related-ticket-suggestions" placeholder="Add related ticket…" aria-label="Add related ticket" autocomplete="off" />
638
+ <button type="button" class="related-add-clear" data-related-clear aria-label="Clear" tabindex="-1">×</button>
639
+ <datalist id="related-ticket-suggestions">${datalistOptions}</datalist>
640
+ </div>
641
+ <select name="type" class="related-add-type meta-inline" aria-label="Relation type">
642
+ <option value="relates_to">relates to</option>
643
+ <option value="blocks">blocks</option>
644
+ <option value="blocked_by">blocked by</option>
645
+ </select>
646
+ <button type="submit" class="ghost meta-add-btn">Add</button>
647
+ </form>
648
+ `;
649
+
650
+ return list + form;
651
+ }
652
+
653
+ function renderHierarchySection(ticket, context) {
654
+ const isEpic = canonicalTicketType(ticket.type) === "epic";
655
+ if (isEpic) {
656
+ const cards = context.child_tickets || [];
657
+ const body = cards.length
658
+ ? `<div class="detail-card-grid">${cards.map((c) => renderDetailCard(c, { detach: { mode: "child", id: c.id } })).join("")}</div>`
659
+ : `<p class="description">No child features yet. Create a feature / task card and set this epic as its parent.</p>`;
660
+ return `
661
+ <div class="section">
662
+ <h3>Features inside this Epic</h3>
663
+ ${body}
664
+ </div>
665
+ `;
666
+ }
667
+ return "";
668
+ }
669
+
670
+ function renderParentEpicSection(ticket, context) {
671
+ if (canonicalTicketType(ticket.type) === "epic") return "";
672
+
673
+ const epics = ticketsForProject().filter((t) => t.type === "epic" && t.id !== ticket.id);
674
+ const datalistOptions = epics
675
+ .map((t) => `<option value="#${t.number} — ${escapeHtml(t.title)}"></option>`)
676
+ .join("");
677
+
678
+ const body = context.parent_ticket
679
+ ? `<div class="detail-card-grid detail-card-grid--single">${renderDetailCard(context.parent_ticket, { detach: { mode: "parent" } })}</div>`
680
+ : `
681
+ <form id="parentEpicForm" class="related-add-row">
682
+ <div class="related-add-input-wrap">
683
+ <input type="text" name="target" class="related-add-input meta-inline-input" list="parent-epic-suggestions" placeholder="Set parent epic…" aria-label="Set parent epic" autocomplete="off" />
684
+ <button type="button" class="related-add-clear" data-parent-epic-clear aria-label="Clear" tabindex="-1">×</button>
685
+ <datalist id="parent-epic-suggestions">${datalistOptions}</datalist>
686
+ </div>
687
+ <button type="submit" class="ghost meta-add-btn">Set</button>
688
+ </form>
689
+ `;
690
+
691
+ return `
692
+ <div class="section">
693
+ <h3>Epic</h3>
694
+ ${body}
695
+ </div>
696
+ `;
697
+ }
698
+
699
+ export function renderDetailCard(ticket, options = {}) {
700
+ const canonicalType = canonicalTicketType(ticket.type);
701
+ const priorityKey = priorityKeyFor(ticket.priority);
702
+ const typeText = typeLabel(ticket.type).toUpperCase();
703
+ const eyebrow = options.eyebrow
704
+ ? `<span class="card-eyebrow">${escapeHtml(options.eyebrow)}</span>`
705
+ : "";
706
+ let detachBtn = "";
707
+ if (options.detach) {
708
+ const { mode, id } = options.detach;
709
+ let attr = "";
710
+ let label = "";
711
+ if (mode === "child") {
712
+ attr = `data-detach-child="${escapeHtml(id)}"`;
713
+ label = "Remove from epic";
714
+ } else if (mode === "parent") {
715
+ attr = `data-detach-parent="1"`;
716
+ label = "Remove parent epic";
717
+ } else if (mode === "relation") {
718
+ attr = `data-detach-relation="${escapeHtml(id)}"`;
719
+ label = "Remove relation";
720
+ }
721
+ detachBtn = `<button type="button" class="card-detach-btn" ${attr} aria-label="${label}" title="${label}">×</button>`;
722
+ } else if (options.archive) {
723
+ const tid = escapeHtml(options.archive.id);
724
+ const restoreSvg = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/></svg>`;
725
+ const trashSvg = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>`;
726
+ detachBtn = `
727
+ <div class="card-archive-actions">
728
+ <button type="button" class="card-archive-btn card-archive-btn--restore" data-archive-restore="${tid}" aria-label="Restore to board" title="Restore to board">${restoreSvg}</button>
729
+ <button type="button" class="card-archive-btn card-archive-btn--delete" data-archive-delete="${tid}" aria-label="Delete permanently" title="Delete permanently">${trashSvg}</button>
730
+ </div>
731
+ `;
732
+ }
733
+ const unreadN = unreadCount(ticket);
734
+ const unreadDot = unreadN === 0
735
+ ? ""
736
+ : unreadN === 1
737
+ ? `<span class="card-unread-dot" role="status" aria-label="1 unread update"></span>`
738
+ : `<span class="card-unread-dot has-count" role="status" aria-label="${unreadN} unread updates">${unreadN > 99 ? "99+" : unreadN}</span>`;
739
+ return `
740
+ <article class="card card--detail type-${escapeHtml(canonicalType)} priority-${escapeHtml(priorityKey)}"${options.disableOpen ? "" : ` data-open-ticket="${escapeHtml(ticket.id)}"`}>
741
+ ${unreadDot}
742
+ ${detachBtn}
743
+ ${eyebrow}
744
+ <h3>${escapeHtml(ticket.title)}</h3>
745
+ <div class="card-meta">
746
+ <div class="card-expand-trigger card-expand-trigger--static">
747
+ <span class="ticket-number">${escapeHtml(ticketLabel(ticket))}</span>
748
+ <div class="card-type-id type-pill-${escapeHtml(canonicalType)}">
749
+ ${renderTypeIcon(ticket.type)}
750
+ <span class="card-type-label">${escapeHtml(typeText)}</span>
751
+ </div>
752
+ ${state.showPriority ? renderPriorityPill(ticket.priority) : ""}
753
+ </div>
754
+ </div>
755
+ </article>
756
+ `;
757
+ }
758
+
759
+ function renderRelatedCard(relation) {
760
+ const verb = String(relation.type || "").replace(/_/g, " ");
761
+ const direction = relation.direction === "incoming" ? "from" : "to";
762
+ return renderDetailCard(relation.other_ticket, {
763
+ eyebrow: `${verb} ${direction}`,
764
+ detach: { mode: "relation", id: relation.id }
765
+ });
766
+ }