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