@starlove/orbit 1.3.5 → 1.4.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 (129) hide show
  1. package/Dockerfile +2 -1
  2. package/README.md +10 -9
  3. package/SKILL-ORBIT.md +41 -17
  4. package/dist/full/fonts/Chakra_Petch/ChakraPetch-Bold.ttf +0 -0
  5. package/dist/full/fonts/Chakra_Petch/ChakraPetch-BoldItalic.ttf +0 -0
  6. package/dist/full/fonts/Chakra_Petch/ChakraPetch-Italic.ttf +0 -0
  7. package/dist/full/fonts/Chakra_Petch/ChakraPetch-Light.ttf +0 -0
  8. package/dist/full/fonts/Chakra_Petch/ChakraPetch-LightItalic.ttf +0 -0
  9. package/dist/full/fonts/Chakra_Petch/ChakraPetch-Medium.ttf +0 -0
  10. package/dist/full/fonts/Chakra_Petch/ChakraPetch-MediumItalic.ttf +0 -0
  11. package/dist/full/fonts/Chakra_Petch/ChakraPetch-Regular.ttf +0 -0
  12. package/dist/full/fonts/Chakra_Petch/ChakraPetch-SemiBold.ttf +0 -0
  13. package/dist/full/fonts/Chakra_Petch/ChakraPetch-SemiBoldItalic.ttf +0 -0
  14. package/dist/full/fonts/Chakra_Petch/OFL.txt +93 -0
  15. package/dist/full/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf +0 -0
  16. package/dist/full/fonts/Inter/Inter-VariableFont_opsz,wght.ttf +0 -0
  17. package/dist/full/fonts/Inter/OFL.txt +93 -0
  18. package/dist/full/fonts/Inter/README.txt +118 -0
  19. package/dist/full/fonts/JetBrains_Mono/JetBrainsMono-Italic-VariableFont_wght.ttf +0 -0
  20. package/dist/full/fonts/JetBrains_Mono/JetBrainsMono-VariableFont_wght.ttf +0 -0
  21. package/dist/full/fonts/JetBrains_Mono/OFL.txt +93 -0
  22. package/dist/full/fonts/JetBrains_Mono/README.txt +79 -0
  23. package/dist/full/fonts/OpenRing-Regular.ttf +0 -0
  24. package/dist/full/fonts/Open_Sans/OFL.txt +93 -0
  25. package/dist/full/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf +0 -0
  26. package/dist/full/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf +0 -0
  27. package/dist/full/fonts/Open_Sans/README.txt +100 -0
  28. package/dist/full/fonts/Orbitron/OFL.txt +93 -0
  29. package/dist/full/fonts/Orbitron/Orbitron-VariableFont_wght.ttf +0 -0
  30. package/dist/full/fonts/Orbitron/README.txt +68 -0
  31. package/dist/full/fonts/Rajdhani/OFL.txt +93 -0
  32. package/dist/full/fonts/Rajdhani/Rajdhani-Bold.ttf +0 -0
  33. package/dist/full/fonts/Rajdhani/Rajdhani-Light.ttf +0 -0
  34. package/dist/full/fonts/Rajdhani/Rajdhani-Medium.ttf +0 -0
  35. package/dist/full/fonts/Rajdhani/Rajdhani-Regular.ttf +0 -0
  36. package/dist/full/fonts/Rajdhani/Rajdhani-SemiBold.ttf +0 -0
  37. package/dist/full/fonts/runescape-uf.ttf +0 -0
  38. package/dist/full/index.html +135 -25
  39. package/dist/full/js/app.4e6fbe20.js +952 -0
  40. package/dist/full/js/app.4e6fbe20.js.map +7 -0
  41. package/dist/full/styles.4e60d824.css +1 -0
  42. package/dist/full/version.json +2 -2
  43. package/docs/AGENT_PROTOCOL.md +20 -13
  44. package/docs/ORBIT_DISPATCH.md +73 -38
  45. package/docs/UPDATING.md +114 -0
  46. package/package.json +5 -7
  47. package/public/fonts/Chakra_Petch/ChakraPetch-Bold.ttf +0 -0
  48. package/public/fonts/Chakra_Petch/ChakraPetch-BoldItalic.ttf +0 -0
  49. package/public/fonts/Chakra_Petch/ChakraPetch-Italic.ttf +0 -0
  50. package/public/fonts/Chakra_Petch/ChakraPetch-Light.ttf +0 -0
  51. package/public/fonts/Chakra_Petch/ChakraPetch-LightItalic.ttf +0 -0
  52. package/public/fonts/Chakra_Petch/ChakraPetch-Medium.ttf +0 -0
  53. package/public/fonts/Chakra_Petch/ChakraPetch-MediumItalic.ttf +0 -0
  54. package/public/fonts/Chakra_Petch/ChakraPetch-Regular.ttf +0 -0
  55. package/public/fonts/Chakra_Petch/ChakraPetch-SemiBold.ttf +0 -0
  56. package/public/fonts/Chakra_Petch/ChakraPetch-SemiBoldItalic.ttf +0 -0
  57. package/public/fonts/Chakra_Petch/OFL.txt +93 -0
  58. package/public/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf +0 -0
  59. package/public/fonts/Inter/Inter-VariableFont_opsz,wght.ttf +0 -0
  60. package/public/fonts/Inter/OFL.txt +93 -0
  61. package/public/fonts/Inter/README.txt +118 -0
  62. package/public/fonts/JetBrains_Mono/JetBrainsMono-Italic-VariableFont_wght.ttf +0 -0
  63. package/public/fonts/JetBrains_Mono/JetBrainsMono-VariableFont_wght.ttf +0 -0
  64. package/public/fonts/JetBrains_Mono/OFL.txt +93 -0
  65. package/public/fonts/JetBrains_Mono/README.txt +79 -0
  66. package/public/fonts/OpenRing-Regular.ttf +0 -0
  67. package/public/fonts/Open_Sans/OFL.txt +93 -0
  68. package/public/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf +0 -0
  69. package/public/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf +0 -0
  70. package/public/fonts/Open_Sans/README.txt +100 -0
  71. package/public/fonts/Orbitron/OFL.txt +93 -0
  72. package/public/fonts/Orbitron/Orbitron-VariableFont_wght.ttf +0 -0
  73. package/public/fonts/Orbitron/README.txt +68 -0
  74. package/public/fonts/Rajdhani/OFL.txt +93 -0
  75. package/public/fonts/Rajdhani/Rajdhani-Bold.ttf +0 -0
  76. package/public/fonts/Rajdhani/Rajdhani-Light.ttf +0 -0
  77. package/public/fonts/Rajdhani/Rajdhani-Medium.ttf +0 -0
  78. package/public/fonts/Rajdhani/Rajdhani-Regular.ttf +0 -0
  79. package/public/fonts/Rajdhani/Rajdhani-SemiBold.ttf +0 -0
  80. package/public/fonts/runescape-uf.ttf +0 -0
  81. package/public/index.html +133 -23
  82. package/public/js/artifact-drawer.js +79 -0
  83. package/public/js/board-export.js +161 -0
  84. package/public/js/board-menu.js +219 -17
  85. package/public/js/card-actions.js +1 -1
  86. package/public/js/create-card.js +60 -23
  87. package/public/js/drawer.js +164 -4
  88. package/public/js/font-preference.js +230 -0
  89. package/public/js/format.js +0 -10
  90. package/public/js/icons.js +16 -0
  91. package/public/js/local-backend.js +31 -4
  92. package/public/js/main.js +7 -1
  93. package/public/js/modal.js +3 -1
  94. package/public/js/panel.js +58 -0
  95. package/public/js/settings-tabs.js +1 -1
  96. package/public/js/settings.js +606 -275
  97. package/public/js/sse.js +3 -0
  98. package/public/js/state.js +1 -1
  99. package/public/js/text-editing.js +128 -0
  100. package/public/js/ticket-detail.js +377 -148
  101. package/public/js/ticket-typeahead.js +49 -0
  102. package/public/js/toast.js +19 -4
  103. package/public/js/topbar-notes.js +3 -3
  104. package/public/js/typeahead.js +206 -0
  105. package/public/js/update-check.js +58 -0
  106. package/public/styles.css +5447 -3572
  107. package/src/cli/orbit.js +9 -5
  108. package/src/core/agent.js +12 -4
  109. package/src/core/agents-md.js +3 -3
  110. package/src/core/artifacts.js +149 -0
  111. package/src/core/board.js +3 -2
  112. package/src/core/boards.js +0 -1
  113. package/src/core/css-bundle.js +1 -0
  114. package/src/core/db.js +6 -0
  115. package/src/core/dispatch.js +344 -34
  116. package/src/core/http.js +9 -2
  117. package/src/core/paths.js +26 -2
  118. package/src/core/router.js +33 -0
  119. package/src/core/search.js +3 -2
  120. package/src/core/snapshots.js +10 -5
  121. package/src/core/tickets.js +34 -3
  122. package/src/core/util.js +3 -2
  123. package/src/core/version-check.js +154 -0
  124. package/src/mcp/orbit-client.js +18 -2
  125. package/src/mcp-server.js +19 -14
  126. package/src/server.js +28 -3
  127. package/dist/full/js/app.4ae80764.js +0 -742
  128. package/dist/full/js/app.4ae80764.js.map +0 -7
  129. package/dist/full/styles.b9e0c130.css +0 -1
