@starlove/orbit 1.0.6 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -7
- package/SKILL-ORBIT.md +28 -1
- package/build.mjs +2 -2
- 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.c2e3d20a.css → styles.836872e7.css} +1117 -47
- package/dist/full/version.json +2 -2
- package/docs/AGENT_PROTOCOL.md +27 -5
- package/docs/MCP_SETUP.md +7 -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/api.js +5 -1
- 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/drawer.js +2 -0
- package/public/js/format.js +336 -0
- package/public/js/kanban.js +25 -2
- package/public/js/local-backend.js +24 -8
- package/public/js/main.js +14 -6
- package/public/js/motion-preference.js +53 -0
- package/public/js/router.js +78 -58
- package/public/js/search.js +6 -3
- package/public/js/settings-tabs.js +1 -1
- package/public/js/settings.js +192 -29
- package/public/js/starscape.js +25 -18
- package/public/js/state.js +61 -2
- package/public/js/theme-preference.js +88 -0
- package/public/js/ticket-detail.js +432 -126
- package/public/js/url-routes.js +94 -0
- package/public/styles.css +1117 -47
- package/src/cli/orbit.js +217 -32
- package/src/core/agent.js +1 -18
- package/src/core/agents-md.js +16 -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/bootstrap.js +9 -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 +62 -6
- package/src/core/search.js +56 -22
- package/src/core/snapshots.js +14 -2
- package/src/mcp/orbit-client.js +223 -0
- package/src/mcp-server.js +89 -129
- package/src/server.js +20 -0
- package/dist/full/js/app.aedc1bfe.js +0 -554
- package/dist/full/js/app.aedc1bfe.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,
|
|
@@ -15,76 +15,152 @@ import {
|
|
|
15
15
|
typeLabel,
|
|
16
16
|
typeLabelLong,
|
|
17
17
|
priorityLabel,
|
|
18
|
-
priorityKeyFor
|
|
18
|
+
priorityKeyFor,
|
|
19
|
+
renderPreservedText,
|
|
20
|
+
stateClassFor,
|
|
21
|
+
cleanText
|
|
19
22
|
} from "./format.js";
|
|
20
|
-
import { renderTypeIcon, renderPriorityPill } from "./kanban.js";
|
|
23
|
+
import { renderTypeIcon, renderPriorityPill, renderBoard } from "./kanban.js";
|
|
21
24
|
import { renderDrawerShell, openDrawer, closeDrawer } from "./drawer.js";
|
|
22
25
|
import { navigate } from "./router.js";
|
|
23
26
|
import { api, withBoardQuery } from "./api.js";
|
|
27
|
+
import { features } from "./config.js";
|
|
24
28
|
import { toast } from "./toast.js";
|
|
25
|
-
|
|
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.
|
|
26
32
|
import { renderProjectDetail } from "./settings.js";
|
|
27
33
|
import { unreadCount, markRead } from "./unread.js";
|
|
34
|
+
import { formatActorLabel, formatCommentAuthor } from "./actor-labels.js";
|
|
28
35
|
|
|
29
36
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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.
|
|
32
40
|
*/
|
|
33
|
-
async function
|
|
34
|
-
await api(withBoardQuery(`/api/tickets/${ticketId}`), { method: "PATCH", body });
|
|
35
|
-
await load();
|
|
41
|
+
async function refreshTicketDetail(ticketId, { renderBoardAfter = false } = {}) {
|
|
36
42
|
state.selectedTicketId = ticketId;
|
|
37
43
|
state.detailMode = "ticket";
|
|
38
|
-
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 });
|
|
39
62
|
}
|
|
40
63
|
|
|
41
64
|
/**
|
|
42
65
|
* Replace a static node with an inline editor; commit on Enter / blur, cancel on Esc.
|
|
43
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.
|
|
44
78
|
*/
|
|
45
|
-
function startInlineEdit(node,
|
|
79
|
+
export function startInlineEdit(node, opts) {
|
|
46
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
|
+
|
|
47
91
|
node.dataset.editing = "true";
|
|
48
92
|
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
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";
|
|
52
95
|
editor.value = initialValue;
|
|
53
|
-
editor.setAttribute("data-editing-field",
|
|
54
|
-
if (
|
|
96
|
+
if (fieldName) editor.setAttribute("data-editing-field", fieldName);
|
|
97
|
+
if (ariaLabel) editor.setAttribute("aria-label", ariaLabel);
|
|
98
|
+
if (!multiline) {
|
|
55
99
|
editor.type = "text";
|
|
56
100
|
editor.maxLength = 500;
|
|
101
|
+
editor.autocomplete = "off";
|
|
102
|
+
editor.spellcheck = true;
|
|
57
103
|
} else {
|
|
58
104
|
editor.rows = Math.max(3, String(initialValue).split("\n").length + 1);
|
|
59
105
|
}
|
|
60
106
|
|
|
61
107
|
node.replaceWith(editor);
|
|
62
108
|
editor.focus();
|
|
63
|
-
if (!
|
|
109
|
+
if (!multiline) editor.select();
|
|
64
110
|
else editor.setSelectionRange(editor.value.length, editor.value.length);
|
|
65
111
|
|
|
66
112
|
let done = false;
|
|
67
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
|
+
|
|
68
144
|
const commit = async () => {
|
|
69
145
|
if (done) return;
|
|
70
|
-
|
|
71
|
-
|
|
146
|
+
const next = multiline ? editor.value : editor.value.trim();
|
|
147
|
+
finish();
|
|
72
148
|
if (String(next) === String(initialValue)) {
|
|
73
|
-
await
|
|
149
|
+
if (rerender) await rerender();
|
|
74
150
|
return;
|
|
75
151
|
}
|
|
76
|
-
if (
|
|
77
|
-
toast.warning(
|
|
78
|
-
await
|
|
152
|
+
if (emptyMessage && !next.trim()) {
|
|
153
|
+
toast.warning(emptyMessage);
|
|
154
|
+
if (rerender) await rerender();
|
|
79
155
|
return;
|
|
80
156
|
}
|
|
81
|
-
|
|
157
|
+
if (commitHandler) await commitHandler(next);
|
|
82
158
|
};
|
|
83
159
|
|
|
84
160
|
const cancel = async () => {
|
|
85
161
|
if (done) return;
|
|
86
|
-
|
|
87
|
-
await
|
|
162
|
+
finish();
|
|
163
|
+
if (rerender) await rerender();
|
|
88
164
|
};
|
|
89
165
|
|
|
90
166
|
editor.addEventListener("blur", commit);
|
|
@@ -95,7 +171,7 @@ function startInlineEdit(node, field, initialValue, ticketId) {
|
|
|
95
171
|
return;
|
|
96
172
|
}
|
|
97
173
|
if (event.key === "Enter") {
|
|
98
|
-
if (!
|
|
174
|
+
if (!multiline) {
|
|
99
175
|
event.preventDefault();
|
|
100
176
|
void commit();
|
|
101
177
|
} else if (event.metaKey || event.ctrlKey) {
|
|
@@ -110,33 +186,87 @@ function startInlineEdit(node, field, initialValue, ticketId) {
|
|
|
110
186
|
function wireTicketDetailEditors(ticket) {
|
|
111
187
|
const ticketId = ticket.id;
|
|
112
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
|
+
|
|
113
200
|
const titleEl = drawer.querySelector('[data-edit-field="title"]');
|
|
114
201
|
if (titleEl) {
|
|
115
|
-
titleEl.addEventListener("click",
|
|
116
|
-
startInlineEdit(titleEl, "title", ticket.title || "", ticketId);
|
|
117
|
-
});
|
|
202
|
+
titleEl.addEventListener("click", editTitle);
|
|
118
203
|
titleEl.addEventListener("keydown", (event) => {
|
|
119
204
|
if (event.key === "Enter" || event.key === " ") {
|
|
120
205
|
event.preventDefault();
|
|
121
|
-
|
|
206
|
+
editTitle();
|
|
122
207
|
}
|
|
123
208
|
});
|
|
124
209
|
}
|
|
125
210
|
|
|
126
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
|
+
|
|
127
222
|
if (descEl) {
|
|
128
|
-
descEl.addEventListener("click", () => {
|
|
129
|
-
|
|
223
|
+
descEl.addEventListener("click", (event) => {
|
|
224
|
+
event.preventDefault();
|
|
225
|
+
editDescription();
|
|
130
226
|
});
|
|
131
227
|
descEl.addEventListener("keydown", (event) => {
|
|
132
228
|
if (event.key === "Enter" || event.key === " ") {
|
|
133
229
|
event.preventDefault();
|
|
134
|
-
|
|
230
|
+
editDescription();
|
|
135
231
|
}
|
|
136
232
|
});
|
|
137
233
|
}
|
|
138
234
|
|
|
139
|
-
|
|
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) => {
|
|
140
270
|
select.addEventListener("change", async () => {
|
|
141
271
|
const field = select.dataset.metaField;
|
|
142
272
|
if (field === "type") {
|
|
@@ -195,7 +325,7 @@ function wireTicketDetailEditors(ticket) {
|
|
|
195
325
|
});
|
|
196
326
|
}
|
|
197
327
|
|
|
198
|
-
export async function renderDetail() {
|
|
328
|
+
export async function renderDetail(options = {}) {
|
|
199
329
|
if (state.detailMode === "settings") {
|
|
200
330
|
await renderProjectDetail();
|
|
201
331
|
openDrawer();
|
|
@@ -207,9 +337,22 @@ export async function renderDetail() {
|
|
|
207
337
|
return;
|
|
208
338
|
}
|
|
209
339
|
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
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: [] })
|
|
213
356
|
]);
|
|
214
357
|
const ticket = context.ticket;
|
|
215
358
|
// Acknowledge the read receipt: clear the unread dot on this card.
|
|
@@ -259,42 +402,39 @@ export async function renderDetail() {
|
|
|
259
402
|
const datalistOptions = catalogNames.map((name) => `<option value="${escapeHtml(name)}"></option>`).join("");
|
|
260
403
|
|
|
261
404
|
const detailCanonicalType = canonicalTicketType(ticket.type);
|
|
262
|
-
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";
|
|
263
412
|
const detailSubtitleHtml = `
|
|
264
413
|
<span class="ticket-number">${escapeHtml(ticketLabel(ticket))}</span>
|
|
265
|
-
<div class="
|
|
266
|
-
|
|
267
|
-
<
|
|
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>
|
|
268
418
|
</div>
|
|
269
|
-
${renderPriorityPill(ticket.priority)}
|
|
270
419
|
`;
|
|
271
420
|
|
|
272
421
|
renderDrawerShell({
|
|
422
|
+
mode: "ticket",
|
|
273
423
|
title: ticket.title,
|
|
274
424
|
titleAttrs: {
|
|
275
425
|
class: "editable-field",
|
|
276
426
|
"data-edit-field": "title",
|
|
427
|
+
role: "button",
|
|
277
428
|
tabindex: "0",
|
|
278
|
-
title: "Click to edit"
|
|
429
|
+
title: "Click or press Enter to edit ticket title",
|
|
430
|
+
"aria-label": "Edit ticket title"
|
|
279
431
|
},
|
|
280
432
|
subtitleHtml: detailSubtitleHtml,
|
|
281
433
|
body: `
|
|
282
434
|
<div class="detail-head">
|
|
283
435
|
<p class="detail-updated" title="Last saved change">Updated ${escapeHtml(formatDateDetail(ticket.updated_at))}</p>
|
|
284
|
-
<
|
|
436
|
+
<div class="${descriptionClass}" data-edit-field="description" tabindex="0" title="Click to edit">${descriptionHtml}</div>
|
|
285
437
|
<dl class="ticket-meta" id="ticketMetaGrid" data-ticket-id="${escapeHtml(ticket.id)}">
|
|
286
|
-
<div class="ticket-meta-row">
|
|
287
|
-
<dt>State</dt>
|
|
288
|
-
<dd><select class="meta-select meta-inline" data-meta-field="state_id" aria-label="Lane / state">${stateOptions}</select></dd>
|
|
289
|
-
</div>
|
|
290
|
-
<div class="ticket-meta-row">
|
|
291
|
-
<dt>Type</dt>
|
|
292
|
-
<dd><select class="meta-select meta-inline" data-meta-field="type" aria-label="Ticket type">${typeOptions}</select></dd>
|
|
293
|
-
</div>
|
|
294
|
-
<div class="ticket-meta-row">
|
|
295
|
-
<dt>Priority</dt>
|
|
296
|
-
<dd><select class="meta-select meta-inline" data-meta-field="priority" aria-label="Priority">${priorityOptions}</select></dd>
|
|
297
|
-
</div>
|
|
298
438
|
<div class="ticket-meta-row ticket-meta-row--stack">
|
|
299
439
|
<dt>Labels</dt>
|
|
300
440
|
<dd>
|
|
@@ -311,6 +451,8 @@ export async function renderDetail() {
|
|
|
311
451
|
|
|
312
452
|
${renderHierarchySection(ticket, context)}
|
|
313
453
|
|
|
454
|
+
${features.attachments ? renderAttachmentSection(ticket, attachmentList.attachments || []) : ""}
|
|
455
|
+
|
|
314
456
|
${renderParentEpicSection(ticket, context)}
|
|
315
457
|
|
|
316
458
|
<div class="section">
|
|
@@ -321,21 +463,26 @@ export async function renderDetail() {
|
|
|
321
463
|
${currentBoard()?.ai_enabled !== 0 ? `
|
|
322
464
|
<details class="section ai-fields" data-ai-plan-toggle data-ticket-id="${escapeHtml(ticket.id)}"${aiPlanOpen(ticket.id) ? " open" : ""}>
|
|
323
465
|
<summary><h3>AI Plan / Implementation Record</h3></summary>
|
|
324
|
-
<
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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>
|
|
339
486
|
</details>` : ""}
|
|
340
487
|
|
|
341
488
|
<div class="section">
|
|
@@ -345,7 +492,7 @@ export async function renderDetail() {
|
|
|
345
492
|
|
|
346
493
|
<div class="section">
|
|
347
494
|
<h3>Comments</h3>
|
|
348
|
-
${
|
|
495
|
+
${comments.map(renderComment).join("") || `<p class="description">No comments yet.</p>`}
|
|
349
496
|
<form class="comment-form" id="detailCommentForm">
|
|
350
497
|
<textarea name="body" placeholder="Add a comment, decision, or instruction..." required></textarea>
|
|
351
498
|
<button type="submit">Comment</button>
|
|
@@ -355,37 +502,17 @@ export async function renderDetail() {
|
|
|
355
502
|
});
|
|
356
503
|
|
|
357
504
|
wireTicketDetailEditors(ticket);
|
|
358
|
-
|
|
359
|
-
$("#aiFieldsForm")?.addEventListener("submit", async (event) => {
|
|
360
|
-
event.preventDefault();
|
|
361
|
-
const form = new FormData(event.currentTarget);
|
|
362
|
-
await api(withBoardQuery(`/api/tickets/${ticket.id}`), {
|
|
363
|
-
method: "PATCH",
|
|
364
|
-
body: {
|
|
365
|
-
ai_plan: form.get("ai_plan"),
|
|
366
|
-
implementation_summary: form.get("implementation_summary"),
|
|
367
|
-
implementation_updates: form.get("implementation_updates")
|
|
368
|
-
}
|
|
369
|
-
});
|
|
370
|
-
await load();
|
|
371
|
-
state.selectedTicketId = ticket.id;
|
|
372
|
-
state.detailMode = "ticket";
|
|
373
|
-
await renderDetail();
|
|
374
|
-
toast.success("AI fields saved");
|
|
375
|
-
});
|
|
505
|
+
if (features.attachments) wireAttachmentControls(ticket);
|
|
376
506
|
|
|
377
507
|
$("#detailCommentForm").addEventListener("submit", async (event) => {
|
|
378
508
|
event.preventDefault();
|
|
379
|
-
const body = new FormData(event.currentTarget).get("body").trim();
|
|
509
|
+
const body = cleanText(new FormData(event.currentTarget).get("body")).trim();
|
|
380
510
|
if (!body) return;
|
|
381
511
|
await api(withBoardQuery(`/api/tickets/${ticket.id}/comments`), {
|
|
382
512
|
method: "POST",
|
|
383
513
|
body: { body }
|
|
384
514
|
});
|
|
385
|
-
await
|
|
386
|
-
state.selectedTicketId = ticket.id;
|
|
387
|
-
state.detailMode = "ticket";
|
|
388
|
-
await renderDetail();
|
|
515
|
+
await refreshTicketDetail(ticket.id);
|
|
389
516
|
});
|
|
390
517
|
|
|
391
518
|
drawerInner.querySelectorAll("[data-open-ticket]").forEach((card) => {
|
|
@@ -412,10 +539,7 @@ export async function renderDetail() {
|
|
|
412
539
|
toast.error(err.message || "Failed to remove child");
|
|
413
540
|
return;
|
|
414
541
|
}
|
|
415
|
-
await
|
|
416
|
-
state.selectedTicketId = ticket.id;
|
|
417
|
-
state.detailMode = "ticket";
|
|
418
|
-
await renderDetail();
|
|
542
|
+
await refreshTicketDetail(ticket.id, { renderBoardAfter: true });
|
|
419
543
|
toast.success("Removed from epic");
|
|
420
544
|
});
|
|
421
545
|
});
|
|
@@ -432,10 +556,7 @@ export async function renderDetail() {
|
|
|
432
556
|
toast.error(err.message || "Failed to remove parent");
|
|
433
557
|
return;
|
|
434
558
|
}
|
|
435
|
-
await
|
|
436
|
-
state.selectedTicketId = ticket.id;
|
|
437
|
-
state.detailMode = "ticket";
|
|
438
|
-
await renderDetail();
|
|
559
|
+
await refreshTicketDetail(ticket.id, { renderBoardAfter: true });
|
|
439
560
|
toast.success("Parent epic removed");
|
|
440
561
|
});
|
|
441
562
|
});
|
|
@@ -450,10 +571,7 @@ export async function renderDetail() {
|
|
|
450
571
|
toast.error(err.message || "Failed to remove relation");
|
|
451
572
|
return;
|
|
452
573
|
}
|
|
453
|
-
await
|
|
454
|
-
state.selectedTicketId = ticket.id;
|
|
455
|
-
state.detailMode = "ticket";
|
|
456
|
-
await renderDetail();
|
|
574
|
+
await refreshTicketDetail(ticket.id);
|
|
457
575
|
toast.success("Relation removed");
|
|
458
576
|
});
|
|
459
577
|
});
|
|
@@ -504,10 +622,7 @@ export async function renderDetail() {
|
|
|
504
622
|
toast.error(err.message || "Failed to set parent epic");
|
|
505
623
|
return;
|
|
506
624
|
}
|
|
507
|
-
await
|
|
508
|
-
state.selectedTicketId = ticket.id;
|
|
509
|
-
state.detailMode = "ticket";
|
|
510
|
-
await renderDetail();
|
|
625
|
+
await refreshTicketDetail(ticket.id, { renderBoardAfter: true });
|
|
511
626
|
toast.success("Parent epic set");
|
|
512
627
|
});
|
|
513
628
|
}
|
|
@@ -546,10 +661,7 @@ export async function renderDetail() {
|
|
|
546
661
|
toast.error(err.message || "Failed to add relation");
|
|
547
662
|
return;
|
|
548
663
|
}
|
|
549
|
-
await
|
|
550
|
-
state.selectedTicketId = ticket.id;
|
|
551
|
-
state.detailMode = "ticket";
|
|
552
|
-
await renderDetail();
|
|
664
|
+
await refreshTicketDetail(ticket.id);
|
|
553
665
|
});
|
|
554
666
|
}
|
|
555
667
|
|
|
@@ -560,6 +672,172 @@ function aiPlanOpen(ticketId) {
|
|
|
560
672
|
return localStorage.getItem(`mab_ai_plan_open:${ticketId}`) === "1";
|
|
561
673
|
}
|
|
562
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
|
+
|
|
563
841
|
function resolveTicketIdFromLabel(label, selfId) {
|
|
564
842
|
const trimmed = label.trim();
|
|
565
843
|
const match = trimmed.match(/^#?(\d+)\b/);
|
|
@@ -582,13 +860,13 @@ function renderStatusHistory(history) {
|
|
|
582
860
|
const body = event.body;
|
|
583
861
|
const from = body.from ? escapeHtml(body.from) : "?";
|
|
584
862
|
const to = escapeHtml(body.to || body.state || body.next_state || "?");
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
863
|
+
const byLine = escapeHtml(
|
|
864
|
+
formatActorLabel({
|
|
865
|
+
actor: event.actor,
|
|
866
|
+
actorType: body.actor_type || "human",
|
|
867
|
+
actorId: body.actor_id
|
|
868
|
+
})
|
|
869
|
+
);
|
|
592
870
|
return `<div class="comment status-change">
|
|
593
871
|
<div class="comment-meta">
|
|
594
872
|
<span class="status-route">${from} → ${to}</span>
|
|
@@ -603,10 +881,36 @@ function renderComment(comment) {
|
|
|
603
881
|
return `
|
|
604
882
|
<div class="comment ${comment.kind === "checkpoint" ? "checkpoint" : ""}">
|
|
605
883
|
<div class="comment-meta">
|
|
606
|
-
<strong>${escapeHtml(comment
|
|
884
|
+
<strong>${escapeHtml(formatCommentAuthor(comment))}</strong>
|
|
607
885
|
<span>${comment.kind === "human_comment" ? "" : `${escapeHtml(comment.kind)} - `}${formatDate(comment.created_at)}</span>
|
|
608
886
|
</div>
|
|
609
|
-
<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>
|
|
610
914
|
</div>
|
|
611
915
|
`;
|
|
612
916
|
}
|
|
@@ -699,6 +1003,7 @@ function renderParentEpicSection(ticket, context) {
|
|
|
699
1003
|
export function renderDetailCard(ticket, options = {}) {
|
|
700
1004
|
const canonicalType = canonicalTicketType(ticket.type);
|
|
701
1005
|
const priorityKey = priorityKeyFor(ticket.priority);
|
|
1006
|
+
const stateClass = stateClassFor(ticket);
|
|
702
1007
|
const typeText = typeLabel(ticket.type).toUpperCase();
|
|
703
1008
|
const eyebrow = options.eyebrow
|
|
704
1009
|
? `<span class="card-eyebrow">${escapeHtml(options.eyebrow)}</span>`
|
|
@@ -749,6 +1054,7 @@ export function renderDetailCard(ticket, options = {}) {
|
|
|
749
1054
|
${renderTypeIcon(ticket.type)}
|
|
750
1055
|
<span class="card-type-label">${escapeHtml(typeText)}</span>
|
|
751
1056
|
</div>
|
|
1057
|
+
<span class="detail-card-state state-pill-${escapeHtml(stateClass)}">${escapeHtml(ticket.state_name || "State")}</span>
|
|
752
1058
|
${state.showPriority ? renderPriorityPill(ticket.priority) : ""}
|
|
753
1059
|
</div>
|
|
754
1060
|
</div>
|