@starlove/orbit 1.0.7 → 1.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 (48) hide show
  1. package/README.md +22 -7
  2. package/SKILL-ORBIT.md +26 -1
  3. package/dist/full/index.html +2 -2
  4. package/dist/full/js/app.a4f045fc.js +676 -0
  5. package/dist/full/js/app.a4f045fc.js.map +7 -0
  6. package/dist/full/{styles.14e9dbbd.css → styles.836872e7.css} +836 -39
  7. package/dist/full/version.json +2 -2
  8. package/docs/AGENT_PROTOCOL.md +23 -5
  9. package/docs/MCP_SETUP.md +5 -3
  10. package/docs/ORBIT_DISPATCH.md +185 -0
  11. package/docs/setup/orbit-init-wizard.md +174 -0
  12. package/package.json +2 -2
  13. package/public/js/actor-labels.js +65 -0
  14. package/public/js/app.js +8 -1
  15. package/public/js/card-actions.js +526 -0
  16. package/public/js/config.js +2 -1
  17. package/public/js/format.js +204 -10
  18. package/public/js/kanban.js +15 -0
  19. package/public/js/local-backend.js +24 -8
  20. package/public/js/router.js +3 -2
  21. package/public/js/search.js +6 -3
  22. package/public/js/settings.js +96 -26
  23. package/public/js/state.js +50 -0
  24. package/public/js/ticket-detail.js +428 -125
  25. package/public/styles.css +836 -39
  26. package/src/cli/orbit.js +217 -32
  27. package/src/core/agent.js +1 -18
  28. package/src/core/agents-md.js +15 -10
  29. package/src/core/attachments.js +238 -0
  30. package/src/core/auth.js +36 -0
  31. package/src/core/backups.js +9 -3
  32. package/src/core/board.js +31 -0
  33. package/src/core/boards.js +1 -1
  34. package/src/core/db.js +42 -2
  35. package/src/core/delete-board.js +87 -21
  36. package/src/core/dispatch.js +532 -0
  37. package/src/core/http.js +31 -11
  38. package/src/core/migrate.js +198 -0
  39. package/src/core/paths.js +6 -0
  40. package/src/core/registry.js +12 -9
  41. package/src/core/router.js +61 -5
  42. package/src/core/search.js +3 -3
  43. package/src/core/snapshots.js +14 -2
  44. package/src/mcp/orbit-client.js +81 -38
  45. package/src/mcp-server.js +190 -6
  46. package/src/server.js +20 -0
  47. package/dist/full/js/app.f4fd5e94.js +0 -604
  48. package/dist/full/js/app.f4fd5e94.js.map +0 -7
@@ -4,7 +4,7 @@
4
4
  // related cards. Also owns the small render helpers (status history,
5
5
  // comments, detail-card) that are only used inside the drawer.
6
6
 
7
- import { state, ticketsForProject, selectableStatesForProject, boardLabelCatalog, currentBoard } from "./state.js";
7
+ import { state, ticketsForProject, selectableStatesForProject, boardLabelCatalog, currentBoard, upsertTicket } from "./state.js";
8
8
  import { drawerInner, drawer, $ } from "./dom.js";
