@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
package/public/js/format.js
CHANGED
|
@@ -10,6 +10,112 @@ export function escapeHtml(value) {
|
|
|
10
10
|
.replaceAll("'", "'");
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
// UTF-8 bytes mis-decoded as CP-437 (the typical Windows-tmux artifact).
|
|
14
|
+
// Ported from aeva-dashboard's fix-newlines tool — every entry is
|
|
15
|
+
// [mojibake sequence, what it should have been]. Long sequences first so
|
|
16
|
+
// shorter prefixes do not eat them.
|
|
17
|
+
const MOJIBAKE_REPLACEMENTS = [
|
|
18
|
+
// General Punctuation (UTF-8 E2 80 XX → CP-437 "ΓÇ" + glyph)
|
|
19
|
+
["ΓÇô", "–"],
|
|
20
|
+
["ΓÇö", "—"],
|
|
21
|
+
["ΓÇ£", '"'],
|
|
22
|
+
["ΓÇ¥", '"'],
|
|
23
|
+
["ΓÇÿ", "'"],
|
|
24
|
+
["ΓÇÖ", "'"],
|
|
25
|
+
["ΓǪ", "…"],
|
|
26
|
+
["ΓÇó", "•"],
|
|
27
|
+
["ΓÇ░", "‰"],
|
|
28
|
+
["ΓÇ╣", "‹"],
|
|
29
|
+
["ΓÇ║", "›"],
|
|
30
|
+
|
|
31
|
+
// Box drawing — light (UTF-8 E2 94 XX → CP-437 "Γö" + glyph)
|
|
32
|
+
["ΓöÇ", "─"],
|
|
33
|
+
["Γöé", "│"],
|
|
34
|
+
["Γöî", "┌"],
|
|
35
|
+
["ΓöÉ", "┐"],
|
|
36
|
+
["Γöö", "└"],
|
|
37
|
+
["Γöÿ", "┘"],
|
|
38
|
+
["Γö£", "├"],
|
|
39
|
+
["Γöñ", "┤"],
|
|
40
|
+
["Γö¼", "┬"],
|
|
41
|
+
["Γö┤", "┴"],
|
|
42
|
+
["Γö╝", "┼"],
|
|
43
|
+
|
|
44
|
+
// Box drawing — heavy/double + rounded corners (UTF-8 E2 95 XX → "Γò" + glyph)
|
|
45
|
+
["ΓòÉ", "═"],
|
|
46
|
+
["Γòæ", "║"],
|
|
47
|
+
["ΓòÆ", "╒"],
|
|
48
|
+
["Γòö", "╔"],
|
|
49
|
+
["Γò¡", "╭"],
|
|
50
|
+
["Γò«", "╮"],
|
|
51
|
+
["Γò»", "╯"],
|
|
52
|
+
["Γò░", "╰"],
|
|
53
|
+
|
|
54
|
+
// Geometric shapes (UTF-8 E2 96 XX / E2 97 XX → "Γû" / "Γù" + glyph)
|
|
55
|
+
["Γûæ", "░"],
|
|
56
|
+
["ΓûÆ", "▒"],
|
|
57
|
+
["Γûô", "▓"],
|
|
58
|
+
["ΓûÉ", "▐"],
|
|
59
|
+
["Γûî", "▌"],
|
|
60
|
+
["ΓûÄ", ">"],
|
|
61
|
+
["Γûá", "■"],
|
|
62
|
+
["Γû╝", "▼"],
|
|
63
|
+
["ΓùÅ", "●"],
|
|
64
|
+
["Γùï", "○"],
|
|
65
|
+
|
|
66
|
+
// Misc symbols (UTF-8 E2 9A XX → "ΓÜ" + glyph)
|
|
67
|
+
["ΓÜò", "⚕"],
|
|
68
|
+
["ΓÜá", "⚠"],
|
|
69
|
+
["ΓÜí", "⚡"],
|
|
70
|
+
["ΓÜô", "⚓"],
|
|
71
|
+
|
|
72
|
+
// Arrows (UTF-8 E2 86 XX → "Γå" + glyph)
|
|
73
|
+
["ΓåÉ", "←"],
|
|
74
|
+
["Γåæ", "↑"],
|
|
75
|
+
["ΓåÆ", "→"],
|
|
76
|
+
["Γåô", "↓"],
|
|
77
|
+
["Γåö", "↔"],
|
|
78
|
+
|
|
79
|
+
// Latin-1 Supplement (UTF-8 C2 XX → CP-437 "┬" + glyph)
|
|
80
|
+
[" ", " "],
|
|
81
|
+
["┬⌐", "©"],
|
|
82
|
+
["┬«", "®"],
|
|
83
|
+
["┬░", "°"],
|
|
84
|
+
["┬º", "§"],
|
|
85
|
+
["┬╖", "·"],
|
|
86
|
+
|
|
87
|
+
// Stray curly quotes that survived as proper Unicode — Bruce writes with
|
|
88
|
+
// ASCII straight quotes, so normalize for consistency.
|
|
89
|
+
["“", '"'],
|
|
90
|
+
["”", '"'],
|
|
91
|
+
["‘", "'"],
|
|
92
|
+
["’", "'"],
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
function fixMojibake(text) {
|
|
96
|
+
if (!text) return text;
|
|
97
|
+
let out = text;
|
|
98
|
+
for (const [from, to] of MOJIBAKE_REPLACEMENTS) {
|
|
99
|
+
out = out.split(from).join(to);
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Repair common mojibake and normalize line endings without disturbing the
|
|
106
|
+
* author's indentation or soft line breaks. Use this on inbound text before
|
|
107
|
+
* saving (so the DB stays clean going forward) and again before rendering (so
|
|
108
|
+
* existing dirty rows display fixed without a migration).
|
|
109
|
+
*/
|
|
110
|
+
export function cleanText(value) {
|
|
111
|
+
if (!value) return "";
|
|
112
|
+
return fixMojibake(String(value)).replace(/\r\n?/g, "\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function renderPreservedText(value) {
|
|
116
|
+
return escapeHtml(cleanText(value));
|
|
117
|
+
}
|
|
118
|
+
|
|
13
119
|
const SAFE_LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:", "tel:"]);
|
|
14
120
|
|
|
15
121
|
function sanitizeMarkdownUrl(value) {
|
|
@@ -86,14 +192,31 @@ function renderParagraph(lines) {
|
|
|
86
192
|
return `<p>${renderInlineMarkdown(lines.join("\n")).replaceAll("\n", "<br>")}</p>`;
|
|
87
193
|
}
|
|
88
194
|
|
|
195
|
+
function renderTaskItem(rawContent) {
|
|
196
|
+
// GFM-style `- [ ] text` or `- [x] text`. We render as a disabled checkbox
|
|
197
|
+
// followed by the inline-rendered remainder. The list itself gets a
|
|
198
|
+
// `task-list` class so CSS can drop the default bullet.
|
|
199
|
+
const match = rawContent.match(/^\[( |x|X)\]\s+(.*)$/);
|
|
200
|
+
if (!match) return null;
|
|
201
|
+
const checked = match[1].toLowerCase() === "x";
|
|
202
|
+
const body = renderInlineMarkdown(match[2].trim()).replaceAll("\n", "<br>");
|
|
203
|
+
return {
|
|
204
|
+
checked,
|
|
205
|
+
html: `<input type="checkbox" disabled${checked ? " checked" : ""}> ${body}`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
89
209
|
export function renderMarkdown(value) {
|
|
90
|
-
|
|
210
|
+
// Mojibake repair runs at render time too so existing dirty rows display
|
|
211
|
+
// cleanly without a backfill. cleanText also normalizes line endings.
|
|
212
|
+
const source = cleanText(value);
|
|
91
213
|
if (!source.trim()) return "";
|
|
92
214
|
|
|
93
215
|
const blocks = [];
|
|
94
216
|
const lines = source.split("\n");
|
|
95
217
|
let paragraph = [];
|
|
96
218
|
let list = null;
|
|
219
|
+
let blockquote = null;
|
|
97
220
|
|
|
98
221
|
const flushParagraph = () => {
|
|
99
222
|
if (!paragraph.length) return;
|
|
@@ -103,16 +226,32 @@ export function renderMarkdown(value) {
|
|
|
103
226
|
|
|
104
227
|
const flushList = () => {
|
|
105
228
|
if (!list) return;
|
|
106
|
-
|
|
229
|
+
const classAttr = list.taskList ? ' class="task-list"' : "";
|
|
230
|
+
blocks.push(
|
|
231
|
+
`<${list.type}${classAttr}>${list.items
|
|
232
|
+
.map((item) => `<li${item.taskClass ? ` class="${item.taskClass}"` : ""}>${item.html}</li>`)
|
|
233
|
+
.join("")}</${list.type}>`
|
|
234
|
+
);
|
|
107
235
|
list = null;
|
|
108
236
|
};
|
|
109
237
|
|
|
238
|
+
const flushBlockquote = () => {
|
|
239
|
+
if (!blockquote) return;
|
|
240
|
+
blocks.push(`<blockquote>${renderMarkdown(blockquote.join("\n"))}</blockquote>`);
|
|
241
|
+
blockquote = null;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const flushAll = () => {
|
|
245
|
+
flushParagraph();
|
|
246
|
+
flushList();
|
|
247
|
+
flushBlockquote();
|
|
248
|
+
};
|
|
249
|
+
|
|
110
250
|
for (let i = 0; i < lines.length; i += 1) {
|
|
111
251
|
const line = lines[i];
|
|
112
252
|
|
|
113
253
|
if (/^\s*```/.test(line)) {
|
|
114
|
-
|
|
115
|
-
flushList();
|
|
254
|
+
flushAll();
|
|
116
255
|
const codeLines = [];
|
|
117
256
|
i += 1;
|
|
118
257
|
while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) {
|
|
@@ -123,6 +262,38 @@ export function renderMarkdown(value) {
|
|
|
123
262
|
continue;
|
|
124
263
|
}
|
|
125
264
|
|
|
265
|
+
// Horizontal rule: a line of three+ dashes / asterisks / underscores.
|
|
266
|
+
if (/^\s*(?:-\s*){3,}\s*$/.test(line) || /^\s*(?:\*\s*){3,}\s*$/.test(line) || /^\s*(?:_\s*){3,}\s*$/.test(line)) {
|
|
267
|
+
flushAll();
|
|
268
|
+
blocks.push("<hr>");
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ATX header: 1-6 # followed by a space and content.
|
|
273
|
+
const header = line.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/);
|
|
274
|
+
if (header) {
|
|
275
|
+
flushAll();
|
|
276
|
+
const level = header[1].length;
|
|
277
|
+
blocks.push(`<h${level}>${renderInlineMarkdown(header[2])}</h${level}>`);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Blockquote: `> ...`. We collect consecutive `>` lines, strip the leading
|
|
282
|
+
// marker, and recurse so quoted content can itself contain lists/headers.
|
|
283
|
+
const quoteMatch = line.match(/^\s{0,3}>\s?(.*)$/);
|
|
284
|
+
if (quoteMatch) {
|
|
285
|
+
flushParagraph();
|
|
286
|
+
flushList();
|
|
287
|
+
if (!blockquote) blockquote = [];
|
|
288
|
+
blockquote.push(quoteMatch[1]);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (blockquote && !line.trim()) {
|
|
292
|
+
flushBlockquote();
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
flushBlockquote();
|
|
296
|
+
|
|
126
297
|
if (!line.trim()) {
|
|
127
298
|
flushParagraph();
|
|
128
299
|
flushList();
|
|
@@ -134,20 +305,34 @@ export function renderMarkdown(value) {
|
|
|
134
305
|
if (ordered || unordered) {
|
|
135
306
|
flushParagraph();
|
|
136
307
|
const type = ordered ? "ol" : "ul";
|
|
137
|
-
|
|
308
|
+
const content = (ordered || unordered)[1].trim();
|
|
309
|
+
|
|
310
|
+
let task = null;
|
|
311
|
+
if (unordered) task = renderTaskItem(content);
|
|
312
|
+
|
|
313
|
+
if (!list || list.type !== type || Boolean(list.taskList) !== Boolean(task)) {
|
|
138
314
|
flushList();
|
|
139
|
-
list = { type, items: [] };
|
|
315
|
+
list = { type, items: [], taskList: Boolean(task) };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (task) {
|
|
319
|
+
list.items.push({
|
|
320
|
+
html: task.html,
|
|
321
|
+
taskClass: task.checked ? "task-item task-item-done" : "task-item",
|
|
322
|
+
});
|
|
323
|
+
} else {
|
|
324
|
+
list.items.push({
|
|
325
|
+
html: renderInlineMarkdown(content).replaceAll("\n", "<br>"),
|
|
326
|
+
});
|
|
140
327
|
}
|
|
141
|
-
list.items.push(renderInlineMarkdown((ordered || unordered)[1].trim()).replaceAll("\n", "<br>"));
|
|
142
328
|
continue;
|
|
143
329
|
}
|
|
144
330
|
|
|
145
331
|
flushList();
|
|
146
|
-
paragraph.push(line
|
|
332
|
+
paragraph.push(line);
|
|
147
333
|
}
|
|
148
334
|
|
|
149
|
-
|
|
150
|
-
flushList();
|
|
335
|
+
flushAll();
|
|
151
336
|
|
|
152
337
|
return blocks.join("");
|
|
153
338
|
}
|
|
@@ -179,6 +364,15 @@ export function ticketLabel(ticket) {
|
|
|
179
364
|
return `#${ticket.number}`;
|
|
180
365
|
}
|
|
181
366
|
|
|
367
|
+
export function stateClassFor(ticketOrState) {
|
|
368
|
+
const source =
|
|
369
|
+
ticketOrState?.state_role || ticketOrState?.role || ticketOrState?.state_name || ticketOrState?.name || "state";
|
|
370
|
+
return String(source)
|
|
371
|
+
.toLowerCase()
|
|
372
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
373
|
+
.replace(/^-|-$/g, "") || "state";
|
|
374
|
+
}
|
|
375
|
+
|
|
182
376
|
export function canonicalTicketType(type) {
|
|
183
377
|
if (type === "epic" || type === "feature" || type === "bug" || type === "task") return type;
|
|
184
378
|
return "task";
|
package/public/js/kanban.js
CHANGED
|
@@ -19,6 +19,7 @@ import { api, withBoardQuery } from "./api.js";
|
|
|
19
19
|
import { navigate } from "./router.js";
|
|
20
20
|
import { closeCreateFlyout, openCreateFlyout } from "./create-card.js";
|
|
21
21
|
import { load } from "./app.js";
|
|
22
|
+
import { bindCardContextMenu, closeCardActionMenu, createCardActionHandlers, setCardActionDragInFlight } from "./card-actions.js";
|
|
22
23
|
import { unreadCount } from "./unread.js";
|
|
23
24
|
|
|
24
25
|
export function renderBoard() {
|
|
@@ -66,7 +67,10 @@ export function renderBoard() {
|
|
|
66
67
|
});
|
|
67
68
|
});
|
|
68
69
|
|
|
70
|
+
const cardActionHandlers = createCardActionHandlers({ navigator: navigate });
|
|
69
71
|
kanban.querySelectorAll(".card").forEach((card) => {
|
|
72
|
+
const ticket = tickets.find((item) => item.id === card.dataset.ticketId);
|
|
73
|
+
if (ticket) bindCardContextMenu(card, ticket, { handlers: cardActionHandlers, onBeforeOpen: closeCreateFlyout });
|
|
70
74
|
// HTML5 DnD ends with a synthetic click on the source element; ignore that
|
|
71
75
|
// follow-up click so dropping a card does not also open the detail drawer.
|
|
72
76
|
let suppressClickAfterDrag = false;
|
|
@@ -102,10 +106,13 @@ export function renderBoard() {
|
|
|
102
106
|
});
|
|
103
107
|
}
|
|
104
108
|
card.addEventListener("dragstart", (event) => {
|
|
109
|
+
closeCardActionMenu();
|
|
110
|
+
setCardActionDragInFlight(true);
|
|
105
111
|
event.dataTransfer.setData("text/plain", card.dataset.ticketId);
|
|
106
112
|
event.dataTransfer.effectAllowed = "move";
|
|
107
113
|
});
|
|
108
114
|
card.addEventListener("dragend", () => {
|
|
115
|
+
setCardActionDragInFlight(false);
|
|
109
116
|
suppressClickAfterDrag = true;
|
|
110
117
|
// If no click follows (e.g. drag cancelled oddly), do not eat the next real click.
|
|
111
118
|
setTimeout(() => {
|
|
@@ -115,6 +122,14 @@ export function renderBoard() {
|
|
|
115
122
|
});
|
|
116
123
|
}
|
|
117
124
|
|
|
125
|
+
export function renderBoardSelection() {
|
|
126
|
+
kanban.querySelectorAll(".card.selected").forEach((card) => card.classList.remove("selected"));
|
|
127
|
+
if (!state.selectedTicketId) return;
|
|
128
|
+
kanban
|
|
129
|
+
.querySelectorAll(`.card[data-ticket-id="${CSS.escape(state.selectedTicketId)}"]`)
|
|
130
|
+
.forEach((card) => card.classList.add("selected"));
|
|
131
|
+
}
|
|
132
|
+
|
|
118
133
|
function renderEmptyState(columnName) {
|
|
119
134
|
const name = columnName.toLowerCase();
|
|
120
135
|
let flair = {
|
|
@@ -464,7 +464,6 @@ async function handleBoardPatch(boardId, body) {
|
|
|
464
464
|
const board = await reqPromise(stores.boards.get(BOARD_ID));
|
|
465
465
|
const ALLOWED = [
|
|
466
466
|
"name",
|
|
467
|
-
"slug",
|
|
468
467
|
"repo_url",
|
|
469
468
|
"system_path",
|
|
470
469
|
"default_branch",
|
|
@@ -472,7 +471,16 @@ async function handleBoardPatch(boardId, body) {
|
|
|
472
471
|
"agent_instructions",
|
|
473
472
|
"ai_enabled"
|
|
474
473
|
];
|
|
475
|
-
for (const k of ALLOWED)
|
|
474
|
+
for (const k of ALLOWED) {
|
|
475
|
+
if (!(k in body)) continue;
|
|
476
|
+
if (k === "name") {
|
|
477
|
+
const name = String(body[k] || "").trim();
|
|
478
|
+
if (!name) throw err(400, "missing_name");
|
|
479
|
+
board[k] = name;
|
|
480
|
+
} else {
|
|
481
|
+
board[k] = body[k];
|
|
482
|
+
}
|
|
483
|
+
}
|
|
476
484
|
board.updated_at = nowIso();
|
|
477
485
|
stores.boards.put(board);
|
|
478
486
|
recordEvent(stores, "board_updated", null, { fields: Object.keys(body) });
|
|
@@ -728,9 +736,6 @@ async function handleTicketContext(ticketId) {
|
|
|
728
736
|
.map((tl) => snap.labels.find((l) => l.id === tl.label_id))
|
|
729
737
|
.filter(Boolean)
|
|
730
738
|
.map((l) => ({ ticket_id: ticketId, id: l.id, name: l.name, color: l.color }));
|
|
731
|
-
const comments = snap.comments
|
|
732
|
-
.filter((c) => c.ticket_id === ticketId)
|
|
733
|
-
.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
|
|
734
739
|
const stateById = Object.fromEntries(snap.states.map((s) => [s.id, s]));
|
|
735
740
|
const compact = (t) => {
|
|
736
741
|
if (!t) return null;
|
|
@@ -775,13 +780,23 @@ async function handleTicketContext(ticketId) {
|
|
|
775
780
|
ticket: { ...ticket, labels },
|
|
776
781
|
board: snap.board,
|
|
777
782
|
board_manual: { entries: snap.boardEntries.filter((e) => !e.struck_at) },
|
|
778
|
-
comments,
|
|
779
783
|
parent_ticket: compact(parent),
|
|
780
784
|
child_tickets: childTickets,
|
|
781
785
|
relations,
|
|
782
786
|
blockers: [],
|
|
783
|
-
related_tickets: [...childTickets, ...(parent ? [parent] : [])]
|
|
784
|
-
|
|
787
|
+
related_tickets: [...childTickets, ...(parent ? [parent] : [])]
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
async function handleTicketComments(ticketId) {
|
|
792
|
+
const snap = await snapshotAll();
|
|
793
|
+
const ticket = snap.tickets.find((t) => t.id === ticketId && !t.archived_at);
|
|
794
|
+
if (!ticket) throw err(404, "ticket_not_found");
|
|
795
|
+
return {
|
|
796
|
+
ticket_id: ticketId,
|
|
797
|
+
comments: snap.comments
|
|
798
|
+
.filter((c) => c.ticket_id === ticketId)
|
|
799
|
+
.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)))
|
|
785
800
|
};
|
|
786
801
|
}
|
|
787
802
|
|
|
@@ -941,6 +956,7 @@ const ROUTES = [
|
|
|
941
956
|
{ m: "POST", re: /^\/api\/tickets\/([^/]+)\/archive$/, fn: (m) => handleTicketArchive(decodeURIComponent(m[1]), true) },
|
|
942
957
|
{ m: "POST", re: /^\/api\/tickets\/([^/]+)\/restore$/, fn: (m) => handleTicketArchive(decodeURIComponent(m[1]), false) },
|
|
943
958
|
{ m: "GET", re: /^\/api\/tickets\/([^/]+)\/context$/, fn: (m) => handleTicketContext(decodeURIComponent(m[1])) },
|
|
959
|
+
{ m: "GET", re: /^\/api\/tickets\/([^/]+)\/comments$/, fn: (m) => handleTicketComments(decodeURIComponent(m[1])) },
|
|
944
960
|
{ m: "GET", re: /^\/api\/tickets\/([^/]+)\/history$/, fn: (m) => handleTicketHistory(decodeURIComponent(m[1])) },
|
|
945
961
|
{ m: "POST", re: /^\/api\/tickets\/([^/]+)\/comments$/, fn: (m, u, body) => handleCommentCreate(decodeURIComponent(m[1]), body) },
|
|
946
962
|
{ m: "POST", re: /^\/api\/relations$/, fn: (m, u, body) => handleRelationCreate(body) },
|
package/public/js/router.js
CHANGED
|
@@ -81,7 +81,7 @@ export function syncUrlFromState({ replace = false } = {}) {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
export async function applyRoute() {
|
|
84
|
-
const { load,
|
|
84
|
+
const { load, renderDetailOnly } = await import("./app.js");
|
|
85
85
|
const r = currentRoute();
|
|
86
86
|
const urlHadRoute = hasRoute(location);
|
|
87
87
|
const prevBoardId = state.boardId;
|
|
@@ -124,8 +124,9 @@ export async function applyRoute() {
|
|
|
124
124
|
|
|
125
125
|
if (state.boardId && state.boardId !== prevBoardId) {
|
|
126
126
|
await load();
|
|
127
|
+
return;
|
|
127
128
|
}
|
|
128
|
-
await
|
|
129
|
+
await renderDetailOnly();
|
|
129
130
|
}
|
|
130
131
|
|
|
131
132
|
window.addEventListener("popstate", () => {
|
package/public/js/search.js
CHANGED
|
@@ -6,7 +6,7 @@ import { searchInput, searchResults } from "./dom.js";
|
|
|
6
6
|
import { state, currentBoard } from "./state.js";
|
|
7
7
|
import { api } from "./api.js";
|
|
8
8
|
import { navigate } from "./router.js";
|
|
9
|
-
import { escapeHtml, ticketLabel } from "./format.js";
|
|
9
|
+
import { escapeHtml, stateClassFor, ticketLabel } from "./format.js";
|
|
10
10
|
import { closeCreateFlyout } from "./create-card.js";
|
|
11
11
|
|
|
12
12
|
export async function runSearch() {
|
|
@@ -28,8 +28,11 @@ export async function runSearch() {
|
|
|
28
28
|
.map(
|
|
29
29
|
(ticket) => `
|
|
30
30
|
<div class="search-hit" data-ticket-id="${ticket.id}">
|
|
31
|
-
<
|
|
32
|
-
|
|
31
|
+
<span class="search-hit-main">
|
|
32
|
+
<strong>${escapeHtml(ticketLabel(ticket))}</strong>
|
|
33
|
+
<span class="search-hit-title">${escapeHtml(ticket.title)}</span>
|
|
34
|
+
</span>
|
|
35
|
+
<span class="search-hit-state state-pill-${escapeHtml(stateClassFor(ticket))}">${escapeHtml(ticket.state_name || "State")}</span>
|
|
33
36
|
</div>
|
|
34
37
|
`
|
|
35
38
|
)
|
package/public/js/settings.js
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
import { state, getSettingsTab, statesForProject, ticketsForProject, currentBoard } from "./state.js";
|
|
6
6
|
import { drawerInner, $ } from "./dom.js";
|
|
7
|
-
import { escapeHtml, formatDate, repoLabelFromUrl } from "./format.js";
|
|
7
|
+
import { escapeHtml, formatDate, repoLabelFromUrl, renderPreservedText, cleanText } from "./format.js";
|
|
8
8
|
import { api, withBoardQuery } from "./api.js";
|
|
9
9
|
import { toast, downloadJson } from "./toast.js";
|
|
10
10
|
import { renderDrawerShell } from "./drawer.js";
|
|
11
11
|
import { navigate } from "./router.js";
|
|
12
12
|
import { renderBoard } from "./kanban.js";
|
|
13
|
-
import { renderDetailCard } from "./ticket-detail.js";
|
|
13
|
+
import { renderDetailCard, startInlineEdit } from "./ticket-detail.js";
|
|
14
14
|
import { load } from "./app.js";
|
|
15
15
|
import { features } from "./config.js";
|
|
16
16
|
import {
|
|
@@ -377,8 +377,20 @@ function renderProjectRepositoryTab(context) {
|
|
|
377
377
|
return `
|
|
378
378
|
${repoMeta}
|
|
379
379
|
|
|
380
|
+
${features.multiBoard ? renderBoardRenameSection(project) : ""}
|
|
381
|
+
|
|
380
382
|
<div class="section">
|
|
381
383
|
<h3>Board Snapshot</h3>
|
|
384
|
+
${features.attachments ? `
|
|
385
|
+
<label class="orbit-check">
|
|
386
|
+
<input type="checkbox" id="exportProjectImages" />
|
|
387
|
+
<span class="orbit-check-box" aria-hidden="true">
|
|
388
|
+
<svg viewBox="0 0 16 16" class="orbit-check-tick"><path d="M3.5 8.5l3 3 6-7" /></svg>
|
|
389
|
+
</span>
|
|
390
|
+
<span>Include attached images</span>
|
|
391
|
+
</label>
|
|
392
|
+
<p class="description">Image exports are embedded in the .orbit.json snapshot and may be much larger.</p>
|
|
393
|
+
` : ""}
|
|
382
394
|
<div class="deployment-actions">
|
|
383
395
|
<button type="button" class="ghost" id="exportProject">Export</button>
|
|
384
396
|
<label class="import-button">
|
|
@@ -392,6 +404,22 @@ function renderProjectRepositoryTab(context) {
|
|
|
392
404
|
`;
|
|
393
405
|
}
|
|
394
406
|
|
|
407
|
+
function renderBoardRenameSection(project) {
|
|
408
|
+
return `
|
|
409
|
+
<div class="section board-rename-section">
|
|
410
|
+
<h3>Board Name</h3>
|
|
411
|
+
<p class="description">Rename the board display name only. The canonical URL slug stays <strong>${escapeHtml(project.slug || "")}</strong>, so existing board links keep working.</p>
|
|
412
|
+
<form id="boardRenameForm" class="field-form">
|
|
413
|
+
<label>
|
|
414
|
+
<span>Display name</span>
|
|
415
|
+
<input name="name" value="${escapeHtml(project.name || "")}" maxlength="120" required />
|
|
416
|
+
</label>
|
|
417
|
+
<button type="submit">Rename Board</button>
|
|
418
|
+
</form>
|
|
419
|
+
</div>
|
|
420
|
+
`;
|
|
421
|
+
}
|
|
422
|
+
|
|
395
423
|
function renderDeleteBoardSection(project) {
|
|
396
424
|
const slug = escapeHtml(project.slug || "");
|
|
397
425
|
if (repositoryDelete.step === "choice") {
|
|
@@ -442,19 +470,23 @@ function renderNotesSettingsTab(context) {
|
|
|
442
470
|
const project = context.board;
|
|
443
471
|
const notesPlaceholder =
|
|
444
472
|
"Remember to mention SKILL-ORBIT.md to my AI agents if they get confused";
|
|
473
|
+
const notes = project.project_notes || "";
|
|
474
|
+
const hasNotes = Boolean(notes.trim());
|
|
475
|
+
const placeholderClass = hasNotes ? "" : "is-placeholder";
|
|
476
|
+
const inner = hasNotes ? renderPreservedText(notes) : escapeHtml(notesPlaceholder);
|
|
445
477
|
return `
|
|
446
478
|
<div class="section ai-fields">
|
|
447
|
-
<
|
|
448
|
-
<
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
</
|
|
479
|
+
<div class="inline-md-field">
|
|
480
|
+
<span class="inline-md-field-label">Notes For You</span>
|
|
481
|
+
<div
|
|
482
|
+
class="inline-md-field-body preserved-text-body editable-field settings-notes-body ${placeholderClass}"
|
|
483
|
+
data-edit-field="project_notes"
|
|
484
|
+
tabindex="0"
|
|
485
|
+
title="Click to edit"
|
|
486
|
+
role="button"
|
|
487
|
+
aria-label="Edit Notes For You"
|
|
488
|
+
>${inner}</div>
|
|
489
|
+
</div>
|
|
458
490
|
</div>
|
|
459
491
|
`;
|
|
460
492
|
}
|
|
@@ -571,15 +603,31 @@ function bindProjectTabHandlers(context, tab) {
|
|
|
571
603
|
const project = context.board;
|
|
572
604
|
|
|
573
605
|
if (tab === "repository") {
|
|
574
|
-
const exportSnapshot = async () => {
|
|
575
|
-
const
|
|
576
|
-
|
|
606
|
+
const exportSnapshot = async (includeAttachments = Boolean($("#exportProjectImages")?.checked)) => {
|
|
607
|
+
const suffix = includeAttachments ? "?include_attachments=true" : "";
|
|
608
|
+
const snapshot = await api(`/api/boards/${encodeURIComponent(project.id)}/export${suffix}`);
|
|
609
|
+
const filename = includeAttachments ? `${project.slug}.with-images.orbit.json` : `${project.slug}.orbit.json`;
|
|
610
|
+
downloadJson(filename, snapshot);
|
|
577
611
|
};
|
|
578
612
|
|
|
579
613
|
$("#exportProject")?.addEventListener("click", async () => {
|
|
580
614
|
await exportSnapshot();
|
|
581
615
|
});
|
|
582
616
|
|
|
617
|
+
$("#boardRenameForm")?.addEventListener("submit", async (event) => {
|
|
618
|
+
event.preventDefault();
|
|
619
|
+
const form = new FormData(event.currentTarget);
|
|
620
|
+
const nextName = String(form.get("name") || "").trim();
|
|
621
|
+
if (!nextName || nextName === project.name) return;
|
|
622
|
+
await api(`/api/boards/${encodeURIComponent(project.id)}`, {
|
|
623
|
+
method: "PATCH",
|
|
624
|
+
body: { name: nextName }
|
|
625
|
+
});
|
|
626
|
+
await load();
|
|
627
|
+
state.detailMode = "settings";
|
|
628
|
+
toast.success(`Renamed board to ${nextName}. Canonical slug unchanged: ${project.slug}`);
|
|
629
|
+
});
|
|
630
|
+
|
|
583
631
|
$("#importSnapshotFile")?.addEventListener("change", async (event) => {
|
|
584
632
|
const file = event.currentTarget.files?.[0];
|
|
585
633
|
if (!file) return;
|
|
@@ -639,17 +687,39 @@ function bindProjectTabHandlers(context, tab) {
|
|
|
639
687
|
}
|
|
640
688
|
|
|
641
689
|
if (tab === "notes") {
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
const
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
690
|
+
const notesEl = drawerInner.querySelector('[data-edit-field="project_notes"]');
|
|
691
|
+
if (notesEl) {
|
|
692
|
+
const edit = () =>
|
|
693
|
+
startInlineEdit(notesEl, {
|
|
694
|
+
fieldName: "project_notes",
|
|
695
|
+
multiline: true,
|
|
696
|
+
initialValue: project.project_notes || "",
|
|
697
|
+
ariaLabel: "Edit Notes For You",
|
|
698
|
+
rerender: async () => {
|
|
699
|
+
state.detailMode = "settings";
|
|
700
|
+
await renderProjectDetail();
|
|
701
|
+
},
|
|
702
|
+
commit: async (next) => {
|
|
703
|
+
await api(`/api/boards/${project.id}`, {
|
|
704
|
+
method: "PATCH",
|
|
705
|
+
body: { project_notes: cleanText(next) }
|
|
706
|
+
});
|
|
707
|
+
await load();
|
|
708
|
+
state.detailMode = "settings";
|
|
709
|
+
toast.success("Saved");
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
notesEl.addEventListener("click", (event) => {
|
|
713
|
+
event.preventDefault();
|
|
714
|
+
edit();
|
|
648
715
|
});
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
716
|
+
notesEl.addEventListener("keydown", (event) => {
|
|
717
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
718
|
+
event.preventDefault();
|
|
719
|
+
edit();
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
}
|
|
653
723
|
}
|
|
654
724
|
|
|
655
725
|
if (tab === "lanes") {
|
package/public/js/state.js
CHANGED
|
@@ -77,3 +77,53 @@ export function selectableStatesForProject(currentStateId = "") {
|
|
|
77
77
|
export function ticketsForProject() {
|
|
78
78
|
return state.data.tickets.filter((item) => item.board_id === state.boardId);
|
|
79
79
|
}
|
|
80
|
+
|
|
81
|
+
function compactTicketForBoard(ticket) {
|
|
82
|
+
if (!ticket) return null;
|
|
83
|
+
return {
|
|
84
|
+
id: ticket.id,
|
|
85
|
+
board_id: ticket.board_id,
|
|
86
|
+
board_slug: ticket.board_slug,
|
|
87
|
+
number: ticket.number,
|
|
88
|
+
title: ticket.title,
|
|
89
|
+
type: ticket.type,
|
|
90
|
+
state_name: ticket.state_name,
|
|
91
|
+
state_role: ticket.state_role,
|
|
92
|
+
priority: ticket.priority
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function refreshBoardTicketDerivatives(boardId) {
|
|
97
|
+
if (!state.data?.tickets) return;
|
|
98
|
+
const boardTickets = state.data.tickets.filter((ticket) => ticket.board_id === boardId && !ticket.archived_at);
|
|
99
|
+
const ticketById = new Map(boardTickets.map((ticket) => [ticket.id, ticket]));
|
|
100
|
+
const childCountByParent = new Map();
|
|
101
|
+
for (const ticket of boardTickets) {
|
|
102
|
+
if (!ticket.parent_ticket_id) continue;
|
|
103
|
+
childCountByParent.set(ticket.parent_ticket_id, (childCountByParent.get(ticket.parent_ticket_id) || 0) + 1);
|
|
104
|
+
}
|
|
105
|
+
for (const ticket of boardTickets) {
|
|
106
|
+
ticket.parent_ticket = ticket.parent_ticket_id ? compactTicketForBoard(ticketById.get(ticket.parent_ticket_id)) : null;
|
|
107
|
+
ticket.child_count = childCountByParent.get(ticket.id) || 0;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Merge a ticket returned by a mutation/context endpoint into the bootstrap cache.
|
|
112
|
+
* Keeps drawer edits responsive without paying for a full /api/bootstrap reload. */
|
|
113
|
+
export function upsertTicket(updatedTicket) {
|
|
114
|
+
if (!state.data?.tickets || !updatedTicket?.id) return;
|
|
115
|
+
const index = state.data.tickets.findIndex((ticket) => ticket.id === updatedTicket.id);
|
|
116
|
+
const previous = index >= 0 ? state.data.tickets[index] : {};
|
|
117
|
+
const merged = {
|
|
118
|
+
...previous,
|
|
119
|
+
...updatedTicket,
|
|
120
|
+
labels: updatedTicket.labels || previous.labels || [],
|
|
121
|
+
comment_count: updatedTicket.comment_count ?? previous.comment_count ?? 0,
|
|
122
|
+
child_count: updatedTicket.child_count ?? previous.child_count ?? 0,
|
|
123
|
+
parent_ticket: updatedTicket.parent_ticket || previous.parent_ticket || null
|
|
124
|
+
};
|
|
125
|
+
if (index >= 0) state.data.tickets.splice(index, 1, merged);
|
|
126
|
+
else state.data.tickets.push(merged);
|
|
127
|
+
state.data.tickets.sort((a, b) => String(b.updated_at || "").localeCompare(String(a.updated_at || "")) || String(a.id).localeCompare(String(b.id)));
|
|
128
|
+
refreshBoardTicketDerivatives(merged.board_id || state.boardId);
|
|
129
|
+
}
|