@@ -39,6 +39,12 @@ import { toast } from "./toast.js";
39
39
  import { renderProjectDetail } from "./settings.js";
40
40
  import { unreadCount, markRead } from "./unread.js";
41
41
  import { formatActorLabel, formatCommentAuthor } from "./actor-labels.js";
42
+ import { openArtifactDrawer } from "./artifact-drawer.js";
43
+ import { closeIconSvg } from "./icons.js";
44
+ import { handleTextareaIndentationKeydown } from "./text-editing.js";
45
+ import { renderPanel, renderTogglePanel } from "./panel.js";
46
+ import { attachTypeahead } from "./typeahead.js";
47
+ import { attachTicketTypeahead } from "./ticket-typeahead.js";
42
48
 
43
49
  /**
44
50
  * Refresh the open drawer from the focused ticket context endpoint. When the
@@ -273,6 +279,7 @@ export function startInlineEdit(node, opts) {
273
279
 
274
280
  editor.addEventListener("blur", commit);
275
281
  editor.addEventListener("keydown", (event) => {
282
+ if (multiline && handleTextareaIndentationKeydown(event, editor)) return;
276
283
  if (event.key === "Escape") {
277
284
  event.preventDefault();
278
285
  void cancel();
@@ -290,8 +297,27 @@ export function startInlineEdit(node, opts) {
290
297
  });
291
298
  }
292
299
 
300
+ function promptArtifactPath({ currentPath = "", label = "Artifact" } = {}) {
301
+ const next = window.prompt(
302
+ `Local markdown/text path for ${label} (example: docs/test.md or ./docs/test.md)`,
303
+ currentPath || "docs/",
304
+ );
305
+ if (next === null) return null;
306
+ return cleanText(next).trim();
307
+ }
308
+
309
+ async function promptAndSaveArtifactPath({ ticketId, fieldName, currentPath, label }) {
310
+ if (!ticketId || !fieldName) return null;
311
+ const next = promptArtifactPath({ currentPath, label });
312
+ if (next === null) return null;
313
+ await patchTicket(ticketId, { [fieldName]: next });
314
+ await refreshTicketDetail(ticketId);
315
+ toast.success(next ? "Artifact path updated" : "Artifact path cleared");
316
+ return next;
317
+ }
318
+
293
319
  /** Wire inline editors on the ticket detail header (meta fields, labels). */
