@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.
Files changed (48) hide show
  1. package/README.md +22 -7
  2. package/SKILL-ORBIT.md +26 -1
  3. package/dist/full/index.html +2 -2
  4. package/dist/full/js/app.a4f045fc.js +676 -0
  5. package/dist/full/js/app.a4f045fc.js.map +7 -0
  6. package/dist/full/{styles.14e9dbbd.css → styles.836872e7.css} +836 -39
  7. package/dist/full/version.json +2 -2
  8. package/docs/AGENT_PROTOCOL.md +23 -5
  9. package/docs/MCP_SETUP.md +5 -3
  10. package/docs/ORBIT_DISPATCH.md +185 -0
  11. package/docs/setup/orbit-init-wizard.md +174 -0
  12. package/package.json +2 -2
  13. package/public/js/actor-labels.js +65 -0
  14. package/public/js/app.js +8 -1
  15. package/public/js/card-actions.js +526 -0
  16. package/public/js/config.js +2 -1
  17. package/public/js/format.js +204 -10
  18. package/public/js/kanban.js +15 -0
  19. package/public/js/local-backend.js +24 -8
  20. package/public/js/router.js +3 -2
  21. package/public/js/search.js +6 -3
  22. package/public/js/settings.js +96 -26
  23. package/public/js/state.js +50 -0
  24. package/public/js/ticket-detail.js +428 -125
  25. package/public/styles.css +836 -39
  26. package/src/cli/orbit.js +217 -32
  27. package/src/core/agent.js +114 -24
  28. package/src/core/agents-md.js +15 -10
  29. package/src/core/attachments.js +238 -0
  30. package/src/core/auth.js +36 -0
  31. package/src/core/backups.js +9 -3
  32. package/src/core/board.js +32 -1
  33. package/src/core/boards.js +1 -1
  34. package/src/core/db.js +42 -2
  35. package/src/core/delete-board.js +87 -21
  36. package/src/core/dispatch.js +532 -0
  37. package/src/core/http.js +31 -11
  38. package/src/core/migrate.js +198 -0
  39. package/src/core/paths.js +6 -0
  40. package/src/core/registry.js +12 -9
  41. package/src/core/router.js +81 -7
  42. package/src/core/search.js +83 -10
  43. package/src/core/snapshots.js +14 -2
  44. package/src/mcp/orbit-client.js +109 -46
  45. package/src/mcp-server.js +238 -12
  46. package/src/server.js +20 -0
  47. package/dist/full/js/app.f4fd5e94.js +0 -604
  48. package/dist/full/js/app.f4fd5e94.js.map +0 -7
@@ -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
- const source = String(value ?? "").replace(/\r\n?/g, "\n");
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
- blocks.push(`<${list.type}>${list.items.map((item) => `<li>${item}</li>`).join("")}</${list.type}>`);
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
- flushParagraph();
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
- if (!list || list.type !== type) {
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.trim());
332
+ paragraph.push(line);
147
333
  }
148
334
 
149
- flushParagraph();
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";
@@ -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) if (k in body) board[k] = body[k];
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
- related_comments: []
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) },
@@ -81,7 +81,7 @@ export function syncUrlFromState({ replace = false } = {}) {
81
81
  }
82
82
 
83
83
  export async function applyRoute() {
84
- const { load, render } = await import("./app.js");
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 render();
129
+ await renderDetailOnly();
129
130
  }
130
131
 
131
132
  window.addEventListener("popstate", () => {
@@ -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
- <strong>${escapeHtml(ticketLabel(ticket))}</strong>
32
- ${escapeHtml(ticket.title)}
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
  )
@@ -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
- <form id="notesSettingsForm" class="field-form settings-notes-form">
448
- <label class="settings-notes-field-group">
449
- <span>Notes For You</span>
450
- <textarea
451
- name="project_notes"
452
- class="settings-notes-textarea"
453
- placeholder="${escapeHtml(notesPlaceholder)}"
454
- >${escapeHtml(project.project_notes || "")}</textarea>
455
- </label>
456
- <button type="submit" class="settings-notes-save">Save</button>
457
- </form>
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 snapshot = await api(`/api/boards/${project.id}/export`);
576
- downloadJson(`${project.slug}.orbit.json`, snapshot);
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
- $("#notesSettingsForm")?.addEventListener("submit", async (event) => {
643
- event.preventDefault();
644
- const form = new FormData(event.currentTarget);
645
- await api(`/api/boards/${project.id}`, {
646
- method: "PATCH",
647
- body: { project_notes: String(form.get("project_notes") ?? "") }
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
- await load();
650
- state.detailMode = "settings";
651
- toast.success("Saved");
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") {
@@ -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
+ }