9
9
  import {
10
10
  escapeHtml,
@@ -16,76 +16,151 @@ import {
16
16
  typeLabelLong,
17
17
  priorityLabel,
18
18
  priorityKeyFor,
19
- renderMarkdown
19
+ renderPreservedText,
20
+ stateClassFor,
21
+ cleanText
20
22
  } from "./format.js";
21
- import { renderTypeIcon, renderPriorityPill } from "./kanban.js";
23
+ import { renderTypeIcon, renderPriorityPill, renderBoard } from "./kanban.js";
22
24
  import { renderDrawerShell, openDrawer, closeDrawer } from "./drawer.js";
23
25
  import { navigate } from "./router.js";
24
26
  import { api, withBoardQuery } from "./api.js";
27
+ import { features } from "./config.js";
25
28
  import { toast } from "./toast.js";
26
- import { load } from "./app.js";
29
+ // `load()` is intentionally not used for drawer-local mutations. Reloading
30
+ // /api/bootstrap rebuilds the whole app; these helpers patch the local ticket
31
+ // cache and refresh only the surfaces that actually changed.
27
32
  import { renderProjectDetail } from "./settings.js";
28
33
  import { unreadCount, markRead } from "./unread.js";
34
+ import { formatActorLabel, formatCommentAuthor } from "./actor-labels.js";
29
35
 
30
36
  /**
31
- * PATCH a ticket then reload + re-render the drawer in ticket mode. Lives
32
- * here because every caller is a drawer interaction; api.js stays I/O-only.
37
+ * Refresh the open drawer from the focused ticket context endpoint. When the
38
+ * board card itself changed, also rebuild the board from the patched local
39
+ * cache; otherwise leave the board DOM alone.
33
40
  */
34
- async function patchTicket(ticketId, body) {
35
- await api(withBoardQuery(`/api/tickets/${ticketId}`), { method: "PATCH", body });
36
- await load();
41
+ async function refreshTicketDetail(ticketId, { renderBoardAfter = false } = {}) {
37
42
  state.selectedTicketId = ticketId;
38
43
  state.detailMode = "ticket";
39
- await renderDetail();
44
+ const [context, statusHistory, commentsResult] = await Promise.all([
45
+ api(withBoardQuery(`/api/tickets/${ticketId}/context?depth=1`)),
46
+ api(withBoardQuery(`/api/tickets/${ticketId}/history`)),
47
+ api(withBoardQuery(`/api/tickets/${ticketId}/comments`))
48
+ ]);
49
+ upsertTicket(context.ticket);
50
+ if (renderBoardAfter) renderBoard();
51
+ await renderDetail({ context, statusHistory, comments: commentsResult.comments || [] });
52
+ }
53
+
54
+ /**
55
+ * PATCH a ticket then refresh only the board/detail surfaces that depend on
56
+ * the changed ticket. This avoids the previous full /api/bootstrap reload for
57
+ * every drawer edit.
58
+ */
59
+ async function patchTicket(ticketId, body) {
60
+ await api(withBoardQuery(`/api/tickets/${ticketId}`), { method: "PATCH", body });
61
+ await refreshTicketDetail(ticketId, { renderBoardAfter: true });
40
62
  }
41
63
 
42
64
  /**
43
65
  * Replace a static node with an inline editor; commit on Enter / blur, cancel on Esc.
44
66
  * For multi-line fields, Ctrl/Cmd+Enter commits and plain Enter inserts a newline.
67
+ *
68
+ * Options:
69
+ * - fieldName: short identifier used for aria-label / data attribute (e.g. "title").
70
+ * - multiline: true for <textarea> + Ctrl+Enter commit; false for <input> + Enter commit.
71
+ * - initialValue: text shown in the editor on open.
72
+ * - ariaLabel: full label for screen readers.
73
+ * - emptyMessage: if set, an empty trimmed value triggers a warning toast and cancels.
74
+ * - rerender: async fn called on cancel / no-change to put the static node back.
75
+ * - commit: async fn called with the new value when the user accepts a real change.
76
+ * Receives the *raw* editor value; the caller is responsible for any
77
+ * trimming, cleanText, or PATCH wiring.
45
78
  */
46
- function startInlineEdit(node, field, initialValue, ticketId) {
79
+ export function startInlineEdit(node, opts) {
47
80
  if (!node || node.dataset.editing === "true") return;
81
+ const {
82
+ fieldName,
83
+ multiline = true,
84
+ initialValue = "",
85
+ ariaLabel,
86
+ emptyMessage,
87
+ rerender,
88
+ commit: commitHandler
89
+ } = opts;
90
+
48
91
  node.dataset.editing = "true";
49
92
 
50
- const isTextarea = field === "description";
51
- const editor = document.createElement(isTextarea ? "textarea" : "input");
52
- editor.className = isTextarea ? "inline-desc-editor" : "inline-title-editor";
93
+ const editor = document.createElement(multiline ? "textarea" : "input");
94
+ editor.className = multiline ? "inline-desc-editor" : "inline-title-editor";
53
95
  editor.value = initialValue;
54
- editor.setAttribute("data-editing-field", field);
55
- if (!isTextarea) {
96
+ if (fieldName) editor.setAttribute("data-editing-field", fieldName);
97
+ if (ariaLabel) editor.setAttribute("aria-label", ariaLabel);
98
+ if (!multiline) {
56
99
  editor.type = "text";
57
100
  editor.maxLength = 500;
101
+ editor.autocomplete = "off";
102
+ editor.spellcheck = true;
58
103
  } else {
59
104
  editor.rows = Math.max(3, String(initialValue).split("\n").length + 1);
60
105
  }
61
106
 
62
107
  node.replaceWith(editor);
63
108
  editor.focus();
64
- if (!isTextarea) editor.select();
109
+ if (!multiline) editor.select();
65
110
  else editor.setSelectionRange(editor.value.length, editor.value.length);
66
111
 
67
112
  let done = false;
68
113
 
114
+ const stopWatchingOutsideClicks = () => {
115
+ document.removeEventListener("pointerdown", handleOutsidePointerDown, true);
116
+ };
117
+
118
+ // The title row uses a persistent shell element (#drawerTitle) that
119
+ // renderDrawerShell re-queries by id on every render. Leaving the editor in
120
+ // its place means the next render finds nothing, throws, and the input is
121
+ // stranded on screen until a full reload. Put the original node back before
122
+ // any downstream render runs.
123
+ const restoreNode = () => {
124
+ if (editor.isConnected) editor.replaceWith(node);
125
+ delete node.dataset.editing;
126
+ };
127
+
128
+ const finish = () => {
129
+ done = true;
130
+ stopWatchingOutsideClicks();
131
+ restoreNode();
132
+ };
133
+
134
+ function handleOutsidePointerDown(event) {
135
+ if (done || event.target === editor || editor.contains(event.target)) return;
136
+ // Some drawer/header surfaces are not focusable, so clicking them does not
137
+ // reliably blur the input in every browser. Force the blur path used on
138
+ // commit so the edit styling cannot get stuck.
139
+ editor.blur();
140
+ }
141
+
142
+ document.addEventListener("pointerdown", handleOutsidePointerDown, true);
143
+
69
144
  const commit = async () => {
70
145
  if (done) return;
71
- done = true;
72
- const next = editor.value;
146
+ const next = multiline ? editor.value : editor.value.trim();
147
+ finish();
73
148
  if (String(next) === String(initialValue)) {
74
- await renderDetail();
149
+ if (rerender) await rerender();
75
150
  return;
76
151
  }
77
- if (field === "title" && !next.trim()) {
78
- toast.warning("Title cannot be empty");
79
- await renderDetail();
152
+ if (emptyMessage && !next.trim()) {
153
+ toast.warning(emptyMessage);
154
+ if (rerender) await rerender();
80
155
  return;
81
156
  }
82
- await patchTicket(ticketId, { [field]: next });
157
+ if (commitHandler) await commitHandler(next);
83
158
  };
84
159
 
85
160
  const cancel = async () => {
86
161
  if (done) return;
87
- done = true;
88
- await renderDetail();
162
+ finish();
163
+ if (rerender) await rerender();
89
164
  };
90
165
 
91
166
  editor.addEventListener("blur", commit);
@@ -96,7 +171,7 @@ function startInlineEdit(node, field, initialValue, ticketId) {
96
171
  return;
97
172
  }
98
173
  if (event.key === "Enter") {
99
- if (!isTextarea) {
174
+ if (!multiline) {
100
175
  event.preventDefault();
101
176
  void commit();
102
177
  } else if (event.metaKey || event.ctrlKey) {
@@ -111,34 +186,87 @@ function startInlineEdit(node, field, initialValue, ticketId) {
111
186
  function wireTicketDetailEditors(ticket) {
112
187
  const ticketId = ticket.id;
113
188
 
189
+ const editTitle = () =>
190
+ startInlineEdit(titleEl, {
191
+ fieldName: "title",
192
+ multiline: false,
193
+ initialValue: ticket.title || "",
194
+ ariaLabel: "Edit ticket title",
195
+ emptyMessage: "Title cannot be empty",
196
+ rerender: () => renderDetail(),
197
+ commit: (next) => patchTicket(ticketId, { title: next })
198
+ });
199
+
114
200
  const titleEl = drawer.querySelector('[data-edit-field="title"]');
115
201
  if (titleEl) {
116
- titleEl.addEventListener("click", () => {
117
- startInlineEdit(titleEl, "title", ticket.title || "", ticketId);
118
- });
202
+ titleEl.addEventListener("click", editTitle);
119
203
  titleEl.addEventListener("keydown", (event) => {
120
204
  if (event.key === "Enter" || event.key === " ") {
121
205
  event.preventDefault();
122
- startInlineEdit(titleEl, "title", ticket.title || "", ticketId);
206
+ editTitle();
123
207
  }
124
208
  });
125
209
  }
126
210
 
127
211
  const descEl = drawerInner.querySelector('[data-edit-field="description"]');
212
+ const editDescription = () =>
213
+ startInlineEdit(descEl, {
214
+ fieldName: "description",
215
+ multiline: true,
216
+ initialValue: ticket.description || "",
217
+ ariaLabel: "Edit ticket description",
218
+ rerender: () => renderDetail(),
219
+ commit: (next) => patchTicket(ticketId, { description: cleanText(next) })
220
+ });
221
+
128
222
  if (descEl) {
129
223
  descEl.addEventListener("click", (event) => {
130
224
  event.preventDefault();
131
- startInlineEdit(descEl, "description", ticket.description || "", ticketId);
225
+ editDescription();
132
226
  });
133
227
  descEl.addEventListener("keydown", (event) => {
134
228
  if (event.key === "Enter" || event.key === " ") {
135
229
  event.preventDefault();
136
- startInlineEdit(descEl, "description", ticket.description || "", ticketId);
230
+ editDescription();
137
231
  }
138
232
  });
139
233
  }
140
234
 
141
- drawerInner.querySelectorAll(".meta-select[data-meta-field]").forEach((select) => {
235
+ // AI Plan / Implementation fields: each becomes its own click-to-edit
236
+ // surface so they save independently on blur / Ctrl+Enter, matching the
237
+ // description field. The "ai_plan" / "implementation_summary" /
238
+ // "implementation_updates" data-edit-field selectors are emitted by the
239
+ // detail body template below.
240
+ const AI_FIELDS = [
241
+ { key: "ai_plan", label: "AI plan" },
242
+ { key: "implementation_summary", label: "Implementation summary" },
243
+ { key: "implementation_updates", label: "Implementation updates / lessons" }
244
+ ];
245
+ for (const { key, label } of AI_FIELDS) {
246
+ const fieldEl = drawerInner.querySelector(`[data-edit-field="${key}"]`);
247
+ if (!fieldEl) continue;
248
+ const edit = () =>
249
+ startInlineEdit(fieldEl, {
250
+ fieldName: key,
251
+ multiline: true,
252
+ initialValue: ticket[key] || "",
253
+ ariaLabel: `Edit ${label}`,
254
+ rerender: () => renderDetail(),
255
+ commit: (next) => patchTicket(ticketId, { [key]: cleanText(next) })
256
+ });
257
+ fieldEl.addEventListener("click", (event) => {
258
+ event.preventDefault();
259
+ edit();
260
+ });
261
+ fieldEl.addEventListener("keydown", (event) => {
262
+ if (event.key === "Enter" || event.key === " ") {
263
+ event.preventDefault();
264
+ edit();
265
+ }
266
+ });
267
+ }
268
+
269
+ drawer.querySelectorAll(".meta-select[data-meta-field]").forEach((select) => {
142
270
  select.addEventListener("change", async () => {
143
271
  const field = select.dataset.metaField;
144
272
  if (field === "type") {
@@ -197,7 +325,7 @@ function wireTicketDetailEditors(ticket) {
197
325
  });
198
326
  }
199
327
 
200
- export async function renderDetail() {
328
+ export async function renderDetail(options = {}) {
201
329
  if (state.detailMode === "settings") {
202
330
  await renderProjectDetail();
203
331
  openDrawer();
@@ -209,9 +337,22 @@ export async function renderDetail() {
209
337
  return;
210
338
  }
211
339
 
212
- const [context, statusHistory] = await Promise.all([
213
- api(withBoardQuery(`/api/tickets/${state.selectedTicketId}/context?depth=1`)),
214
- api(withBoardQuery(`/api/tickets/${state.selectedTicketId}/history`))
340
+ const hasOption = (key) => Object.prototype.hasOwnProperty.call(options, key);
341
+ const [context, statusHistory, comments, attachmentList] = await Promise.all([
342
+ hasOption("context")
343
+ ? Promise.resolve(options.context)
344
+ : api(withBoardQuery(`/api/tickets/${state.selectedTicketId}/context?depth=1`)),
345
+ hasOption("statusHistory")
346
+ ? Promise.resolve(options.statusHistory)
347
+ : api(withBoardQuery(`/api/tickets/${state.selectedTicketId}/history`)),
348
+ hasOption("comments")
349
+ ? Promise.resolve(options.comments)
350
+ : api(withBoardQuery(`/api/tickets/${state.selectedTicketId}/comments`)).then((result) => result.comments || []),
351
+ hasOption("attachmentList")
352
+ ? Promise.resolve(options.attachmentList)
353
+ : features.attachments
354
+ ? api(withBoardQuery(`/api/tickets/${state.selectedTicketId}/attachments`)).catch(() => ({ attachments: [] }))
355
+ : Promise.resolve({ attachments: [] })
215
356
  ]);
216
357
  const ticket = context.ticket;
217
358
  // Acknowledge the read receipt: clear the unread dot on this card.
@@ -261,14 +402,20 @@ export async function renderDetail() {
261
402
  const datalistOptions = catalogNames.map((name) => `<option value="${escapeHtml(name)}"></option>`).join("");
262
403
 
263
404
  const detailCanonicalType = canonicalTicketType(ticket.type);
264
- const detailTypeText = typeLabel(ticket.type).toUpperCase();
405
+ const priorityKey = priorityKeyFor(ticket.priority);
406
+ const currentState = states.find((s) => s.id === ticket.state_id);
407
+ const stateClass = stateClassFor(currentState);
408
+ const descriptionHtml = ticket.description ? renderPreservedText(ticket.description) : escapeHtml("No description yet.");
409
+ const descriptionClass = ticket.description
410
+ ? "description preserved-text-body editable-field"
411
+ : "description editable-field is-placeholder";
265
412
  const detailSubtitleHtml = `
266
413
  <span class="ticket-number">${escapeHtml(ticketLabel(ticket))}</span>
267
- <div class="card-type-id type-pill-${escapeHtml(detailCanonicalType)}">
268
- ${renderTypeIcon(ticket.type)}
269
- <span class="card-type-label">${escapeHtml(detailTypeText)}</span>
414
+ <div class="detail-meta-badge-row" aria-label="Ticket metadata controls">
415
+ <select class="detail-meta-badge detail-state-badge state-pill-${escapeHtml(stateClass)} meta-select" data-meta-field="state_id" aria-label="Ticket state">${stateOptions}</select>
416
+ <select class="detail-meta-badge detail-type-badge type-pill-${escapeHtml(detailCanonicalType)} meta-select" data-meta-field="type" aria-label="Ticket type">${typeOptions}</select>
417
+ <select class="detail-meta-badge detail-priority-badge priority-pill priority-pill-${escapeHtml(priorityKey)} meta-select" data-meta-field="priority" aria-label="Ticket priority">${priorityOptions}</select>
270
418
  </div>
271
- ${renderPriorityPill(ticket.priority)}
272
419
  `;
273
420
 
274
421
  renderDrawerShell({
@@ -277,27 +424,17 @@ export async function renderDetail() {
277
424
  titleAttrs: {
278
425
  class: "editable-field",
279
426
  "data-edit-field": "title",
427
+ role: "button",
280
428
  tabindex: "0",
281
- title: "Click to edit"
429
+ title: "Click or press Enter to edit ticket title",
430
+ "aria-label": "Edit ticket title"
282
431
  },
283
432
  subtitleHtml: detailSubtitleHtml,
284
433
  body: `
285
434
  <div class="detail-head">
286
435
  <p class="detail-updated" title="Last saved change">Updated ${escapeHtml(formatDateDetail(ticket.updated_at))}</p>
287
- <div class="description markdown-body editable-field ${ticket.description ? "" : "is-placeholder"}" data-edit-field="description" tabindex="0" title="Click to edit">${ticket.description ? renderMarkdown(ticket.description) : escapeHtml("No description yet.")}</div>
436
+ <div class="${descriptionClass}" data-edit-field="description" tabindex="0" title="Click to edit">${descriptionHtml}</div>
288
437
  <dl class="ticket-meta" id="ticketMetaGrid" data-ticket-id="${escapeHtml(ticket.id)}">
289
- <div class="ticket-meta-row">
290
- <dt>State</dt>
291
- <dd><select class="meta-select meta-inline" data-meta-field="state_id" aria-label="Lane / state">${stateOptions}</select></dd>
292
- </div>
293
- <div class="ticket-meta-row">
294
- <dt>Type</dt>
295
- <dd><select class="meta-select meta-inline" data-meta-field="type" aria-label="Ticket type">${typeOptions}</select></dd>
296
- </div>
297
- <div class="ticket-meta-row">
298
- <dt>Priority</dt>
299
- <dd><select class="meta-select meta-inline" data-meta-field="priority" aria-label="Priority">${priorityOptions}</select></dd>
300
- </div>
301
438
  <div class="ticket-meta-row ticket-meta-row--stack">
302
439
  <dt>Labels</dt>
303
440
  <dd>
@@ -314,6 +451,8 @@ export async function renderDetail() {
314
451
 
315
452
  ${renderHierarchySection(ticket, context)}
316
453
 
454
+ ${features.attachments ? renderAttachmentSection(ticket, attachmentList.attachments || []) : ""}
455
+
317
456
  ${renderParentEpicSection(ticket, context)}
318
457
 
319
458
  <div class="section">
@@ -324,21 +463,26 @@ export async function renderDetail() {
324
463
  ${currentBoard()?.ai_enabled !== 0 ? `
325
464
  <details class="section ai-fields" data-ai-plan-toggle data-ticket-id="${escapeHtml(ticket.id)}"${aiPlanOpen(ticket.id) ? " open" : ""}>
326
465
  <summary><h3>AI Plan / Implementation Record</h3></summary>
327
- <form id="aiFieldsForm" class="field-form">
328
- <label>
329
- <span>AI-Written Plan</span>
330
- <textarea name="ai_plan" placeholder="Paste or let an AI write the plan...">${escapeHtml(ticket.ai_plan || "")}</textarea>
331
- </label>
332
- <label>
333
- <span>Implementation Summary</span>
334
- <textarea name="implementation_summary" placeholder="What changed, what shipped, what remains...">${escapeHtml(ticket.implementation_summary || "")}</textarea>
335
- </label>
336
- <label>
337
- <span>Implementation Updates / Lessons</span>
338
- <textarea name="implementation_updates" placeholder="Progress notes, mistakes to avoid, discoveries...">${escapeHtml(ticket.implementation_updates || "")}</textarea>
339
- </label>
340
- <button type="submit">Save Fields</button>
341
- </form>
466
+ <div class="ai-fields-grid">
467
+ ${renderInlinePreservedTextField({
468
+ fieldName: "ai_plan",
469
+ label: "AI-Written Plan",
470
+ value: ticket.ai_plan,
471
+ placeholder: "Paste or let an AI write the plan..."
472
+ })}
473
+ ${renderInlinePreservedTextField({
474
+ fieldName: "implementation_summary",
475
+ label: "Implementation Summary",
476
+ value: ticket.implementation_summary,
477
+ placeholder: "What changed, what shipped, what remains..."
478
+ })}
479
+ ${renderInlinePreservedTextField({
480
+ fieldName: "implementation_updates",
481
+ label: "Implementation Updates / Lessons",
482
+ value: ticket.implementation_updates,
483
+ placeholder: "Progress notes, mistakes to avoid, discoveries..."
484
+ })}
485
+ </div>
342
486
  </details>` : ""}
343
487
 
344
488
  <div class="section">
@@ -348,7 +492,7 @@ export async function renderDetail() {
348
492
 
349
493
  <div class="section">
350
494
  <h3>Comments</h3>
351
- ${context.comments.map(renderComment).join("") || `<p class="description">No comments yet.</p>`}
495
+ ${comments.map(renderComment).join("") || `<p class="description">No comments yet.</p>`}
352
496
  <form class="comment-form" id="detailCommentForm">
353
497
  <textarea name="body" placeholder="Add a comment, decision, or instruction..." required></textarea>
354
498
  <button type="submit">Comment</button>
@@ -358,37 +502,17 @@ export async function renderDetail() {
358
502
  });
359
503
 
360
504
  wireTicketDetailEditors(ticket);
361
-
362
- $("#aiFieldsForm")?.addEventListener("submit", async (event) => {
363
- event.preventDefault();
364
- const form = new FormData(event.currentTarget);
365
- await api(withBoardQuery(`/api/tickets/${ticket.id}`), {
366
- method: "PATCH",
367
- body: {
368
- ai_plan: form.get("ai_plan"),
369
- implementation_summary: form.get("implementation_summary"),
370
- implementation_updates: form.get("implementation_updates")
371
- }
372
- });
373
- await load();
374
- state.selectedTicketId = ticket.id;
375
- state.detailMode = "ticket";
376
- await renderDetail();
377
- toast.success("AI fields saved");
378
- });
505
+ if (features.attachments) wireAttachmentControls(ticket);
379
506
 
380
507
  $("#detailCommentForm").addEventListener("submit", async (event) => {
381
508
  event.preventDefault();
382
- const body = new FormData(event.currentTarget).get("body").trim();
509
+ const body = cleanText(new FormData(event.currentTarget).get("body")).trim();
383
510
  if (!body) return;
384
511
  await api(withBoardQuery(`/api/tickets/${ticket.id}/comments`), {
385
512
  method: "POST",
386
513
  body: { body }
387
514
  });
388
- await load();
389
- state.selectedTicketId = ticket.id;
390
- state.detailMode = "ticket";
391
- await renderDetail();
515
+ await refreshTicketDetail(ticket.id);
392
516
  });
393
517
 
394
518
  drawerInner.querySelectorAll("[data-open-ticket]").forEach((card) => {
@@ -415,10 +539,7 @@ export async function renderDetail() {
415
539
  toast.error(err.message || "Failed to remove child");
416
540
  return;
417
541
  }
418
- await load();
419
- state.selectedTicketId = ticket.id;
420
- state.detailMode = "ticket";
421
- await renderDetail();
542
+ await refreshTicketDetail(ticket.id, { renderBoardAfter: true });
422
543
  toast.success("Removed from epic");
423
544
  });
424
545
  });
@@ -435,10 +556,7 @@ export async function renderDetail() {
435
556
  toast.error(err.message || "Failed to remove parent");
436
557
  return;
437
558
  }
438
- await load();
439
- state.selectedTicketId = ticket.id;
440
- state.detailMode = "ticket";
441
- await renderDetail();
559
+ await refreshTicketDetail(ticket.id, { renderBoardAfter: true });
442
560
  toast.success("Parent epic removed");
443
561
  });
444
562
  });
@@ -453,10 +571,7 @@ export async function renderDetail() {
453
571
  toast.error(err.message || "Failed to remove relation");
454
572
  return;
455
573
  }
456
- await load();
457
- state.selectedTicketId = ticket.id;
458
- state.detailMode = "ticket";
459
- await renderDetail();
574
+ await refreshTicketDetail(ticket.id);
460
575
  toast.success("Relation removed");
461
576
  });
462
577
  });
@@ -507,10 +622,7 @@ export async function renderDetail() {
507
622
  toast.error(err.message || "Failed to set parent epic");
508
623
  return;
509
624
  }
510
- await load();
511
- state.selectedTicketId = ticket.id;
512
- state.detailMode = "ticket";
513
- await renderDetail();
625
+ await refreshTicketDetail(ticket.id, { renderBoardAfter: true });
514
626
  toast.success("Parent epic set");
515
627
  });
516
628
  }
@@ -549,10 +661,7 @@ export async function renderDetail() {
549
661
  toast.error(err.message || "Failed to add relation");
550
662
  return;
551
663
  }
552
- await load();
553
- state.selectedTicketId = ticket.id;
554
- state.detailMode = "ticket";
555
- await renderDetail();
664
+ await refreshTicketDetail(ticket.id);
556
665
  });
557
666
  }
558
667
 
@@ -563,6 +672,172 @@ function aiPlanOpen(ticketId) {
563
672
  return localStorage.getItem(`mab_ai_plan_open:${ticketId}`) === "1";
564
673
  }
565
674
 
675
+ function renderAttachmentSection(ticket, attachments = []) {
676
+ const cards = attachments.length
677
+ ? `<div class="attachment-grid">${attachments.map((attachment) => renderAttachmentCard(ticket, attachment)).join("")}</div>`
678
+ : `<p class="description">No images attached yet.</p>`;
679
+ return `
680
+ <div class="section attachments-section" data-attachment-section data-ticket-id="${escapeHtml(ticket.id)}">
681
+ <div class="attachment-heading-row">
682
+ <h3>Images</h3>
683
+ <label class="ghost attachment-upload-button">
684
+ Upload
685
+ <input type="file" accept="image/*" multiple data-attachment-input />
686
+ </label>
687
+ </div>
688
+ <div class="attachment-dropzone" data-attachment-dropzone tabindex="0">
689
+ <strong>Paste, drag, or upload images</strong>
690
+ <span>PNG, JPG, GIF, WebP, SVG, BMP, or AVIF up to 10 MB each.</span>
691
+ </div>
692
+ ${cards}
693
+ </div>
694
+ `;
695
+ }
696
+
697
+ function renderAttachmentCard(ticket, attachment) {
698
+ const name = escapeHtml(attachment.original_name || "image");
699
+ const meta = `${formatBytes(attachment.size_bytes)} · ${escapeHtml(attachment.mime_type || "image")}`;
700
+ if (attachment.missing) {
701
+ return `
702
+ <article class="attachment-card is-missing">
703
+ <div class="attachment-missing-thumb">Missing</div>
704
+ <div class="attachment-card-meta"><strong>${name}</strong><span>${meta}</span></div>
705
+ <button type="button" class="attachment-delete" data-delete-attachment="${escapeHtml(attachment.id)}" aria-label="Remove missing image">×</button>
706
+ </article>
707
+ `;
708
+ }
709
+ const src = withBoardQuery(attachment.content_url || `/api/tickets/${ticket.id}/attachments/${attachment.id}/content`);
710
+ return `
711
+ <article class="attachment-card">
712
+ <button type="button" class="attachment-thumb-button" data-open-lightbox="${escapeHtml(src)}" data-lightbox-title="${name}">
713
+ <img src="${escapeHtml(src)}" alt="${name}" loading="lazy" />
714
+ </button>
715
+ <div class="attachment-card-meta"><strong>${name}</strong><span>${meta}</span></div>
716
+ <button type="button" class="attachment-delete" data-delete-attachment="${escapeHtml(attachment.id)}" aria-label="Remove image">×</button>
717
+ </article>
718
+ `;
719
+ }
720
+
721
+ function formatBytes(value) {
722
+ const bytes = Number(value || 0);
723
+ if (bytes < 1024) return `${bytes} B`;
724
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
725
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
726
+ }
727
+
728
+ function imageFilesFromList(files) {
729
+ return [...(files || [])].filter((file) => file && String(file.type || "").startsWith("image/"));
730
+ }
731
+
732
+ async function uploadAttachmentFiles(ticket, files) {
733
+ const imageFiles = imageFilesFromList(files);
734
+ if (!imageFiles.length) {
735
+ toast.warning("No image files found");
736
+ return;
737
+ }
738
+ for (const file of imageFiles) {
739
+ const response = await fetch(withBoardQuery(`/api/tickets/${ticket.id}/attachments?filename=${encodeURIComponent(file.name || "image")}`), {
740
+ method: "POST",
741
+ headers: {
742
+ "Content-Type": file.type || "application/octet-stream",
743
+ "X-File-Name": file.name || "image"
744
+ },
745
+ body: file
746
+ });
747
+ if (!response.ok) {
748
+ let payload = {};
749
+ try { payload = await response.json(); } catch {}
750
+ throw new Error(payload.error || `Upload failed (${response.status})`);
751
+ }
752
+ }
753
+ await refreshTicketDetail(ticket.id);
754
+ toast.success(imageFiles.length === 1 ? "Image attached" : `${imageFiles.length} images attached`);
755
+ }
756
+
757
+ function wireAttachmentControls(ticket) {
758
+ const section = drawerInner.querySelector("[data-attachment-section]");
759
+ if (!section) return;
760
+ const input = section.querySelector("[data-attachment-input]");
761
+ input?.addEventListener("change", async () => {
762
+ try {
763
+ await uploadAttachmentFiles(ticket, input.files);
764
+ input.value = "";
765
+ } catch (err) {
766
+ toast.error(err.message || "Upload failed");
767
+ }
768
+ });
769
+
770
+ const dropzone = section.querySelector("[data-attachment-dropzone]");
771
+ dropzone?.addEventListener("dragover", (event) => {
772
+ event.preventDefault();
773
+ dropzone.classList.add("is-dragover");
774
+ });
775
+ dropzone?.addEventListener("dragleave", () => dropzone.classList.remove("is-dragover"));
776
+ dropzone?.addEventListener("drop", async (event) => {
777
+ event.preventDefault();
778
+ dropzone.classList.remove("is-dragover");
779
+ try {
780
+ await uploadAttachmentFiles(ticket, event.dataTransfer?.files || []);
781
+ } catch (err) {
782
+ toast.error(err.message || "Upload failed");
783
+ }
784
+ });
785
+
786
+ drawerInner.onpaste = async (event) => {
787
+ if (event.target.closest("input, textarea, [contenteditable='true']")) return;
788
+ const files = imageFilesFromList(event.clipboardData?.files || []);
789
+ if (!files.length) return;
790
+ event.preventDefault();
791
+ try {
792
+ await uploadAttachmentFiles(ticket, files);
793
+ } catch (err) {
794
+ toast.error(err.message || "Paste upload failed");
795
+ }
796
+ };
797
+
798
+ section.querySelectorAll("[data-delete-attachment]").forEach((btn) => {
799
+ btn.addEventListener("click", async () => {
800
+ try {
801
+ await api(withBoardQuery(`/api/tickets/${ticket.id}/attachments/${btn.dataset.deleteAttachment}`), { method: "DELETE" });
802
+ await refreshTicketDetail(ticket.id);
803
+ toast.success("Image removed");
804
+ } catch (err) {
805
+ toast.error(err.message || "Delete failed");
806
+ }
807
+ });
808
+ });
809
+
810
+ section.querySelectorAll("[data-open-lightbox]").forEach((btn) => {
811
+ btn.addEventListener("click", () => openAttachmentLightbox(btn.dataset.openLightbox, btn.dataset.lightboxTitle || "Image"));
812
+ });
813
+ }
814
+
815
+ function openAttachmentLightbox(src, title) {
816
+ const existing = document.querySelector(".attachment-lightbox");
817
+ existing?.remove();
818
+ const overlay = document.createElement("div");
819
+ overlay.className = "attachment-lightbox";
820
+ overlay.innerHTML = `
821
+ <div class="attachment-lightbox-panel" role="dialog" aria-modal="true" aria-label="${escapeHtml(title)}">
822
+ <button type="button" class="attachment-lightbox-close" aria-label="Close image">×</button>
823
+ <img src="${escapeHtml(src)}" alt="${escapeHtml(title)}" />
824
+ <p>${escapeHtml(title)}</p>
825
+ </div>
826
+ `;
827
+ const close = () => {
828
+ document.removeEventListener("keydown", onKeyDown);
829
+ overlay.remove();
830
+ };
831
+ function onKeyDown(event) {
832
+ if (event.key === "Escape") close();
833
+ }
834
+ overlay.addEventListener("click", (event) => {
835
+ if (event.target === overlay || event.target.closest(".attachment-lightbox-close")) close();
836
+ });
837
+ document.addEventListener("keydown", onKeyDown);
838
+ document.body.append(overlay);
839
+ }
840
+
566
841
  function resolveTicketIdFromLabel(label, selfId) {
567
842
  const trimmed = label.trim();
568
843
  const match = trimmed.match(/^#?(\d+)\b/);
@@ -585,13 +860,13 @@ function renderStatusHistory(history) {
585
860
  const body = event.body;
586
861
  const from = body.from ? escapeHtml(body.from) : "?";
587
862
  const to = escapeHtml(body.to || body.state || body.next_state || "?");
588
- const actorType = body.actor_type || "human";
589
- const actorName = escapeHtml(event.actor);
590
- const actorId = body.actor_id && body.actor_id !== event.actor ? escapeHtml(body.actor_id) : null;
591
- const byLine =
592
- actorType === "agent"
593
- ? `agent · ${actorId ? `${actorName} (${actorId})` : actorName}`
594
- : `you`;
863
+ const byLine = escapeHtml(
864
+ formatActorLabel({
865
+ actor: event.actor,
866
+ actorType: body.actor_type || "human",
867
+ actorId: body.actor_id
868
+ })
869
+ );
595
870
  return `<div class="comment status-change">
596
871
  <div class="comment-meta">
597
872
  <span class="status-route">${from} → ${to}</span>
@@ -606,10 +881,36 @@ function renderComment(comment) {
606
881
  return `
607
882
  <div class="comment ${comment.kind === "checkpoint" ? "checkpoint" : ""}">
608
883
  <div class="comment-meta">
609
- <strong>${escapeHtml(comment.author)}</strong>
884
+ <strong>${escapeHtml(formatCommentAuthor(comment))}</strong>
610
885
  <span>${comment.kind === "human_comment" ? "" : `${escapeHtml(comment.kind)} - `}${formatDate(comment.created_at)}</span>
611
886
  </div>
612
- <div class="comment-body">${escapeHtml(comment.body)}</div>
887
+ <div class="comment-body preserved-text-body">${renderPreservedText(comment.body)}</div>
888
+ </div>
889
+ `;
890
+ }
891
+
892
+ /**
893
+ * Click-to-edit row for AI Plan / Implementation fields and any other
894
+ * long-form ticket field that should render literal read-only text and become
895
+ * a textarea on click. The wiring (event handlers + commit) lives in
896
+ * wireTicketDetailEditors so this helper stays a pure template.
897
+ */
898
+ export function renderInlinePreservedTextField({ fieldName, label, value, placeholder }) {
899
+ const text = value || "";
900
+ const hasValue = Boolean(text.trim());
901
+ const inner = hasValue ? renderPreservedText(text) : escapeHtml(placeholder || "");
902
+ const placeholderClass = hasValue ? "" : "is-placeholder";
903
+ return `
904
+ <div class="inline-md-field">
905
+ <span class="inline-md-field-label">${escapeHtml(label)}</span>
906
+ <div
907
+ class="inline-md-field-body preserved-text-body editable-field ${placeholderClass}"
908
+ data-edit-field="${escapeHtml(fieldName)}"
909
+ tabindex="0"
910
+ title="Click to edit"
911
+ role="button"
912
+ aria-label="Edit ${escapeHtml(label)}"
913
+ >${inner}</div>
613
914
  </div>
614
915
  `;
615
916
  }
@@ -702,6 +1003,7 @@ function renderParentEpicSection(ticket, context) {
702
1003
  export function renderDetailCard(ticket, options = {}) {
703
1004
  const canonicalType = canonicalTicketType(ticket.type);
704
1005
  const priorityKey = priorityKeyFor(ticket.priority);
1006
+ const stateClass = stateClassFor(ticket);
705
1007
  const typeText = typeLabel(ticket.type).toUpperCase();
706
1008
  const eyebrow = options.eyebrow
707
1009
  ? `<span class="card-eyebrow">${escapeHtml(options.eyebrow)}</span>`
@@ -752,6 +1054,7 @@ export function renderDetailCard(ticket, options = {}) {
752
1054
  ${renderTypeIcon(ticket.type)}
753
1055
  <span class="card-type-label">${escapeHtml(typeText)}</span>
754
1056
  </div>
1057
+ <span class="detail-card-state state-pill-${escapeHtml(stateClass)}">${escapeHtml(ticket.state_name || "State")}</span>
755
1058
  ${state.showPriority ? renderPriorityPill(ticket.priority) : ""}
756
1059
  </div>
757
1060
  </div>