@starlove/orbit 1.1.2 → 1.2.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.
- package/SKILL-ORBIT.md +2 -0
- package/build.mjs +11 -5
- package/dist/full/index.html +169 -20
- package/dist/full/js/app.ccc442f7.js +706 -0
- package/dist/full/js/app.ccc442f7.js.map +7 -0
- package/dist/full/styles.baccc8e3.css +1 -0
- package/dist/full/version.json +2 -2
- package/docs/ORBIT_DISPATCH.md +68 -8
- package/package.json +19 -18
- package/public/index.html +167 -18
- package/public/js/board-menu.js +16 -2
- package/public/js/card-actions.js +19 -0
- package/public/js/create-card.js +1 -1
- package/public/js/drawer.js +17 -43
- package/public/js/format.js +7 -8
- package/public/js/kanban.js +167 -101
- package/public/js/main.js +2 -1
- package/public/js/motion-preference.js +14 -3
- package/public/js/search.js +1 -1
- package/public/js/settings.js +162 -46
- package/public/js/ticket-detail.js +92 -24
- package/public/styles.css +569 -476
- package/src/cli/orbit.js +22 -0
- package/src/core/board.js +1 -0
- package/src/core/css-bundle.js +29 -0
- package/src/core/db.js +19 -0
- package/src/core/dispatch.js +176 -11
- package/src/core/http.js +10 -3
- package/src/core/registry.js +2 -2
- package/src/core/review-verdicts.js +138 -0
- package/src/core/router.js +32 -3
- package/src/core/snapshots.js +429 -9
- package/src/mcp/orbit-client.js +45 -19
- package/src/mcp-server.js +102 -27
- package/dist/full/js/app.a4f045fc.js +0 -676
- package/dist/full/js/app.a4f045fc.js.map +0 -7
- package/dist/full/styles.836872e7.css +0 -5962
package/public/js/settings.js
CHANGED
|
@@ -165,8 +165,12 @@ function renderProjectAiTab(context) {
|
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
function renderAgentContextSection(project) {
|
|
168
|
-
const
|
|
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
|
-
<
|
|
178
|
-
<
|
|
179
|
-
|
|
180
|
-
|
|
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
|
}
|
|
@@ -232,7 +242,7 @@ function renderMcpSetupSection(context) {
|
|
|
232
242
|
<strong data-mcp-output-title>${escapeHtml(client.label)} setup</strong>
|
|
233
243
|
<p class="description" data-mcp-helper>${escapeHtml(client.helper)}</p>
|
|
234
244
|
</div>
|
|
235
|
-
<button type="button"
|
|
245
|
+
<button type="button" data-variant="secondary" data-copy-mcp="config">Copy setup</button>
|
|
236
246
|
</div>
|
|
237
247
|
<div class="mcp-terminal">
|
|
238
248
|
<div class="mcp-terminal-chrome" aria-hidden="true"><span></span><span></span><span></span></div>
|
|
@@ -248,7 +258,7 @@ function renderMcpSetupSection(context) {
|
|
|
248
258
|
<strong>Manual run command</strong>
|
|
249
259
|
<p class="description">Use this only when an app asks you to start the MCP server yourself.</p>
|
|
250
260
|
</div>
|
|
251
|
-
<button type="button"
|
|
261
|
+
<button type="button" data-variant="secondary" data-copy-mcp="command">Copy command</button>
|
|
252
262
|
</div>
|
|
253
263
|
<div class="mcp-terminal">
|
|
254
264
|
<div class="mcp-terminal-chrome" aria-hidden="true"><span></span><span></span><span></span></div>
|
|
@@ -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" />
|
|
@@ -392,11 +403,22 @@ function renderProjectRepositoryTab(context) {
|
|
|
392
403
|
<p class="description">Image exports are embedded in the .orbit.json snapshot and may be much larger.</p>
|
|
393
404
|
` : ""}
|
|
394
405
|
<div class="deployment-actions">
|
|
395
|
-
<button type="button"
|
|
396
|
-
<
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
406
|
+
<button type="button" data-variant="secondary" id="exportProject">Export</button>
|
|
407
|
+
<input type="file" id="importSnapshotAsNewFile" class="import-file-input" accept=".orbit.json,.json,application/json" />
|
|
408
|
+
<button type="button" data-variant="secondary" id="importSnapshotAsNewButton">Import as New Board</button>
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
411
|
+
|
|
412
|
+
<div class="section repository-danger-zone">
|
|
413
|
+
<h3>Replace Current Board</h3>
|
|
414
|
+
<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>
|
|
415
|
+
<label class="repository-confirm-field">
|
|
416
|
+
<span>Board slug</span>
|
|
417
|
+
<input id="replaceImportConfirmInput" autocomplete="off" spellcheck="false" placeholder="${escapeHtml(project.slug || "")}" />
|
|
418
|
+
</label>
|
|
419
|
+
<div class="deployment-actions">
|
|
420
|
+
<input type="file" id="replaceCurrentBoardImportFile" class="import-file-input" accept=".orbit.json,.json,application/json" disabled />
|
|
421
|
+
<button type="button" data-variant="secondary" id="replaceCurrentBoardImportButton" disabled>Replace Current Board</button>
|
|
400
422
|
</div>
|
|
401
423
|
</div>
|
|
402
424
|
|
|
@@ -414,7 +436,7 @@ function renderBoardRenameSection(project) {
|
|
|
414
436
|
<span>Display name</span>
|
|
415
437
|
<input name="name" value="${escapeHtml(project.name || "")}" maxlength="120" required />
|
|
416
438
|
</label>
|
|
417
|
-
<button type="submit">Rename Board</button>
|
|
439
|
+
<button type="submit" data-variant="secondary">Rename Board</button>
|
|
418
440
|
</form>
|
|
419
441
|
</div>
|
|
420
442
|
`;
|
|
@@ -428,9 +450,9 @@ function renderDeleteBoardSection(project) {
|
|
|
428
450
|
<h3>Delete Board</h3>
|
|
429
451
|
<p class="description">Delete this board from Orbit and remove its repo-local Orbit files. Export a snapshot first if you may need this board again.</p>
|
|
430
452
|
<div class="repository-delete-actions">
|
|
431
|
-
<button type="button"
|
|
432
|
-
<button type="button"
|
|
433
|
-
<button type="button"
|
|
453
|
+
<button type="button" data-variant="secondary" id="deleteBoardExportFirst">Export first</button>
|
|
454
|
+
<button type="button" data-variant="ghost" class="danger-button" id="deleteBoardSkipExport">Delete without exporting</button>
|
|
455
|
+
<button type="button" data-variant="ghost" id="deleteBoardCancel">Cancel</button>
|
|
434
456
|
</div>
|
|
435
457
|
</div>
|
|
436
458
|
`;
|
|
@@ -450,8 +472,8 @@ function renderDeleteBoardSection(project) {
|
|
|
450
472
|
<input id="deleteBoardConfirmInput" autocomplete="off" spellcheck="false" placeholder="${slug}" autofocus />
|
|
451
473
|
</label>
|
|
452
474
|
<div class="repository-delete-actions">
|
|
453
|
-
<button type="button" class="danger-button" id="deleteBoardConfirm" disabled>Delete board permanently</button>
|
|
454
|
-
<button type="button"
|
|
475
|
+
<button type="button" data-variant="primary" class="danger-button" id="deleteBoardConfirm" disabled>Delete board permanently</button>
|
|
476
|
+
<button type="button" data-variant="ghost" id="deleteBoardCancel">Cancel</button>
|
|
455
477
|
</div>
|
|
456
478
|
</div>
|
|
457
479
|
`;
|
|
@@ -461,7 +483,7 @@ function renderDeleteBoardSection(project) {
|
|
|
461
483
|
<div class="section repository-danger-zone">
|
|
462
484
|
<h3>Delete Board</h3>
|
|
463
485
|
<p class="description">Remove this board from Orbit and delete its repo-local Orbit files. This cannot be undone without an exported snapshot.</p>
|
|
464
|
-
<button type="button"
|
|
486
|
+
<button type="button" data-variant="ghost" class="danger-button" id="deleteBoardStart">Delete Board</button>
|
|
465
487
|
</div>
|
|
466
488
|
`;
|
|
467
489
|
}
|
|
@@ -510,7 +532,7 @@ function renderProjectLanesTab() {
|
|
|
510
532
|
${renderLaneManager()}
|
|
511
533
|
<form id="laneCreateForm" class="lane-create-form">
|
|
512
534
|
<input name="name" placeholder="New lane name..." required />
|
|
513
|
-
<button type="submit">Add Lane</button>
|
|
535
|
+
<button type="submit" data-variant="primary">Add Lane</button>
|
|
514
536
|
</form>
|
|
515
537
|
</div>
|
|
516
538
|
`;
|
|
@@ -593,12 +615,24 @@ function renderProjectJournalTab(context) {
|
|
|
593
615
|
</select>
|
|
594
616
|
<input name="title" placeholder="Entry title..." required />
|
|
595
617
|
<textarea name="body" placeholder="Decision rationale or lesson: do X instead of Y when working with Z." required></textarea>
|
|
596
|
-
<button type="submit">Add Entry</button>
|
|
618
|
+
<button type="submit" data-variant="primary">Add Entry</button>
|
|
597
619
|
</form>
|
|
598
620
|
</div>
|
|
599
621
|
`;
|
|
600
622
|
}
|
|
601
623
|
|
|
624
|
+
function parseImportSnapshotText(text) {
|
|
625
|
+
const trimmed = String(text || "").trimStart();
|
|
626
|
+
if (trimmed.startsWith("<")) {
|
|
627
|
+
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.");
|
|
628
|
+
}
|
|
629
|
+
try {
|
|
630
|
+
return JSON.parse(text);
|
|
631
|
+
} catch (error) {
|
|
632
|
+
throw new Error(error?.message || "invalid JSON");
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
602
636
|
function bindProjectTabHandlers(context, tab) {
|
|
603
637
|
const project = context.board;
|
|
604
638
|
|
|
@@ -628,19 +662,76 @@ function bindProjectTabHandlers(context, tab) {
|
|
|
628
662
|
toast.success(`Renamed board to ${nextName}. Canonical slug unchanged: ${project.slug}`);
|
|
629
663
|
});
|
|
630
664
|
|
|
631
|
-
|
|
632
|
-
const file = event.currentTarget.files?.[0];
|
|
633
|
-
if (!file) return;
|
|
665
|
+
const importSnapshotFile = async (file, bodyForSnapshot) => {
|
|
634
666
|
const text = await file.text();
|
|
635
|
-
const snapshot =
|
|
636
|
-
|
|
667
|
+
const snapshot = parseImportSnapshotText(text);
|
|
668
|
+
return api("/api/admin/import", {
|
|
637
669
|
method: "POST",
|
|
638
|
-
body:
|
|
670
|
+
body: bodyForSnapshot(snapshot)
|
|
639
671
|
});
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
const importSnapshotAsNewFile = $("#importSnapshotAsNewFile");
|
|
675
|
+
$("#importSnapshotAsNewButton")?.addEventListener("click", () => {
|
|
676
|
+
importSnapshotAsNewFile?.click();
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
importSnapshotAsNewFile?.addEventListener("change", async (event) => {
|
|
680
|
+
const input = event.currentTarget;
|
|
681
|
+
const file = input.files?.[0];
|
|
682
|
+
if (!file) return;
|
|
683
|
+
try {
|
|
684
|
+
const result = await importSnapshotFile(file, (snapshot) => ({ snapshot, create_new: true, board_id: project.id }));
|
|
685
|
+
state.boardId = result.imported_board_id;
|
|
686
|
+
state.boardSlug = result.imported_board_slug;
|
|
687
|
+
state.detailMode = "empty";
|
|
688
|
+
await load();
|
|
689
|
+
await navigate({ boardId: result.imported_board_id, view: "board" });
|
|
690
|
+
const counts = result.imported_counts || {};
|
|
691
|
+
const cardCount = Number.isFinite(Number(counts.tickets)) ? ` (${counts.tickets} cards)` : "";
|
|
692
|
+
toast.success(result.source_format === "trello-board-export" ? `Trello board imported as new board${cardCount}` : "Board imported as new board");
|
|
693
|
+
} catch (err) {
|
|
694
|
+
toast.error(`Import failed: ${err?.message || "invalid JSON"}`);
|
|
695
|
+
} finally {
|
|
696
|
+
input.value = "";
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
const replaceImportConfirmInput = $("#replaceImportConfirmInput");
|
|
701
|
+
const replaceCurrentBoardImportFile = $("#replaceCurrentBoardImportFile");
|
|
702
|
+
const replaceCurrentBoardImportButton = $("#replaceCurrentBoardImportButton");
|
|
703
|
+
replaceImportConfirmInput?.addEventListener("input", () => {
|
|
704
|
+
const disabled = replaceImportConfirmInput.value.trim() !== project.slug;
|
|
705
|
+
if (replaceCurrentBoardImportFile) replaceCurrentBoardImportFile.disabled = disabled;
|
|
706
|
+
if (replaceCurrentBoardImportButton) replaceCurrentBoardImportButton.disabled = disabled;
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
replaceCurrentBoardImportButton?.addEventListener("click", () => {
|
|
710
|
+
if (replaceCurrentBoardImportButton.disabled || replaceCurrentBoardImportFile?.disabled) return;
|
|
711
|
+
replaceCurrentBoardImportFile?.click();
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
replaceCurrentBoardImportFile?.addEventListener("change", async (event) => {
|
|
715
|
+
const input = event.currentTarget;
|
|
716
|
+
const file = input.files?.[0];
|
|
717
|
+
if (!file) return;
|
|
718
|
+
if (replaceImportConfirmInput.value.trim() !== project.slug) {
|
|
719
|
+
toast.error("Type the board slug before replacing this board.");
|
|
720
|
+
input.value = "";
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
try {
|
|
724
|
+
const result = await importSnapshotFile(file, (snapshot) => ({ snapshot, replace_existing: true, board_id: project.id }));
|
|
725
|
+
await load();
|
|
726
|
+
state.detailMode = "settings";
|
|
727
|
+
const counts = result.imported_counts || {};
|
|
728
|
+
const cardCount = Number.isFinite(Number(counts.tickets)) ? ` (${counts.tickets} cards)` : "";
|
|
729
|
+
toast.success(result.source_format === "trello-board-export" ? `Trello board replaced from Trello import${cardCount}` : "Board replaced from snapshot");
|
|
730
|
+
} catch (err) {
|
|
731
|
+
toast.error(`Import failed: ${err?.message || "invalid JSON"}`);
|
|
732
|
+
} finally {
|
|
733
|
+
input.value = "";
|
|
734
|
+
}
|
|
644
735
|
});
|
|
645
736
|
|
|
646
737
|
$("#deleteBoardStart")?.addEventListener("click", async () => {
|
|
@@ -817,17 +908,39 @@ function bindProjectTabHandlers(context, tab) {
|
|
|
817
908
|
});
|
|
818
909
|
});
|
|
819
910
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
911
|
+
const instructionsEl = drawerInner.querySelector('[data-edit-field="agent_instructions"]');
|
|
912
|
+
if (instructionsEl) {
|
|
913
|
+
const edit = () =>
|
|
914
|
+
startInlineEdit(instructionsEl, {
|
|
915
|
+
fieldName: "agent_instructions",
|
|
916
|
+
multiline: true,
|
|
917
|
+
initialValue: project.agent_instructions || "",
|
|
918
|
+
ariaLabel: "Edit Agent Instructions",
|
|
919
|
+
rerender: async () => {
|
|
920
|
+
state.detailMode = "settings";
|
|
921
|
+
await renderProjectDetail();
|
|
922
|
+
},
|
|
923
|
+
commit: async (next) => {
|
|
924
|
+
await api(`/api/boards/${project.id}`, {
|
|
925
|
+
method: "PATCH",
|
|
926
|
+
body: { agent_instructions: cleanText(next) }
|
|
927
|
+
});
|
|
928
|
+
await load();
|
|
929
|
+
state.detailMode = "settings";
|
|
930
|
+
toast.success("Saved");
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
instructionsEl.addEventListener("click", (event) => {
|
|
934
|
+
event.preventDefault();
|
|
935
|
+
edit();
|
|
826
936
|
});
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
937
|
+
instructionsEl.addEventListener("keydown", (event) => {
|
|
938
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
939
|
+
event.preventDefault();
|
|
940
|
+
edit();
|
|
941
|
+
}
|
|
942
|
+
});
|
|
943
|
+
}
|
|
831
944
|
|
|
832
945
|
$("#aiEnabledToggle")?.addEventListener("change", async (event) => {
|
|
833
946
|
const enabled = !!event.currentTarget.checked;
|
|
@@ -950,7 +1063,8 @@ function renderProjectEntries(entries = []) {
|
|
|
950
1063
|
<span>${escapeHtml(entry.created_by)} - ${formatDate(entry.created_at)}</span>
|
|
951
1064
|
<button
|
|
952
1065
|
type="button"
|
|
953
|
-
|
|
1066
|
+
data-variant="ghost"
|
|
1067
|
+
class="project-entry-strike"
|
|
954
1068
|
data-entry-action="toggle-struck"
|
|
955
1069
|
data-entry-id="${escapeHtml(entry.id)}"
|
|
956
1070
|
data-struck="${isStruck ? "true" : "false"}"
|
|
@@ -1018,14 +1132,14 @@ function renderLaneManager() {
|
|
|
1018
1132
|
: "";
|
|
1019
1133
|
return `
|
|
1020
1134
|
<div class="lane-row${isAiReady ? " lane-row--locked" : ""}" data-lane-id="${lane.id}">
|
|
1021
|
-
<button type="button" class="lane-icon-btn" data-lane-action="up" ${index === 0 ? "disabled" : ""} title="Move lane left" aria-label="Move lane left">↑</button>
|
|
1022
|
-
<button type="button" class="lane-icon-btn" data-lane-action="down" ${index === lanes.length - 1 ? "disabled" : ""} title="Move lane right" aria-label="Move lane right">↓</button>
|
|
1135
|
+
<button type="button" data-variant="ghost" class="settings-lane-icon-btn" data-lane-action="up" ${index === 0 ? "disabled" : ""} title="Move lane left" aria-label="Move lane left">↑</button>
|
|
1136
|
+
<button type="button" data-variant="ghost" class="settings-lane-icon-btn" data-lane-action="down" ${index === lanes.length - 1 ? "disabled" : ""} title="Move lane right" aria-label="Move lane right">↓</button>
|
|
1023
1137
|
<input name="name" value="${escapeHtml(lane.name)}" aria-label="Lane name" ${isAiReady ? `readonly title="${escapeHtml(nameTitle)}"` : ""} />
|
|
1024
|
-
<button type="button" class="lane-new-toggle ${lane.is_default ? "is-on" : ""}" data-lane-action="toggle-default" aria-pressed="${lane.is_default ? "true" : "false"}" title="${lane.is_default ? "Default lane for new cards — click another lane to move it" : "Make this the default lane for new cards"}" aria-label="Default lane for new cards">
|
|
1138
|
+
<button type="button" data-variant="${lane.is_default ? "secondary" : "ghost"}" class="settings-lane-new-toggle ${lane.is_default ? "is-on" : ""}" data-lane-action="toggle-default" aria-pressed="${lane.is_default ? "true" : "false"}" title="${lane.is_default ? "Default lane for new cards — click another lane to move it" : "Make this the default lane for new cards"}" aria-label="Default lane for new cards">
|
|
1025
1139
|
<span class="lane-new-sun" aria-hidden="true"></span>
|
|
1026
1140
|
<span class="lane-new-label">New</span>
|
|
1027
1141
|
</button>
|
|
1028
|
-
<button type="button" class="lane-delete-btn" data-lane-action="delete" ${deleteDisabled(lane, counts, aiEnabled) ? "disabled" : ""} title="${deleteTitle(lane, counts, aiEnabled)}" aria-label="Delete lane">×</button>
|
|
1142
|
+
<button type="button" data-variant="ghost" class="settings-lane-delete-btn danger-button" data-lane-action="delete" ${deleteDisabled(lane, counts, aiEnabled) ? "disabled" : ""} title="${deleteTitle(lane, counts, aiEnabled)}" aria-label="Delete lane">×</button>
|
|
1029
1143
|
</div>
|
|
1030
1144
|
`;
|
|
1031
1145
|
})
|
|
@@ -1054,9 +1168,11 @@ async function handleLaneAction(button) {
|
|
|
1054
1168
|
if (wasOn) return;
|
|
1055
1169
|
drawerInner.querySelectorAll('[data-lane-action="toggle-default"]').forEach((btn) => {
|
|
1056
1170
|
btn.setAttribute("aria-pressed", "false");
|
|
1171
|
+
btn.setAttribute("data-variant", "ghost");
|
|
1057
1172
|
btn.classList.remove("is-on");
|
|
1058
1173
|
});
|
|
1059
1174
|
button.setAttribute("aria-pressed", "true");
|
|
1175
|
+
button.setAttribute("data-variant", "secondary");
|
|
1060
1176
|
button.classList.add("is-on");
|
|
1061
1177
|
await updateLane(row, { silent: true });
|
|
1062
1178
|
toast.success("Default lane updated");
|
|
@@ -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 =
|
|
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,
|
|
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
|
|
109
|
-
if (!multiline)
|
|
110
|
-
|
|
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
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
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/${
|
|
410
|
+
: api(withBoardQuery(`/api/tickets/${requestedTicketId}/context?depth=1`)),
|
|
345
411
|
hasOption("statusHistory")
|
|
346
412
|
? Promise.resolve(options.statusHistory)
|
|
347
|
-
: api(withBoardQuery(`/api/tickets/${
|
|
413
|
+
: api(withBoardQuery(`/api/tickets/${requestedTicketId}/history`)),
|
|
348
414
|
hasOption("comments")
|
|
349
415
|
? Promise.resolve(options.comments)
|
|
350
|
-
: api(withBoardQuery(`/api/tickets/${
|
|
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/${
|
|
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
|
|
@@ -412,9 +480,9 @@ export async function renderDetail(options = {}) {
|
|
|
412
480
|
const detailSubtitleHtml = `
|
|
413
481
|
<span class="ticket-number">${escapeHtml(ticketLabel(ticket))}</span>
|
|
414
482
|
<div class="detail-meta-badge-row" aria-label="Ticket metadata controls">
|
|
415
|
-
<select class="detail-meta-badge detail-state-badge
|
|
483
|
+
<select class="detail-meta-badge detail-state-badge meta-select" data-variant="${escapeHtml(stateClass)}" data-meta-field="state_id" aria-label="Ticket state">${stateOptions}</select>
|
|
416
484
|
<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
|
|
485
|
+
<select class="detail-meta-badge detail-priority-badge priority-pill meta-select" data-variant="${escapeHtml(priorityKey)}" data-meta-field="priority" aria-label="Ticket priority">${priorityOptions}</select>
|
|
418
486
|
</div>
|
|
419
487
|
`;
|
|
420
488
|
|
|
@@ -1042,7 +1110,7 @@ export function renderDetailCard(ticket, options = {}) {
|
|
|
1042
1110
|
? `<span class="card-unread-dot" role="status" aria-label="1 unread update"></span>`
|
|
1043
1111
|
: `<span class="card-unread-dot has-count" role="status" aria-label="${unreadN} unread updates">${unreadN > 99 ? "99+" : unreadN}</span>`;
|
|
1044
1112
|
return `
|
|
1045
|
-
<article class="card card--detail type-${escapeHtml(canonicalType)} priority-${escapeHtml(priorityKey)}"${options.disableOpen ? "" : ` data-open-ticket="${escapeHtml(ticket.id)}"`}>
|
|
1113
|
+
<article class="card card--detail type-${escapeHtml(canonicalType)} priority-${escapeHtml(priorityKey)}" data-variant="${escapeHtml(canonicalType)}"${options.disableOpen ? "" : ` data-open-ticket="${escapeHtml(ticket.id)}"`}>
|
|
1046
1114
|
${unreadDot}
|
|
1047
1115
|
${detachBtn}
|
|
1048
1116
|
${eyebrow}
|
|
@@ -1054,7 +1122,7 @@ export function renderDetailCard(ticket, options = {}) {
|
|
|
1054
1122
|
${renderTypeIcon(ticket.type)}
|
|
1055
1123
|
<span class="card-type-label">${escapeHtml(typeText)}</span>
|
|
1056
1124
|
</div>
|
|
1057
|
-
<span class="detail-card-state
|
|
1125
|
+
<span class="detail-card-state" data-variant="${escapeHtml(stateClass)}">${escapeHtml(ticket.state_name || "State")}</span>
|
|
1058
1126
|
${state.showPriority ? renderPriorityPill(ticket.priority) : ""}
|
|
1059
1127
|
</div>
|
|
1060
1128
|
</div>
|