@starlove/orbit 1.1.1 → 1.1.11

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 (103) hide show
  1. package/.dockerignore +0 -0
  2. package/Dockerfile +0 -0
  3. package/LICENSE +0 -0
  4. package/README.md +2 -2
  5. package/SKILL-ORBIT.md +2 -0
  6. package/build.mjs +0 -0
  7. package/dist/full/apple-touch-icon.png +0 -0
  8. package/dist/full/cursors/live_arrow.cur +0 -0
  9. package/dist/full/cursors/live_link.cur +0 -0
  10. package/dist/full/cursors/live_move.cur +0 -0
  11. package/dist/full/cursors/live_select.cur +0 -0
  12. package/dist/full/cursors/live_unavail.cur +0 -0
  13. package/dist/full/favicon-32x32.png +0 -0
  14. package/dist/full/index.html +160 -15
  15. package/dist/full/js/app.55b38e12.js +698 -0
  16. package/dist/full/js/app.55b38e12.js.map +7 -0
  17. package/dist/full/orbits-favicon.png +0 -0
  18. package/dist/full/orbits-logo-bg.png +0 -0
  19. package/dist/full/orbits-logo.png +0 -0
  20. package/dist/full/{styles.836872e7.css → styles.273fc37a.css} +79 -39
  21. package/dist/full/version.json +2 -2
  22. package/docs/AGENT_PROTOCOL.md +22 -6
  23. package/docs/MCP_SETUP.md +0 -0
  24. package/docs/ORBIT_DISPATCH.md +68 -8
  25. package/docs/SS-Orbit-Dark.png +0 -0
  26. package/docs/SS-Orbit-Light.png +0 -0
  27. package/docs/mcp-client.example.json +0 -0
  28. package/docs/setup/orbit-init-wizard.md +0 -0
  29. package/package.json +1 -1
  30. package/public/apple-touch-icon.png +0 -0
  31. package/public/cursors/live_arrow.cur +0 -0
  32. package/public/cursors/live_link.cur +0 -0
  33. package/public/cursors/live_move.cur +0 -0
  34. package/public/cursors/live_select.cur +0 -0
  35. package/public/cursors/live_unavail.cur +0 -0
  36. package/public/favicon-32x32.png +0 -0
  37. package/public/index.html +158 -13
  38. package/public/js/actor-labels.js +0 -0
  39. package/public/js/api.js +0 -0
  40. package/public/js/app.js +0 -0
  41. package/public/js/board-menu.js +0 -0
  42. package/public/js/card-actions.js +19 -0
  43. package/public/js/config.js +0 -0
  44. package/public/js/create-card.js +0 -0
  45. package/public/js/dom.js +0 -0
  46. package/public/js/drawer.js +17 -43
  47. package/public/js/format.js +5 -5
  48. package/public/js/kanban.js +157 -92
  49. package/public/js/local-backend.js +15 -4
  50. package/public/js/main.js +2 -1
  51. package/public/js/modal.js +0 -0
  52. package/public/js/motion-preference.js +14 -3
  53. package/public/js/router.js +0 -0
  54. package/public/js/search.js +0 -0
  55. package/public/js/settings-tabs.js +0 -0
  56. package/public/js/settings.js +131 -27
  57. package/public/js/sse.js +0 -0
  58. package/public/js/starscape.js +0 -0
  59. package/public/js/state.js +0 -0
  60. package/public/js/theme-preference.js +0 -0
  61. package/public/js/ticket-detail.js +88 -20
  62. package/public/js/toast.js +0 -0
  63. package/public/js/transport.js +0 -0
  64. package/public/js/unread.js +0 -0
  65. package/public/js/url-routes.js +0 -0
  66. package/public/orbits-favicon.png +0 -0
  67. package/public/orbits-logo-bg.png +0 -0
  68. package/public/orbits-logo.png +0 -0
  69. package/public/styles.css +79 -39
  70. package/src/cli/orbit.js +22 -0
  71. package/src/core/agent.js +25 -4
  72. package/src/core/agents-md.js +0 -0
  73. package/src/core/attachments.js +0 -0
  74. package/src/core/auth.js +0 -0
  75. package/src/core/backups.js +0 -0
  76. package/src/core/board.js +2 -1
  77. package/src/core/boards.js +0 -0
  78. package/src/core/bootstrap.js +0 -0
  79. package/src/core/db.js +19 -0
  80. package/src/core/delete-board.js +0 -0
  81. package/src/core/dispatch.js +178 -13
  82. package/src/core/events.js +0 -0
  83. package/src/core/http.js +0 -0
  84. package/src/core/migrate.js +0 -0
  85. package/src/core/paths.js +0 -0
  86. package/src/core/provision-repo-board.js +0 -0
  87. package/src/core/queries.js +0 -0
  88. package/src/core/registry.js +2 -2
  89. package/src/core/relations.js +0 -0
  90. package/src/core/review-verdicts.js +138 -0
  91. package/src/core/router.js +38 -3
  92. package/src/core/search.js +0 -0
  93. package/src/core/seed.js +0 -0
  94. package/src/core/snapshots.js +429 -9
  95. package/src/core/states.js +0 -0
  96. package/src/core/system-picker.js +0 -0
  97. package/src/core/tickets.js +0 -0
  98. package/src/core/util.js +0 -0
  99. package/src/mcp/orbit-client.js +47 -19
  100. package/src/mcp-server.js +122 -26
  101. package/src/server.js +0 -0
  102. package/dist/full/js/app.a4f045fc.js +0 -676
  103. package/dist/full/js/app.a4f045fc.js.map +0 -7