294
- function wireTicketDetailEditors(ticket) {
320
+ function wireTicketDetailEditors(ticket, context) {
295
321
  const ticketId = ticket.id;
296
322
 
297
323
  const editTitle = () =>
@@ -347,12 +373,12 @@ function wireTicketDetailEditors(ticket) {
347
373
  // "implementation_updates" data-edit-field selectors are emitted by the
348
374
  // detail body template below.
349
375
  const AI_FIELDS = [
350
- { key: "ai_plan", label: "AI plan" },
351
- { key: "implementation_summary", label: "Implementation summary" },
376
+ { key: "ai_plan", label: "AI Plan" },
377
+ { key: "implementation_summary", label: "Work Summary" },
352
378
  {
353
379
  key: "implementation_updates",
354
- label: "Implementation updates / lessons",
355
- },
380
+ label: "Revisions",
381
+ }
356
382
  ];
357
383
  for (const { key, label } of AI_FIELDS) {
358
384
  const fieldEl = drawerInner.querySelector(`[data-edit-field="${key}"]`);
@@ -434,6 +460,10 @@ function wireTicketDetailEditors(ticket) {
434
460
  ?.addEventListener("click", () => {
435
461
  void addLabel();
436
462
  });
463
+ // Attach the typeahead first so its Enter handler runs before the add-on-Enter
464
+ // handler below; when a suggestion is highlighted it stops propagation so the
465
+ // partial typed text isn't added instead.
466
+ attachLabelTypeahead(drawerInner.querySelector("[data-label-input]"), ticket);
437
467
  drawerInner
438
468
  .querySelector("[data-label-input]")
439
469
  ?.addEventListener("keydown", (event) => {
@@ -514,7 +544,7 @@ export async function renderDetail(options = {}) {
514
544
  )
515
545
  .join("");
516
546
 
517
- const priorityOptions = [0, 1, 2, 3, 4]
547
+ const priorityOptions = [4, 3, 2, 1, 0]
518
548
  .map(
519
549
  (p) =>
520
550
  `<option value="${p}" ${Number(ticket.priority) === p ? "selected" : ""}>${escapeHtml(priorityLabel(p))}</option>`,
@@ -526,19 +556,11 @@ export async function renderDetail(options = {}) {
526
556
  (label) => `
527
557
  <span class="label-pill-removable" style="--label-color: ${escapeHtml(label.color)}">
528
558
  ${escapeHtml(label.name)}
529
- <button type="button" class="label-pill-remove" data-remove-label="${escapeHtml(label.name)}" title="Remove label">×</button>
559
+ <button type="button" class="label-pill-remove" data-remove-label="${escapeHtml(label.name)}" title="Remove label" aria-label="Remove label">${closeIconSvg}</button>
530
560
  </span>`,
531
561
  )
532
562
  .join("");
533
563
 
534
- const catalogNames = boardLabelCatalog()
535
- .map((l) => l.name)
536
- .filter((name) => !ticket.labels?.some((tl) => tl.name === name));
537
-
538
- const datalistOptions = catalogNames
539
- .map((name) => `<option value="${escapeHtml(name)}"></option>`)
540
- .join("");
541
-
542
564
  const detailCanonicalType = canonicalTicketType(ticket.type);
543
565
  const priorityKey = priorityKeyFor(ticket.priority);
544
566
  const currentState = states.find((s) => s.id === ticket.state_id);
@@ -547,8 +569,8 @@ export async function renderDetail(options = {}) {
547
569
  ? renderPreservedText(ticket.description)
548
570
  : escapeHtml("No description yet.");
549
571
  const descriptionClass = ticket.description
550
- ? "description preserved-text-body editable-field"
551
- : "description editable-field is-placeholder";
572
+ ? "description preserved-text-body editable-field detail-description-body"
573
+ : "description editable-field detail-description-body is-placeholder";
552
574
  const detailSubtitleHtml = `
553
575
  <span class="ticket-number">${escapeHtml(ticketLabel(ticket))}</span>
554
576
  <div class="detail-meta-badge-row" aria-label="Ticket metadata controls">
@@ -574,80 +596,103 @@ export async function renderDetail(options = {}) {
574
596
  <div class="detail-head">
575
597
  <p class="detail-updated" title="Last saved change">Updated ${escapeHtml(formatDateDetail(ticket.updated_at))}</p>
576
598
  <div class="${descriptionClass}" data-edit-field="description" tabindex="0" title="Click to edit">${descriptionHtml}</div>
577
- <dl class="ticket-meta" id="ticketMetaGrid" data-ticket-id="${escapeHtml(ticket.id)}">
578
- <div class="ticket-meta-row ticket-meta-row--stack">
579
- <dt>Labels</dt>
580
- <dd>
599
+ </div>
600
+
601
+ <div class="settings-stack detail-section-stack">
602
+ ${renderPanel({
603
+ title: "Labels",
604
+ body: `
605
+ <div class="settings-row">
581
606
  <div class="label-pills-row" data-other-labels>${labelPills}</div>
582
607
  <div class="label-add-row">
583
- <input type="text" class="label-add-input meta-inline-input" data-label-input list="board-label-suggestions" placeholder="Add label…" aria-label="Add label" />
584
- <datalist id="board-label-suggestions">${datalistOptions}</datalist>
585
- <button type="button" class="ghost meta-add-btn" data-label-add>Add</button>
608
+ <div class="label-add-input-wrap">
609
+ <input type="text" class="label-add-input meta-inline-input" data-label-input autocomplete="off" placeholder="Add label…" aria-label="Add label" />
610
+ </div>
611
+ <button type="button" class="meta-add-btn detail-action-btn" data-variant="ghost" data-label-add>Add</button>
586
612
  </div>
587
- </dd>
588
- </div>
589
- </dl>
590
- </div>
591
-
592
- ${renderHierarchySection(ticket, context)}
593
-
594
- ${features.attachments ? renderAttachmentSection(ticket, attachmentList.attachments || []) : ""}
595
-
596
- ${renderParentEpicSection(ticket, context)}
597
-
598
- <div class="section">
599
- <h3>Related</h3>
600
- ${renderRelated(context.relations, ticket)}
601
- </div>
602
-
603
- ${
604
- currentBoard()?.ai_enabled !== 0
605
- ? `
606
- <details class="section ai-fields" data-ai-plan-toggle data-ticket-id="${escapeHtml(ticket.id)}"${aiPlanOpen(ticket.id) ? " open" : ""}>
607
- <summary><h3>AI Plan / Implementation Record</h3></summary>
608
- <div class="ai-fields-grid">
609
- ${renderInlinePreservedTextField({
610
- fieldName: "ai_plan",
611
- label: "AI-Written Plan",
612
- value: ticket.ai_plan,
613
- placeholder: "Paste or let an AI write the plan...",
614
- })}
615
- ${renderInlinePreservedTextField({
616
- fieldName: "implementation_summary",
617
- label: "Implementation Summary",
618
- value: ticket.implementation_summary,
619
- placeholder: "What changed, what shipped, what remains...",
620
- })}
621
- ${renderInlinePreservedTextField({
622
- fieldName: "implementation_updates",
623
- label: "Implementation Updates / Lessons",
624
- value: ticket.implementation_updates,
625
- placeholder: "Progress notes, mistakes to avoid, discoveries...",
626
- })}
627
- </div>
628
- </details>`
629
- : ""
630
- }
631
-
632
- <div class="section">
633
- <h3>Status History</h3>
634
- ${renderStatusHistory(statusHistory)}
635
- </div>
613
+ </div>
614
+ `,
615
+ })}
616
+
617
+ ${renderHierarchySection(ticket, context)}
618
+
619
+ ${features.attachments ? renderAttachmentSection(ticket, attachmentList.attachments || []) : ""}
620
+
621
+ ${renderParentEpicSection(ticket, context)}
622
+
623
+ ${renderPanel({
624
+ title: "Related",
625
+ body: `<div class="settings-row">${renderRelated(context.relations, ticket)}</div>`,
626
+ })}
627
+
628
+ ${
629
+ currentBoard()?.ai_enabled !== 0
630
+ ? renderTogglePanel({
631
+ title: "AI Plan / Implementation Record",
632
+ open: aiPlanOpen(ticket.id),
633
+ attrs: `data-ai-plan-toggle data-ticket-id="${escapeHtml(ticket.id)}"`,
634
+ body: `
635
+ <div class="ai-fields-grid">
636
+ ${renderInlinePreservedTextField({
637
+ fieldName: "ai_plan",
638
+ label: "AI Plan",
639
+ value: ticket.ai_plan,
640
+ placeholder: "What to do: ",
641
+ ticketId: ticket.id,
642
+ artifactPath: ticket.plan_artifact_path,
643
+ artifactFieldName: "plan_artifact_path",
644
+ artifactLabel: "Open full handoff",
645
+ })}
646
+ ${renderInlinePreservedTextField({
647
+ fieldName: "implementation_summary",
648
+ label: "Work Summary",
649
+ value: ticket.implementation_summary,
650
+ placeholder: "What was done: ",
651
+ ticketId: ticket.id,
652
+ artifactPath: ticket.implementation_artifact_path,
653
+ artifactFieldName: "implementation_artifact_path",
654
+ artifactLabel: "Open full report",
655
+ })}
656
+ ${renderInlinePreservedTextField({
657
+ fieldName: "implementation_updates",
658
+ label: "Revisions",
659
+ value: ticket.implementation_updates,
660
+ placeholder: "Changes since initial implementation: ",
661
+ })}
662
+ </div>`,
663
+ })
664
+ : ""
665
+ }
636
666
 
637
- <div class="section">
638
- <h3>Comments</h3>
639
- ${comments.map(renderComment).join("") || `<p class="description">No comments yet.</p>`}
640
- <form class="comment-form" id="detailCommentForm">
641
- <textarea name="body" placeholder="Add a comment, decision, or instruction..." required></textarea>
642
- <button type="submit">Comment</button>
643
- </form>
667
+ ${renderPanel({
668
+ title: "Status History",
669
+ body: `<div class="settings-row">${renderStatusHistory(statusHistory)}</div>`,
670
+ })}
671
+
672
+ ${renderPanel({
673
+ title: "Comments",
674
+ body: `
675
+ <div class="settings-row">
676
+ ${comments.map(renderComment).join("") || `<p class="description">No comments yet.</p>`}
677
+ <form class="comment-form" id="detailCommentForm">
678
+ <textarea name="body" placeholder="Add a comment, decision, or instruction..." required></textarea>
679
+ <button type="submit" class="detail-action-btn" data-variant="ghost">Comment</button>
680
+ </form>
681
+ </div>
682
+ `,
683
+ })}
644
684
  </div>
645
685
  `,
646
686
  });
647
687
 
648
- wireTicketDetailEditors(ticket);
688
+ wireTicketDetailEditors(ticket, context);
649
689
  if (features.attachments) wireAttachmentControls(ticket);
650
690
 
691
+ const commentTextarea = $("#detailCommentForm textarea");
692
+ commentTextarea?.addEventListener("keydown", (event) => {
693
+ handleTextareaIndentationKeydown(event, commentTextarea);
694
+ });
695
+
651
696
  $("#detailCommentForm").addEventListener("submit", async (event) => {
652
697
  event.preventDefault();
653
698
  const body = cleanText(
@@ -661,6 +706,20 @@ export async function renderDetail(options = {}) {
661
706
  await refreshTicketDetail(ticket.id);
662
707
  });
663
708
 
709
+ drawerInner.querySelectorAll("[data-comment-action='toggle-ai-omission']").forEach((button) => {
710
+ button.addEventListener("click", async () => {
711
+ const commentId = button.dataset.commentId;
712
+ if (!commentId) return;
713
+ const omittedForAi = button.dataset.omittedForAi !== "true";
714
+ await api(withBoardQuery(`/api/tickets/${ticket.id}/comments/${commentId}`), {
715
+ method: "PATCH",
716
+ body: { omitted_for_ai: omittedForAi },
717
+ });
718
+ toast.info(omittedForAi ? "Comment omitted from AI context" : "Comment included in AI context");
719
+ await refreshTicketDetail(ticket.id);
720
+ });
721
+ });
722
+
664
723
  drawerInner.querySelectorAll("[data-open-ticket]").forEach((card) => {
665
724
  card.addEventListener("click", async (event) => {
666
725
  if (
@@ -738,12 +797,45 @@ export async function renderDetail(options = {}) {
738
797
  });
739
798
  }
740
799
 
800
+ drawerInner.querySelectorAll("[data-open-artifact]").forEach((btn) => {
801
+ btn.addEventListener("click", async (event) => {
802
+ event.stopPropagation();
803
+ const artifactFieldName = btn.dataset.artifactField;
804
+ const artifactTitle = btn.dataset.artifactTitle || "Linked artifact";
805
+ const artifactTicketId = btn.dataset.artifactTicketId || ticket.id;
806
+ const currentPath = String(btn.dataset.openArtifact || "").trim();
807
+ if (!currentPath) {
808
+ await promptAndSaveArtifactPath({
809
+ ticketId: artifactTicketId,
810
+ fieldName: artifactFieldName,
811
+ currentPath,
812
+ label: artifactTitle,
813
+ });
814
+ return;
815
+ }
816
+ const opened = await openArtifactDrawer({
817
+ ticketId: artifactTicketId,
818
+ path: currentPath,
819
+ title: artifactTitle,
820
+ });
821
+ if (opened === false && artifactFieldName) {
822
+ await promptAndSaveArtifactPath({
823
+ ticketId: artifactTicketId,
824
+ fieldName: artifactFieldName,
825
+ currentPath,
826
+ label: artifactTitle,
827
+ });
828
+ }
829
+ });
830
+ });
831
+
741
832
  const parentEpicForm = drawerInner.querySelector("#parentEpicForm");
742
833
  if (parentEpicForm) {
743
834
  const parentInput = parentEpicForm.querySelector("[name=target]");
744
835
  const parentClear = parentEpicForm.querySelector(
745
836
  "[data-parent-epic-clear]",
746
837
  );
838
+ attachTicketTypeahead(parentInput, () => epicCandidates(ticket));
747
839
  const syncParentClear = () => {
748
840
  parentEpicForm.classList.toggle(
749
841
  "has-value",
@@ -791,6 +883,9 @@ export async function renderDetail(options = {}) {
791
883
  if (relatedAddForm) {
792
884
  const relatedInput = relatedAddForm.querySelector("[name=target]");
793
885
  const relatedClear = relatedAddForm.querySelector("[data-related-clear]");
886
+ attachTicketTypeahead(relatedInput, () =>
887
+ relatedTicketCandidates(context.relations, ticket),
888
+ );
794
889
  const syncRelatedClear = () => {
795
890
  relatedAddForm.classList.toggle(
796
891
  "has-value",
@@ -832,6 +927,53 @@ export async function renderDetail(options = {}) {
832
927
  });
833
928
  }
834
929
 
930
+ // Reverse of "Set parent epic": from an epic, pull a feature/task in as a
931
+ // child by patching that ticket's parent_ticket_id to this epic.
932
+ const epicChildForm = drawerInner.querySelector("#epicChildForm");
933
+ if (epicChildForm) {
934
+ const childInput = epicChildForm.querySelector("[name=target]");
935
+ const childClear = epicChildForm.querySelector("[data-epic-child-clear]");
936
+ const syncChildClear = () => {
937
+ epicChildForm.classList.toggle("has-value", childInput.value.length > 0);
938
+ };
939
+ childInput.addEventListener("input", syncChildClear);
940
+ syncChildClear();
941
+ childClear.addEventListener("click", () => {
942
+ childInput.value = "";
943
+ syncChildClear();
944
+ childInput.focus();
945
+ });
946
+ attachTicketTypeahead(childInput, () => childCandidates(ticket));
947
+ epicChildForm.addEventListener("submit", async (event) => {
948
+ event.preventDefault();
949
+ const targetLabel = String(
950
+ new FormData(event.currentTarget).get("target") || "",
951
+ ).trim();
952
+ if (!targetLabel) return;
953
+ const targetId = resolveTicketIdFromLabel(targetLabel, ticket.id);
954
+ if (!targetId) {
955
+ toast.warning("Pick a ticket from the suggestions");
956
+ return;
957
+ }
958
+ const target = ticketsForProject().find((t) => t.id === targetId);
959
+ if (!target || canonicalTicketType(target.type) === "epic") {
960
+ toast.warning("Epics can't be nested inside an epic");
961
+ return;
962
+ }
963
+ try {
964
+ await api(withBoardQuery(`/api/tickets/${targetId}`), {
965
+ method: "PATCH",
966
+ body: { parent_ticket_id: ticket.id },
967
+ });
968
+ } catch (err) {
969
+ toast.error(err.message || "Failed to add feature");
970
+ return;
971
+ }
972
+ await refreshTicketDetail(ticket.id, { renderBoardAfter: true });
973
+ toast.success("Feature added to epic");
974
+ });
975
+ }
976
+
835
977
  openDrawer();
836
978
  }
837
979
 
@@ -842,23 +984,25 @@ function aiPlanOpen(ticketId) {
842
984
  function renderAttachmentSection(ticket, attachments = []) {
843
985
  const cards = attachments.length
844
986
  ? `<div class="attachment-grid">${attachments.map((attachment) => renderAttachmentCard(ticket, attachment)).join("")}</div>`
845
- : `<p class="description">No images attached yet.</p>`;
846
- return `
847
- <div class="section attachments-section" data-attachment-section data-ticket-id="${escapeHtml(ticket.id)}">
848
- <div class="attachment-heading-row">
849
- <h3>Images</h3>
850
- <label class="ghost attachment-upload-button">
987
+ : `<p class="description attachment-empty-state">No images attached yet.</p>`;
988
+ return renderPanel({
989
+ title: "Images",
990
+ className: "attachments-panel",
991
+ attrs: `data-attachment-section data-ticket-id="${escapeHtml(ticket.id)}"`,
992
+ body: `
993
+ <div class="settings-row">
994
+ <div class="attachment-dropzone" data-attachment-dropzone tabindex="0">
995
+ <strong>Paste, drag, or upload images</strong>
996
+ <span>PNG, JPG, GIF, WebP, SVG, BMP, or AVIF up to 10 MB each.</span>
997
+ </div>
998
+ ${cards}
999
+ <label class="attachment-upload-button detail-action-btn" role="button" data-variant="ghost" tabindex="0">
851
1000
  Upload
852
1001
  <input type="file" accept="image/*" multiple data-attachment-input />
853
1002
  </label>
854
1003
  </div>
855
- <div class="attachment-dropzone" data-attachment-dropzone tabindex="0">
856
- <strong>Paste, drag, or upload images</strong>
857
- <span>PNG, JPG, GIF, WebP, SVG, BMP, or AVIF up to 10 MB each.</span>
858
- </div>
859
- ${cards}
860
- </div>
861
- `;
1004
+ `,
1005
+ });
862
1006
  }
863
1007
 
864
1008
  function renderAttachmentCard(ticket, attachment) {
@@ -869,7 +1013,7 @@ function renderAttachmentCard(ticket, attachment) {
869
1013
  <article class="attachment-card is-missing">
870
1014
  <div class="attachment-missing-thumb">Missing</div>
871
1015
  <div class="attachment-card-meta"><strong>${name}</strong><span>${meta}</span></div>
872
- <button type="button" class="attachment-delete" data-delete-attachment="${escapeHtml(attachment.id)}" aria-label="Remove missing image">×</button>
1016
+ <button type="button" class="attachment-delete" data-delete-attachment="${escapeHtml(attachment.id)}" aria-label="Remove missing image">${closeIconSvg}</button>
873
1017
  </article>
874
1018
  `;
875
1019
  }
@@ -879,11 +1023,11 @@ function renderAttachmentCard(ticket, attachment) {
879
1023
  );
880
1024
  return `
881
1025
  <article class="attachment-card">
882
- <button type="button" class="attachment-thumb-button" data-open-lightbox="${escapeHtml(src)}" data-lightbox-title="${name}">
1026
+ <button type="button" class="attachment-thumb-button" data-no-arc data-open-lightbox="${escapeHtml(src)}" data-lightbox-title="${name}">
883
1027
  <img src="${escapeHtml(src)}" alt="${name}" loading="lazy" />
884
1028
  </button>
885
1029
  <div class="attachment-card-meta"><strong>${name}</strong><span>${meta}</span></div>
886
- <button type="button" class="attachment-delete" data-delete-attachment="${escapeHtml(attachment.id)}" aria-label="Remove image">×</button>
1030
+ <button type="button" class="attachment-delete" data-delete-attachment="${escapeHtml(attachment.id)}" aria-label="Remove image">${closeIconSvg}</button>
887
1031
  </article>
888
1032
  `;
889
1033
  }
@@ -1015,7 +1159,7 @@ function openAttachmentLightbox(src, title) {
1015
1159
  overlay.className = "attachment-lightbox";
1016
1160
  overlay.innerHTML = `
1017
1161
  <div class="attachment-lightbox-panel" role="dialog" aria-modal="true" aria-label="${escapeHtml(title)}">
1018
- <button type="button" class="attachment-lightbox-close" aria-label="Close image">×</button>
1162
+ <button type="button" class="attachment-lightbox-close" aria-label="Close image">${closeIconSvg}</button>
1019
1163
  <img src="${escapeHtml(src)}" alt="${escapeHtml(title)}" />
1020
1164
  <p>${escapeHtml(title)}</p>
1021
1165
  </div>
@@ -1078,13 +1222,36 @@ function renderStatusHistory(history) {
1078
1222
  }
1079
1223
 
1080
1224
  function renderComment(comment) {
1225
+ const aiEnabled = currentBoard()?.ai_enabled !== 0;
1226
+ const omittedForAi = Boolean(comment.omitted_for_ai);
1227
+ const kindLabel =
1228
+ comment.kind === "human_comment" ? "" : escapeHtml(comment.kind.replace(/_/g, " "));
1081
1229
  return `
1082
- <div class="comment ${comment.kind === "checkpoint" ? "checkpoint" : ""}">
1083
- <div class="comment-meta">
1084
- <strong>${escapeHtml(formatCommentAuthor(comment))}</strong>
1085
- <span>${comment.kind === "human_comment" ? "" : `${escapeHtml(comment.kind)} - `}${formatDate(comment.created_at)}</span>
1230
+ <div class="comment ${comment.kind === "checkpoint" ? "checkpoint" : ""} ${aiEnabled && omittedForAi ? "is-ai-omitted" : ""}">
1231
+ <div class="comment-head">
1232
+ <div class="comment-head-main">
1233
+ <div class="comment-head-title">
1234
+ <strong>${escapeHtml(formatCommentAuthor(comment))}</strong>
1235
+ ${kindLabel ? `<span class="comment-kind-pill">${kindLabel}</span>` : ""}
1236
+ </div>
1237
+ </div>
1086
1238
  </div>
1087
1239
  <div class="comment-body preserved-text-body">${renderPreservedText(comment.body)}</div>
1240
+ <div class="comment-footer">
1241
+ <span class="comment-footer-meta">${formatDate(comment.created_at)}</span>
1242
+ ${
1243
+ aiEnabled
1244
+ ? `<button
1245
+ type="button"
1246
+ class="comment-ai-toggle detail-action-btn"
1247
+ data-variant="ghost"
1248
+ data-comment-action="toggle-ai-omission"
1249
+ data-comment-id="${escapeHtml(comment.id)}"
1250
+ data-omitted-for-ai="${omittedForAi ? "true" : "false"}"
1251
+ >${omittedForAi ? "AI Include" : "AI Ignore"}</button>`
1252
+ : ""
1253
+ }
1254
+ </div>
1088
1255
  </div>
1089
1256
  `;
1090
1257
  }
@@ -1100,16 +1267,36 @@ export function renderInlinePreservedTextField({
1100
1267
  label,
1101
1268
  value,
1102
1269
  placeholder,
1270
+ ticketId,
1271
+ artifactPath,
1272
+ artifactFieldName,
1273
+ artifactLabel,
1103
1274
  }) {
1104
1275
  const text = value || "";
1105
1276
  const hasValue = Boolean(text.trim());
1277
+ const artifactPathText = String(artifactPath || "").trim();
1278
+ const hasArtifactControl = Boolean(artifactLabel || artifactFieldName);
1106
1279
  const inner = hasValue
1107
1280
  ? renderPreservedText(text)
1108
1281
  : escapeHtml(placeholder || "");
1109
1282
  const placeholderClass = hasValue ? "" : "is-placeholder";
1283
+ const artifactButton = hasArtifactControl
1284
+ ? `<button
1285
+ type="button"
1286
+ class="detail-action-btn"
1287
+ data-variant="ghost"
1288
+ data-open-artifact="${escapeHtml(artifactPathText)}"
1289
+ data-artifact-field="${escapeHtml(artifactFieldName || "")}"
1290
+ data-artifact-ticket-id="${escapeHtml(ticketId || "")}"
1291
+ data-artifact-title="${escapeHtml(label)}"
1292
+ >${escapeHtml(artifactLabel || "Open full artifact")}</button>`
1293
+ : "";
1110
1294
  return `
1111
1295
  <div class="inline-md-field">
1112
- <span class="inline-md-field-label">${escapeHtml(label)}</span>
1296
+ <div class="inline-md-field-head">
1297
+ <span class="inline-md-field-label">${escapeHtml(label)}</span>
1298
+ ${artifactButton}
1299
+ </div>
1113
1300
  <div
1114
1301
  class="inline-md-field-body preserved-text-body editable-field ${placeholderClass}"
1115
1302
  data-edit-field="${escapeHtml(fieldName)}"
@@ -1122,7 +1309,9 @@ export function renderInlinePreservedTextField({
1122
1309
  `;
1123
1310
  }
1124
1311
 
1125
- function renderRelated(relations = [], ticket) {
1312
+ // Tickets eligible to be linked as "related": everything in the project except
1313
+ // the ticket itself, its existing relations, and its parent/child hierarchy.
1314
+ function relatedTicketCandidates(relations = [], ticket) {
1126
1315
  const relatedIds = new Set(relations.map((r) => r.other_ticket.id));
1127
1316
  const hierarchyIds = new Set();
1128
1317
  if (ticket?.parent_ticket_id) hierarchyIds.add(ticket.parent_ticket_id);
@@ -1131,16 +1320,13 @@ function renderRelated(relations = [], ticket) {
1131
1320
  if (t.parent_ticket_id === ticket.id) hierarchyIds.add(t.id);
1132
1321
  }
1133
1322
  }
1134
- const candidates = ticketsForProject().filter(
1323
+ return ticketsForProject().filter(
1135
1324
  (t) =>
1136
1325
  t.id !== ticket?.id && !relatedIds.has(t.id) && !hierarchyIds.has(t.id),
1137
1326
  );
1138
- const datalistOptions = candidates
1139
- .map(
1140
- (t) => `<option value="#${t.number} — ${escapeHtml(t.title)}"></option>`,
1141
- )
1142
- .join("");
1327
+ }
1143
1328
 
1329
+ function renderRelated(relations = [], ticket) {
1144
1330
  const list = relations.length
1145
1331
  ? `<div class="detail-card-grid">${relations.map((r) => renderRelatedCard(r)).join("")}</div>`
1146
1332
  : "";
@@ -1148,16 +1334,15 @@ function renderRelated(relations = [], ticket) {
1148
1334
  const form = `
1149
1335
  <form id="relatedAddForm" class="related-add-row">
1150
1336
  <div class="related-add-input-wrap">
1151
- <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" />
1152
- <button type="button" class="related-add-clear" data-related-clear aria-label="Clear" tabindex="-1">×</button>
1153
- <datalist id="related-ticket-suggestions">${datalistOptions}</datalist>
1337
+ <input type="text" name="target" class="related-add-input meta-inline-input" placeholder="Add related ticket…" aria-label="Add related ticket" autocomplete="off" />
1338
+ <button type="button" class="related-add-clear" data-related-clear aria-label="Clear" tabindex="-1">${closeIconSvg}</button>
1154
1339
  </div>
1155
1340
  <select name="type" class="related-add-type meta-inline" aria-label="Relation type">
1156
1341
  <option value="relates_to">relates to</option>
1157
1342
  <option value="blocks">blocks</option>
1158
1343
  <option value="blocked_by">blocked by</option>
1159
1344
  </select>
1160
- <button type="submit" class="ghost meta-add-btn">Add</button>
1345
+ <button type="submit" class="meta-add-btn detail-action-btn" data-variant="ghost">Add</button>
1161
1346
  </form>
1162
1347
  `;
1163
1348
 
@@ -1166,52 +1351,96 @@ function renderRelated(relations = [], ticket) {
1166
1351
 
1167
1352
  function renderHierarchySection(ticket, context) {
1168
1353
  const isEpic = canonicalTicketType(ticket.type) === "epic";
1169
- if (isEpic) {
1170
- const cards = context.child_tickets || [];
1171
- const body = cards.length
1172
- ? `<div class="detail-card-grid">${cards.map((c) => renderDetailCard(c, { detach: { mode: "child", id: c.id } })).join("")}</div>`
1173
- : `<p class="description">No child features yet. Create a feature / task card and set this epic as its parent.</p>`;
1174
- return `
1175
- <div class="section">
1176
- <h3>Features inside this Epic</h3>
1177
- ${body}
1354
+ if (!isEpic) return "";
1355
+
1356
+ const cards = context.child_tickets || [];
1357
+ const grid = cards.length
1358
+ ? `<div class="detail-card-grid">${cards.map((c) => renderDetailCard(c, { detach: { mode: "child", id: c.id } })).join("")}</div>`
1359
+ : "";
1360
+
1361
+ const form = `
1362
+ <form id="epicChildForm" class="related-add-row epic-child-form">
1363
+ <div class="related-add-input-wrap">
1364
+ <input type="text" name="target" class="related-add-input meta-inline-input" placeholder="Add feature to epic…" aria-label="Add feature to epic" autocomplete="off" />
1365
+ <button type="button" class="related-add-clear" data-epic-child-clear aria-label="Clear" tabindex="-1">${closeIconSvg}</button>
1178
1366
  </div>
1179
- `;
1180
- }
1181
- return "";
1182
- }
1367
+ <button type="submit" class="meta-add-btn detail-action-btn" data-variant="ghost">Add</button>
1368
+ </form>
1369
+ `;
1183
1370
 
1184
- function renderParentEpicSection(ticket, context) {
1185
- if (canonicalTicketType(ticket.type) === "epic") return "";
1371
+ return renderPanel({
1372
+ title: "Features inside this Epic",
1373
+ body: `<div class="settings-row">${grid}${form}</div>`,
1374
+ });
1375
+ }
1186
1376
 
1187
- const epics = ticketsForProject().filter(
1377
+ // Epics eligible to become this ticket's parent (any epic but itself).
1378
+ function epicCandidates(ticket) {
1379
+ return ticketsForProject().filter(
1188
1380
  (t) => t.type === "epic" && t.id !== ticket.id,
1189
1381
  );
1190
- const datalistOptions = epics
1191
- .map(
1192
- (t) => `<option value="#${t.number} — ${escapeHtml(t.title)}"></option>`,
1193
- )
1194
- .join("");
1382
+ }
1383
+
1384
+ // Tickets eligible to be pulled into this epic as children: any non-epic ticket
1385
+ // not already parented to it. (A ticket with another parent is reparented.)
1386
+ function childCandidates(epic) {
1387
+ return ticketsForProject().filter(
1388
+ (t) =>
1389
+ t.id !== epic.id &&
1390
+ canonicalTicketType(t.type) !== "epic" &&
1391
+ t.parent_ticket_id !== epic.id,
1392
+ );
1393
+ }
1394
+
1395
+
1396
+ // Board labels not yet applied to this ticket, as {name, color} items.
1397
+ function labelCandidates(ticket) {
1398
+ const applied = new Set((ticket.labels || []).map((l) => l.name));
1399
+ return boardLabelCatalog().filter((l) => !applied.has(l.name));
1400
+ }
1401
+
1402
+ // A typeahead row for a label: colour swatch + bold name. Mirrors the topbar
1403
+ // hit layout (bold lead + trailing badge), adapted for a label's lack of a
1404
+ // number / state.
1405
+ function renderLabelHitRow(label) {
1406
+ return `
1407
+ <span class="search-hit-main">
1408
+ <span class="typeahead-swatch" style="--label-color: ${escapeHtml(label.color || "var(--muted)")}"></span>
1409
+ <strong>${escapeHtml(label.name)}</strong>
1410
+ </span>
1411
+ `;
1412
+ }
1413
+
1414
+ function attachLabelTypeahead(input, ticket) {
1415
+ if (!input) return;
1416
+ attachTypeahead(input, {
1417
+ getItems: () => labelCandidates(ticket),
1418
+ match: (label, query) => label.name.toLowerCase().includes(query),
1419
+ renderItem: renderLabelHitRow,
1420
+ valueOf: (label) => label.name,
1421
+ emptyText: "No matching labels",
1422
+ });
1423
+ }
1424
+
1425
+ function renderParentEpicSection(ticket, context) {
1426
+ if (canonicalTicketType(ticket.type) === "epic") return "";
1195
1427
 
1196
1428
  const body = context.parent_ticket
1197
1429
  ? `<div class="detail-card-grid detail-card-grid--single">${renderDetailCard(context.parent_ticket, { detach: { mode: "parent" } })}</div>`
1198
1430
  : `
1199
- <form id="parentEpicForm" class="related-add-row">
1431
+ <form id="parentEpicForm" class="related-add-row parent-epic-form">
1200
1432
  <div class="related-add-input-wrap">
1201
- <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" />
1202
- <button type="button" class="related-add-clear" data-parent-epic-clear aria-label="Clear" tabindex="-1">×</button>
1203
- <datalist id="parent-epic-suggestions">${datalistOptions}</datalist>
1433
+ <input type="text" name="target" class="related-add-input meta-inline-input" placeholder="Set parent epic…" aria-label="Set parent epic" autocomplete="off" />
1434
+ <button type="button" class="related-add-clear" data-parent-epic-clear aria-label="Clear" tabindex="-1">${closeIconSvg}</button>
1204
1435
  </div>
1205
- <button type="submit" class="ghost meta-add-btn">Set</button>
1436
+ <button type="submit" class="meta-add-btn detail-action-btn" data-variant="ghost">Set</button>
1206
1437
  </form>
1207
1438
  `;
1208
1439
 
1209
- return `
1210
- <div class="section">
1211
- <h3>Epic</h3>
1212
- ${body}
1213
- </div>
1214
- `;
1440
+ return renderPanel({
1441
+ title: "Epic",
1442
+ body: `<div class="settings-row">${body}</div>`,
1443
+ });
1215
1444
  }
1216
1445
 
1217
1446
  export function renderDetailCard(ticket, options = {}) {
@@ -1237,7 +1466,7 @@ export function renderDetailCard(ticket, options = {}) {
1237
1466
  attr = `data-detach-relation="${escapeHtml(id)}"`;
1238
1467
  label = "Remove relation";
1239
1468
  }
1240
- detachBtn = `<button type="button" class="card-detach-btn" ${attr} aria-label="${label}" title="${label}">×</button>`;
1469
+ detachBtn = `<button type="button" class="card-detach-btn" ${attr} aria-label="${label}" title="${label}">${closeIconSvg}</button>`;
1241
1470
  } else if (options.archive) {
1242
1471
  const tid = escapeHtml(options.archive.id);
1243
1472
  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>`;
@@ -1259,7 +1488,6 @@ export function renderDetailCard(ticket, options = {}) {
1259
1488
  return `
1260
1489
  <article class="card card--detail type-${escapeHtml(canonicalType)} priority-${escapeHtml(priorityKey)}" data-variant="${escapeHtml(canonicalType)}"${options.disableOpen ? "" : ` data-open-ticket="${escapeHtml(ticket.id)}"`}>
1261
1490
  ${unreadDot}
1262
- ${detachBtn}
1263
1491
  ${eyebrow}
1264
1492
  <h3>${escapeHtml(ticket.title)}</h3>
1265
1493
  <div class="card-meta">
@@ -1271,6 +1499,7 @@ export function renderDetailCard(ticket, options = {}) {
1271
1499
  </div>
1272
1500
  <span class="detail-card-state" data-variant="${escapeHtml(stateClass)}">${escapeHtml(ticket.state_name || "State")}</span>
1273
1501
  ${state.showPriority ? renderPriorityPill(ticket.priority) : ""}
1502
+ ${detachBtn}
1274
1503
  </div>
1275
1504
  </div>
1276
1505
  </article>