@@ -165,8 +165,12 @@ function renderProjectAiTab(context) {
165
165
  }
166
166
 
167
167
  function renderAgentContextSection(project) {
168
- const ph =
168
+ const instructionsPlaceholder =
169
169
  "This project is about…\n\nHow any AI agent should use this board and work in this repo.";
170
+ const instructions = project.agent_instructions || "";
171
+ const hasInstructions = Boolean(instructions.trim());
172
+ const placeholderClass = hasInstructions ? "" : "is-placeholder";
173
+ const inner = hasInstructions ? renderPreservedText(instructions) : escapeHtml(instructionsPlaceholder);
170
174
  return `
171
175
  <div class="section ai-subsection ai-context-setup">
172
176
  <div class="ai-context-title-row">
@@ -174,10 +178,16 @@ function renderAgentContextSection(project) {
174
178
  <h3>Agent Instructions</h3>
175
179
  </div>
176
180
  <p class="description">This project-level context is provided to agents when they work on tickets.</p>
177
- <form id="agentInstructionsForm" class="field-form">
178
- <textarea name="agent_instructions" placeholder="${escapeHtml(ph)}">${escapeHtml(project.agent_instructions || "")}</textarea>
179
- <button type="submit">Save</button>
180
- </form>
181
+ <div class="inline-md-field">
182
+ <div
183
+ class="inline-md-field-body preserved-text-body editable-field settings-agent-instructions-body ${placeholderClass}"
184
+ data-edit-field="agent_instructions"
185
+ tabindex="0"
186
+ title="Click to edit"
187
+ role="button"
188
+ aria-label="Edit Agent Instructions"
189
+ >${inner}</div>
190
+ </div>
181
191
  </div>
182
192
  `;
183
193
  }
@@ -381,6 +391,7 @@ function renderProjectRepositoryTab(context) {
381
391
 
382
392
  <div class="section">
383
393
  <h3>Board Snapshot</h3>
394
+ <p class="description">Import accepts Orbit snapshots or Trello board JSON exports. The default import creates a separate board and leaves this board untouched.</p>
384
395
  ${features.attachments ? `
385
396
  <label class="orbit-check">
386
397
  <input type="checkbox" id="exportProjectImages" />
@@ -394,8 +405,23 @@ function renderProjectRepositoryTab(context) {
394
405
  <div class="deployment-actions">
395
406
  <button type="button" class="ghost" id="exportProject">Export</button>
396
407
  <label class="import-button">
397
- <input type="file" id="importSnapshotFile" accept=".orbit.json,.json,application/json" />
398
- <span>Import</span>
408
+ <input type="file" id="importSnapshotAsNewFile" accept=".orbit.json,.json,application/json" />
409
+ <span>Import as New Board</span>
410
+ </label>
411
+ </div>
412
+ </div>
413
+
414
+ <div class="section repository-danger-zone">
415
+ <h3>Replace Current Board</h3>
416
+ <p class="description">Advanced: replace this board from an Orbit snapshot or Trello export after making the normal pre-import backup. Type <strong>${escapeHtml(project.slug || "")}</strong> to enable replacement.</p>
417
+ <label class="repository-confirm-field">
418
+ <span>Board slug</span>
419
+ <input id="replaceImportConfirmInput" autocomplete="off" spellcheck="false" placeholder="${escapeHtml(project.slug || "")}" />
420
+ </label>
421
+ <div class="deployment-actions">
422
+ <label class="import-button danger-button">
423
+ <input type="file" id="replaceCurrentBoardImportFile" accept=".orbit.json,.json,application/json" disabled />
424
+ <span>Replace Current Board</span>
399
425
  </label>
400
426
  </div>
401
427
  </div>
@@ -599,6 +625,18 @@ function renderProjectJournalTab(context) {
599
625
  `;
600
626
  }
601
627
 
628
+ function parseImportSnapshotText(text) {
629
+ const trimmed = String(text || "").trimStart();
630
+ if (trimmed.startsWith("<")) {
631
+ throw new Error("That file looks like an HTML page instead of JSON. In Trello, use Export JSON/download the board JSON, not Save Page As.");
632
+ }
633
+ try {
634
+ return JSON.parse(text);
635
+ } catch (error) {
636
+ throw new Error(error?.message || "invalid JSON");
637
+ }
638
+ }
639
+
602
640
  function bindProjectTabHandlers(context, tab) {
603
641
  const project = context.board;
604
642
 
@@ -628,19 +666,63 @@ function bindProjectTabHandlers(context, tab) {
628
666
  toast.success(`Renamed board to ${nextName}. Canonical slug unchanged: ${project.slug}`);
629
667
  });
630
668
 
631
- $("#importSnapshotFile")?.addEventListener("change", async (event) => {
632
- const file = event.currentTarget.files?.[0];
633
- if (!file) return;
669
+ const importSnapshotFile = async (file, bodyForSnapshot) => {
634
670
  const text = await file.text();
635
- const snapshot = JSON.parse(text);
636
- await api("/api/admin/import", {
671
+ const snapshot = parseImportSnapshotText(text);
672
+ return api("/api/admin/import", {
637
673
  method: "POST",
638
- body: { snapshot, replace_existing: true, board_id: project.id }
674
+ body: bodyForSnapshot(snapshot)
639
675
  });
640
- await load();
641
- state.detailMode = "settings";
642
- toast.success("Board imported");
643
- event.currentTarget.value = "";
676
+ };
677
+
678
+ $("#importSnapshotAsNewFile")?.addEventListener("change", async (event) => {
679
+ const input = event.currentTarget;
680
+ const file = input.files?.[0];
681
+ if (!file) return;
682
+ try {
683
+ const result = await importSnapshotFile(file, (snapshot) => ({ snapshot, create_new: true, board_id: project.id }));
684
+ state.boardId = result.imported_board_id;
685
+ state.boardSlug = result.imported_board_slug;
686
+ state.detailMode = "empty";
687
+ await load();
688
+ await navigate({ boardId: result.imported_board_id, view: "board" });
689
+ const counts = result.imported_counts || {};
690
+ const cardCount = Number.isFinite(Number(counts.tickets)) ? ` (${counts.tickets} cards)` : "";
691
+ toast.success(result.source_format === "trello-board-export" ? `Trello board imported as new board${cardCount}` : "Board imported as new board");
692
+ } catch (err) {
693
+ toast.error(`Import failed: ${err?.message || "invalid JSON"}`);
694
+ } finally {
695
+ input.value = "";
696
+ }
697
+ });
698
+
699
+ const replaceImportConfirmInput = $("#replaceImportConfirmInput");
700
+ const replaceCurrentBoardImportFile = $("#replaceCurrentBoardImportFile");
701
+ replaceImportConfirmInput?.addEventListener("input", () => {
702
+ if (replaceCurrentBoardImportFile) replaceCurrentBoardImportFile.disabled = replaceImportConfirmInput.value.trim() !== project.slug;
703
+ });
704
+
705
+ replaceCurrentBoardImportFile?.addEventListener("change", async (event) => {
706
+ const input = event.currentTarget;
707
+ const file = input.files?.[0];
708
+ if (!file) return;
709
+ if (replaceImportConfirmInput.value.trim() !== project.slug) {
710
+ toast.error("Type the board slug before replacing this board.");
711
+ input.value = "";
712
+ return;
713
+ }
714
+ try {
715
+ const result = await importSnapshotFile(file, (snapshot) => ({ snapshot, replace_existing: true, board_id: project.id }));
716
+ await load();
717
+ state.detailMode = "settings";
718
+ const counts = result.imported_counts || {};
719
+ const cardCount = Number.isFinite(Number(counts.tickets)) ? ` (${counts.tickets} cards)` : "";
720
+ toast.success(result.source_format === "trello-board-export" ? `Trello board replaced from Trello import${cardCount}` : "Board replaced from snapshot");
721
+ } catch (err) {
722
+ toast.error(`Import failed: ${err?.message || "invalid JSON"}`);
723
+ } finally {
724
+ input.value = "";
725
+ }
644
726
  });
645
727
 
646
728
  $("#deleteBoardStart")?.addEventListener("click", async () => {
@@ -817,17 +899,39 @@ function bindProjectTabHandlers(context, tab) {
817
899
  });
818
900
  });
819
901
 
820
- $("#agentInstructionsForm")?.addEventListener("submit", async (event) => {
821
- event.preventDefault();
822
- const form = new FormData(event.currentTarget);
823
- await api(`/api/boards/${project.id}`, {
824
- method: "PATCH",
825
- body: Object.fromEntries(form.entries())
902
+ const instructionsEl = drawerInner.querySelector('[data-edit-field="agent_instructions"]');
903
+ if (instructionsEl) {
904
+ const edit = () =>
905
+ startInlineEdit(instructionsEl, {
906
+ fieldName: "agent_instructions",
907
+ multiline: true,
908
+ initialValue: project.agent_instructions || "",
909
+ ariaLabel: "Edit Agent Instructions",
910
+ rerender: async () => {
911
+ state.detailMode = "settings";
912
+ await renderProjectDetail();
913
+ },
914
+ commit: async (next) => {
915
+ await api(`/api/boards/${project.id}`, {
916
+ method: "PATCH",
917
+ body: { agent_instructions: cleanText(next) }
918
+ });
919
+ await load();
920
+ state.detailMode = "settings";
921
+ toast.success("Saved");
922
+ }
923
+ });
924
+ instructionsEl.addEventListener("click", (event) => {
925
+ event.preventDefault();
926
+ edit();
826
927
  });
827
- await load();
828
- state.detailMode = "settings";
829
- toast.success("Saved");
830
- });
928
+ instructionsEl.addEventListener("keydown", (event) => {
929
+ if (event.key === "Enter" || event.key === " ") {
930
+ event.preventDefault();
931
+ edit();
932
+ }
933
+ });
934
+ }
831
935
 
832
936
  $("#aiEnabledToggle")?.addEventListener("change", async (event) => {
833
937
  const enabled = !!event.currentTarget.checked;
package/public/js/sse.js CHANGED
File without changes
File without changes
File without changes
File without changes
@@ -39,15 +39,15 @@ import { formatActorLabel, formatCommentAuthor } from "./actor-labels.js";
39
39
  * cache; otherwise leave the board DOM alone.
40
40
  */
41
41
  async function refreshTicketDetail(ticketId, { renderBoardAfter = false } = {}) {
42
- state.selectedTicketId = ticketId;
43
- state.detailMode = "ticket";
44
42
  const [context, statusHistory, commentsResult] = await Promise.all([
45
43
  api(withBoardQuery(`/api/tickets/${ticketId}/context?depth=1`)),
46
44
  api(withBoardQuery(`/api/tickets/${ticketId}/history`)),
47
45
  api(withBoardQuery(`/api/tickets/${ticketId}/comments`))
48
46
  ]);
47
+ if (context.ticket?.id !== ticketId) return;
49
48
  upsertTicket(context.ticket);
50
49
  if (renderBoardAfter) renderBoard();
50
+ if (state.detailMode !== "ticket" || state.selectedTicketId !== ticketId) return;
51
51
  await renderDetail({ context, statusHistory, comments: commentsResult.comments || [] });
52
52
  }
53
53
 
@@ -71,11 +71,62 @@ async function patchTicket(ticketId, body) {
71
71
  * - initialValue: text shown in the editor on open.
72
72
  * - ariaLabel: full label for screen readers.
73
73
  * - emptyMessage: if set, an empty trimmed value triggers a warning toast and cancels.
74
+ * - sourceEvent: optional pointer/click event used to seed the textarea caret near the clicked text.
74
75
  * - rerender: async fn called on cancel / no-change to put the static node back.
75
76
  * - commit: async fn called with the new value when the user accepts a real change.
76
77
  * Receives the *raw* editor value; the caller is responsible for any
77
78
  * trimming, cleanText, or PATCH wiring.
78
79
  */
80
+ function inlineTextOffsetFromPoint(node, sourceEvent, maxLength) {
81
+ if (!node || !sourceEvent || typeof sourceEvent.clientX !== "number" || typeof sourceEvent.clientY !== "number") return null;
82
+
83
+ let range = null;
84
+ if (document.caretPositionFromPoint) {
85
+ const position = document.caretPositionFromPoint(sourceEvent.clientX, sourceEvent.clientY);
86
+ if (position?.offsetNode && (position.offsetNode === node || node.contains(position.offsetNode))) {
87
+ range = document.createRange();
88
+ range.setStart(node, 0);
89
+ range.setEnd(position.offsetNode, position.offset);
90
+ }
91
+ } else if (document.caretRangeFromPoint) {
92
+ const caretRange = document.caretRangeFromPoint(sourceEvent.clientX, sourceEvent.clientY);
93
+ if (caretRange?.startContainer && (caretRange.startContainer === node || node.contains(caretRange.startContainer))) {
94
+ range = document.createRange();
95
+ range.setStart(node, 0);
96
+ range.setEnd(caretRange.startContainer, caretRange.startOffset);
97
+ }
98
+ }
99
+
100
+ if (!range) return null;
101
+ return Math.max(0, Math.min(maxLength, range.toString().length));
102
+ }
103
+
104
+ function captureInlineEditScrollState(node) {
105
+ const scrollContainer = node?.closest?.(".drawer-inner");
106
+ if (!scrollContainer) return null;
107
+ return {
108
+ scrollContainer,
109
+ nodeRect: node.getBoundingClientRect(),
110
+ scrollTop: scrollContainer.scrollTop
111
+ };
112
+ }
113
+
114
+ function restoreInlineEditScroll(scrollState, editor) {
115
+ if (!scrollState?.scrollContainer || !editor?.isConnected) return;
116
+ const editorRect = editor.getBoundingClientRect();
117
+ const nextScrollTop = Math.max(0, scrollState.scrollContainer.scrollTop + (editorRect.top - scrollState.nodeRect.top));
118
+ if (Number.isFinite(nextScrollTop)) scrollState.scrollContainer.scrollTop = nextScrollTop;
119
+ else scrollState.scrollContainer.scrollTop = scrollState.scrollTop;
120
+ }
121
+
122
+ function focusWithoutScrolling(editor) {
123
+ try {
124
+ editor.focus({ preventScroll: true });
125
+ } catch {
126
+ editor.focus();
127
+ }
128
+ }
129
+
79
130
  export function startInlineEdit(node, opts) {
80
131
  if (!node || node.dataset.editing === "true") return;
81
132
  const {
@@ -84,15 +135,20 @@ export function startInlineEdit(node, opts) {
84
135
  initialValue = "",
85
136
  ariaLabel,
86
137
  emptyMessage,
138
+ sourceEvent,
87
139
  rerender,
88
140
  commit: commitHandler
89
141
  } = opts;
90
142
 
143
+ const scrollState = multiline ? captureInlineEditScrollState(node) : null;
144
+ const initialString = String(initialValue);
145
+ const clickedCaretOffset = multiline ? inlineTextOffsetFromPoint(node, sourceEvent, initialString.length) : null;
146
+
91
147
  node.dataset.editing = "true";
92
148
 
93
149
  const editor = document.createElement(multiline ? "textarea" : "input");
94
150
  editor.className = multiline ? "inline-desc-editor" : "inline-title-editor";
95
- editor.value = initialValue;
151
+ editor.value = initialString;
96
152
  if (fieldName) editor.setAttribute("data-editing-field", fieldName);
97
153
  if (ariaLabel) editor.setAttribute("aria-label", ariaLabel);
98
154
  if (!multiline) {
@@ -101,13 +157,22 @@ export function startInlineEdit(node, opts) {
101
157
  editor.autocomplete = "off";
102
158
  editor.spellcheck = true;
103
159
  } else {
104
- editor.rows = Math.max(3, String(initialValue).split("\n").length + 1);
160
+ editor.rows = Math.max(3, initialString.split("\n").length + 1);
161
+ const sourceHeight = node.getBoundingClientRect().height;
162
+ if (Number.isFinite(sourceHeight) && sourceHeight > 0) {
163
+ editor.style.minHeight = `${Math.ceil(sourceHeight)}px`;
164
+ }
105
165
  }
106
166
 
107
167
  node.replaceWith(editor);
108
- editor.focus();
109
- if (!multiline) editor.select();
110
- else editor.setSelectionRange(editor.value.length, editor.value.length);
168
+ focusWithoutScrolling(editor);
169
+ if (!multiline) {
170
+ editor.select();
171
+ } else {
172
+ const selectionStart = clickedCaretOffset ?? 0;
173
+ editor.setSelectionRange(selectionStart, selectionStart);
174
+ restoreInlineEditScroll(scrollState, editor);
175
+ }
111
176
 
112
177
  let done = false;
113
178
 
@@ -115,11 +180,9 @@ export function startInlineEdit(node, opts) {
115
180
  document.removeEventListener("pointerdown", handleOutsidePointerDown, true);
116
181
  };
117
182
 
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.
183
+ // If a downstream render rebuilds the title block while the editor is open,
184
+ // the editor is dropped along with everything else; only restore the saved
185
+ // node when our editor is still connected to the DOM.
123
186
  const restoreNode = () => {
124
187
  if (editor.isConnected) editor.replaceWith(node);
125
188
  delete node.dataset.editing;
@@ -209,12 +272,13 @@ function wireTicketDetailEditors(ticket) {
209
272
  }
210
273
 
211
274
  const descEl = drawerInner.querySelector('[data-edit-field="description"]');
212
- const editDescription = () =>
275
+ const editDescription = (sourceEvent) =>
213
276
  startInlineEdit(descEl, {
214
277
  fieldName: "description",
215
278
  multiline: true,
216
279
  initialValue: ticket.description || "",
217
280
  ariaLabel: "Edit ticket description",
281
+ sourceEvent,
218
282
  rerender: () => renderDetail(),
219
283
  commit: (next) => patchTicket(ticketId, { description: cleanText(next) })
220
284
  });
@@ -222,7 +286,7 @@ function wireTicketDetailEditors(ticket) {
222
286
  if (descEl) {
223
287
  descEl.addEventListener("click", (event) => {
224
288
  event.preventDefault();
225
- editDescription();
289
+ editDescription(event);
226
290
  });
227
291
  descEl.addEventListener("keydown", (event) => {
228
292
  if (event.key === "Enter" || event.key === " ") {
@@ -245,18 +309,19 @@ function wireTicketDetailEditors(ticket) {
245
309
  for (const { key, label } of AI_FIELDS) {
246
310
  const fieldEl = drawerInner.querySelector(`[data-edit-field="${key}"]`);
247
311
  if (!fieldEl) continue;
248
- const edit = () =>
312
+ const edit = (sourceEvent) =>
249
313
  startInlineEdit(fieldEl, {
250
314
  fieldName: key,
251
315
  multiline: true,
252
316
  initialValue: ticket[key] || "",
253
317
  ariaLabel: `Edit ${label}`,
318
+ sourceEvent,
254
319
  rerender: () => renderDetail(),
255
320
  commit: (next) => patchTicket(ticketId, { [key]: cleanText(next) })
256
321
  });
257
322
  fieldEl.addEventListener("click", (event) => {
258
323
  event.preventDefault();
259
- edit();
324
+ edit(event);
260
325
  });
261
326
  fieldEl.addEventListener("keydown", (event) => {
262
327
  if (event.key === "Enter" || event.key === " ") {
@@ -337,23 +402,26 @@ export async function renderDetail(options = {}) {
337
402
  return;
338
403
  }
339
404
 
405
+ const requestedTicketId = state.selectedTicketId;
340
406
  const hasOption = (key) => Object.prototype.hasOwnProperty.call(options, key);
341
407
  const [context, statusHistory, comments, attachmentList] = await Promise.all([
342
408
  hasOption("context")
343
409
  ? Promise.resolve(options.context)
344
- : api(withBoardQuery(`/api/tickets/${state.selectedTicketId}/context?depth=1`)),
410
+ : api(withBoardQuery(`/api/tickets/${requestedTicketId}/context?depth=1`)),
345
411
  hasOption("statusHistory")
346
412
  ? Promise.resolve(options.statusHistory)
347
- : api(withBoardQuery(`/api/tickets/${state.selectedTicketId}/history`)),
413
+ : api(withBoardQuery(`/api/tickets/${requestedTicketId}/history`)),
348
414
  hasOption("comments")
349
415
  ? Promise.resolve(options.comments)
350
- : api(withBoardQuery(`/api/tickets/${state.selectedTicketId}/comments`)).then((result) => result.comments || []),
416
+ : api(withBoardQuery(`/api/tickets/${requestedTicketId}/comments`)).then((result) => result.comments || []),
351
417
  hasOption("attachmentList")
352
418
  ? Promise.resolve(options.attachmentList)
353
419
  : features.attachments
354
- ? api(withBoardQuery(`/api/tickets/${state.selectedTicketId}/attachments`)).catch(() => ({ attachments: [] }))
420
+ ? api(withBoardQuery(`/api/tickets/${requestedTicketId}/attachments`)).catch(() => ({ attachments: [] }))
355
421
  : Promise.resolve({ attachments: [] })
356
422
  ]);
423
+ if (state.detailMode !== "ticket" || state.selectedTicketId !== requestedTicketId) return;
424
+ if (context.ticket?.id !== requestedTicketId) return;
357
425
  const ticket = context.ticket;
358
426
  // Acknowledge the read receipt: clear the unread dot on this card.
359
427
  // Also wipe the dot from the already-rendered card in the board so the
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes