@starlove/orbit 1.1.2 → 1.1.11
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/.dockerignore +0 -0
- package/Dockerfile +0 -0
- package/LICENSE +0 -0
- package/README.md +0 -0
- package/SKILL-ORBIT.md +2 -0
- package/build.mjs +0 -0
- package/dist/full/apple-touch-icon.png +0 -0
- package/dist/full/cursors/live_arrow.cur +0 -0
- package/dist/full/cursors/live_link.cur +0 -0
- package/dist/full/cursors/live_move.cur +0 -0
- package/dist/full/cursors/live_select.cur +0 -0
- package/dist/full/cursors/live_unavail.cur +0 -0
- package/dist/full/favicon-32x32.png +0 -0
- package/dist/full/index.html +160 -15
- package/dist/full/js/app.55b38e12.js +698 -0
- package/dist/full/js/app.55b38e12.js.map +7 -0
- package/dist/full/orbits-favicon.png +0 -0
- package/dist/full/orbits-logo-bg.png +0 -0
- package/dist/full/orbits-logo.png +0 -0
- package/dist/full/{styles.836872e7.css → styles.273fc37a.css} +79 -39
- package/dist/full/version.json +2 -2
- package/docs/AGENT_PROTOCOL.md +0 -0
- package/docs/MCP_SETUP.md +0 -0
- package/docs/ORBIT_DISPATCH.md +68 -8
- package/docs/SS-Orbit-Dark.png +0 -0
- package/docs/SS-Orbit-Light.png +0 -0
- package/docs/mcp-client.example.json +0 -0
- package/docs/setup/orbit-init-wizard.md +0 -0
- package/package.json +1 -1
- package/public/apple-touch-icon.png +0 -0
- package/public/cursors/live_arrow.cur +0 -0
- package/public/cursors/live_link.cur +0 -0
- package/public/cursors/live_move.cur +0 -0
- package/public/cursors/live_select.cur +0 -0
- package/public/cursors/live_unavail.cur +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/index.html +158 -13
- package/public/js/actor-labels.js +0 -0
- package/public/js/api.js +0 -0
- package/public/js/app.js +0 -0
- package/public/js/board-menu.js +0 -0
- package/public/js/card-actions.js +19 -0
- package/public/js/config.js +0 -0
- package/public/js/create-card.js +0 -0
- package/public/js/dom.js +0 -0
- package/public/js/drawer.js +17 -43
- package/public/js/format.js +5 -5
- package/public/js/kanban.js +157 -92
- package/public/js/local-backend.js +0 -0
- package/public/js/main.js +2 -1
- package/public/js/modal.js +0 -0
- package/public/js/motion-preference.js +14 -3
- package/public/js/router.js +0 -0
- package/public/js/search.js +0 -0
- package/public/js/settings-tabs.js +0 -0
- package/public/js/settings.js +131 -27
- package/public/js/sse.js +0 -0
- package/public/js/starscape.js +0 -0
- package/public/js/state.js +0 -0
- package/public/js/theme-preference.js +0 -0
- package/public/js/ticket-detail.js +88 -20
- package/public/js/toast.js +0 -0
- package/public/js/transport.js +0 -0
- package/public/js/unread.js +0 -0
- package/public/js/url-routes.js +0 -0
- package/public/orbits-favicon.png +0 -0
- package/public/orbits-logo-bg.png +0 -0
- package/public/orbits-logo.png +0 -0
- package/public/styles.css +79 -39
- package/src/cli/orbit.js +22 -0
- package/src/core/agent.js +0 -0
- package/src/core/agents-md.js +0 -0
- package/src/core/attachments.js +0 -0
- package/src/core/auth.js +0 -0
- package/src/core/backups.js +0 -0
- package/src/core/board.js +1 -0
- package/src/core/boards.js +0 -0
- package/src/core/bootstrap.js +0 -0
- package/src/core/db.js +19 -0
- package/src/core/delete-board.js +0 -0
- package/src/core/dispatch.js +176 -11
- package/src/core/events.js +0 -0
- package/src/core/http.js +0 -0
- package/src/core/migrate.js +0 -0
- package/src/core/paths.js +0 -0
- package/src/core/provision-repo-board.js +0 -0
- package/src/core/queries.js +0 -0
- package/src/core/registry.js +2 -2
- package/src/core/relations.js +0 -0
- package/src/core/review-verdicts.js +138 -0
- package/src/core/router.js +32 -3
- package/src/core/search.js +0 -0
- package/src/core/seed.js +0 -0
- package/src/core/snapshots.js +429 -9
- package/src/core/states.js +0 -0
- package/src/core/system-picker.js +0 -0
- package/src/core/tickets.js +0 -0
- package/src/core/util.js +0 -0
- package/src/mcp/orbit-client.js +45 -19
- package/src/mcp-server.js +102 -27
- package/src/server.js +0 -0
- package/dist/full/js/app.a4f045fc.js +0 -676
- package/dist/full/js/app.a4f045fc.js.map +0 -7
package/public/js/drawer.js
CHANGED
|
@@ -36,19 +36,9 @@ export function closeDrawer() {
|
|
|
36
36
|
drawer.hidden = true;
|
|
37
37
|
drawerBackdrop.hidden = true;
|
|
38
38
|
drawerInner.innerHTML = "";
|
|
39
|
-
const
|
|
40
|
-
const titleEl = document.getElementById("drawerTitle");
|
|
41
|
-
const subtitleEl = document.getElementById("drawerSubtitle");
|
|
39
|
+
const titleBlockEl = document.getElementById("drawerTitleBlock");
|
|
42
40
|
const tabsEl = document.getElementById("drawerTabs");
|
|
43
|
-
if (
|
|
44
|
-
if (titleEl) {
|
|
45
|
-
for (const attr of Array.from(titleEl.attributes)) {
|
|
46
|
-
if (attr.name !== "id") titleEl.removeAttribute(attr.name);
|
|
47
|
-
}
|
|
48
|
-
titleEl.className = "drawer-title-text";
|
|
49
|
-
titleEl.textContent = "";
|
|
50
|
-
}
|
|
51
|
-
if (subtitleEl) { subtitleEl.innerHTML = ""; subtitleEl.hidden = true; }
|
|
41
|
+
if (titleBlockEl) titleBlockEl.innerHTML = "";
|
|
52
42
|
if (tabsEl) { tabsEl.innerHTML = ""; tabsEl.hidden = true; }
|
|
53
43
|
drawer.classList.remove("is-wide");
|
|
54
44
|
};
|
|
@@ -77,40 +67,24 @@ export function closeDrawer() {
|
|
|
77
67
|
export function renderDrawerShell({ eyebrow, title, titleAttrs, subtitleHtml, tabs, activeTab, onTabSelect, body, mode }) {
|
|
78
68
|
drawer.classList.toggle("is-settings", mode === "settings");
|
|
79
69
|
drawer.classList.toggle("is-wide", mode === "ticket" || mode === "settings");
|
|
80
|
-
const
|
|
81
|
-
const titleEl = document.getElementById("drawerTitle");
|
|
82
|
-
const subtitleEl = document.getElementById("drawerSubtitle");
|
|
70
|
+
const titleBlockEl = document.getElementById("drawerTitleBlock");
|
|
83
71
|
const tabsEl = document.getElementById("drawerTabs");
|
|
84
72
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
subtitleEl.innerHTML = subtitleHtml;
|
|
95
|
-
subtitleEl.hidden = false;
|
|
96
|
-
} else {
|
|
97
|
-
subtitleEl.innerHTML = "";
|
|
98
|
-
subtitleEl.hidden = true;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Reset prior edit-attrs / classes each render so different modes don't
|
|
102
|
-
// carry state from one another.
|
|
103
|
-
for (const attr of Array.from(titleEl.attributes)) {
|
|
104
|
-
if (attr.name !== "id") titleEl.removeAttribute(attr.name);
|
|
105
|
-
}
|
|
106
|
-
titleEl.className = "drawer-title-text";
|
|
107
|
-
if (titleAttrs) {
|
|
108
|
-
for (const [key, value] of Object.entries(titleAttrs)) {
|
|
109
|
-
if (key === "class") titleEl.className = `drawer-title-text ${value}`;
|
|
110
|
-
else titleEl.setAttribute(key, value);
|
|
111
|
-
}
|
|
73
|
+
// Rebuild the eyebrow / title / subtitle wholesale each render. Like
|
|
74
|
+
// drawerInner.innerHTML below, this means any listeners the caller attached
|
|
75
|
+
// last render (e.g. the inline title editor's click handler) die with the
|
|
76
|
+
// old nodes, instead of stacking on persistent shell elements.
|
|
77
|
+
const extraTitleClass = titleAttrs?.class ? ` ${titleAttrs.class}` : "";
|
|
78
|
+
let titleAttrHtml = ` class="drawer-title-text${extraTitleClass}"`;
|
|
79
|
+
for (const [key, value] of Object.entries(titleAttrs || {})) {
|
|
80
|
+
if (key === "class") continue;
|
|
81
|
+
titleAttrHtml += ` ${key}="${escapeHtml(String(value))}"`;
|
|
112
82
|
}
|
|
113
|
-
|
|
83
|
+
const segments = [];
|
|
84
|
+
if (eyebrow) segments.push(`<span class="drawer-eyebrow">${escapeHtml(eyebrow)}</span>`);
|
|
85
|
+
segments.push(`<h2${titleAttrHtml}>${escapeHtml(title ?? "")}</h2>`);
|
|
86
|
+
if (subtitleHtml) segments.push(`<div class="drawer-subtitle">${subtitleHtml}</div>`);
|
|
87
|
+
titleBlockEl.innerHTML = segments.join("");
|
|
114
88
|
|
|
115
89
|
if (tabs && tabs.length) {
|
|
116
90
|
tabsEl.hidden = false;
|
package/public/js/format.js
CHANGED
|
@@ -11,9 +11,9 @@ export function escapeHtml(value) {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
// UTF-8 bytes mis-decoded as CP-437 (the typical Windows-tmux artifact).
|
|
14
|
-
// Ported from
|
|
15
|
-
//
|
|
16
|
-
//
|
|
14
|
+
// Ported from a local fix-newlines utility — every entry is [mojibake sequence,
|
|
15
|
+
// what it should have been]. Long sequences first so shorter prefixes do not
|
|
16
|
+
// eat them.
|
|
17
17
|
const MOJIBAKE_REPLACEMENTS = [
|
|
18
18
|
// General Punctuation (UTF-8 E2 80 XX → CP-437 "ΓÇ" + glyph)
|
|
19
19
|
["ΓÇô", "–"],
|
|
@@ -84,8 +84,8 @@ const MOJIBAKE_REPLACEMENTS = [
|
|
|
84
84
|
["┬º", "§"],
|
|
85
85
|
["┬╖", "·"],
|
|
86
86
|
|
|
87
|
-
// Stray curly quotes that survived as proper Unicode
|
|
88
|
-
//
|
|
87
|
+
// Stray curly quotes that survived as proper Unicode; normalize to ASCII
|
|
88
|
+
// straight quotes so pasted terminal/log text stays consistent.
|
|
89
89
|
["“", '"'],
|
|
90
90
|
["”", '"'],
|
|
91
91
|
["‘", "'"],
|
package/public/js/kanban.js
CHANGED
|
@@ -19,106 +19,162 @@ 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 {
|
|
22
|
+
import { bindCardContextMenuDelegated, closeCardActionMenu, createCardActionHandlers, setCardActionDragInFlight } from "./card-actions.js";
|
|
23
23
|
import { unreadCount } from "./unread.js";
|
|
24
24
|
|
|
25
|
+
// Lookup for the current board's tickets, keyed by id. Refreshed on every
|
|
26
|
+
// renderBoard() so the single, persistent set of delegated listeners can
|
|
27
|
+
// resolve a card's ticket in O(1) without re-binding anything per render.
|
|
28
|
+
let boardTicketsById = new Map();
|
|
29
|
+
// Created once (lazily) and reused — handlers are stateless w.r.t. a given card.
|
|
30
|
+
let cardActionHandlers = null;
|
|
31
|
+
// HTML5 DnD ends with a synthetic click on the drag source; this flag lets the
|
|
32
|
+
// delegated click handler swallow that one click so a drop doesn't also open
|
|
33
|
+
// the ticket drawer. Module-scoped because there's now one shared listener.
|
|
34
|
+
let suppressClickAfterDrag = false;
|
|
35
|
+
|
|
36
|
+
// renderBoard() is hot: it runs on every drag-drop, every SSE board event, and
|
|
37
|
+
// every settings toggle. Keep it to a single innerHTML write plus a Map refresh.
|
|
38
|
+
// All interaction wiring lives in enableKanbanInteractions(), attached ONCE to
|
|
39
|
+
// the persistent `kanban` element (see below), so re-rendering never re-binds
|
|
40
|
+
// per-card/column/button listeners or rescans the ticket list.
|
|
25
41
|
export function renderBoard() {
|
|
26
42
|
const states = visibleStatesForProject();
|
|
27
43
|
const tickets = ticketsForProject();
|
|
44
|
+
boardTicketsById = new Map(tickets.map((ticket) => [ticket.id, ticket]));
|
|
28
45
|
kanban.innerHTML = states.map((column) => renderColumn(column, tickets)).join("");
|
|
46
|
+
}
|
|
29
47
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
function toggleCardExpansion(card) {
|
|
49
|
+
if (!card) return;
|
|
50
|
+
const id = card.dataset.ticketId;
|
|
51
|
+
const willExpand = !state.expandedCardIds.has(id);
|
|
52
|
+
if (willExpand) {
|
|
53
|
+
state.expandedCardIds.add(id);
|
|
54
|
+
// Build the expandable body (markdown parse + labels + relations) the first
|
|
55
|
+
// time a card opens. Collapsed cards — the overwhelming majority on render —
|
|
56
|
+
// never pay this cost. Once built it stays cached in the DOM for re-toggles.
|
|
57
|
+
if (!card.querySelector(".card-expandable")) {
|
|
58
|
+
const ticket = boardTicketsById.get(id);
|
|
59
|
+
const meta = card.querySelector(".card-meta");
|
|
60
|
+
if (ticket && meta) meta.insertAdjacentHTML("afterend", renderCardExpandable(ticket));
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
state.expandedCardIds.delete(id);
|
|
64
|
+
}
|
|
65
|
+
card.classList.toggle("is-expanded", willExpand);
|
|
66
|
+
const toggleBtn = card.querySelector("button.card-expand-trigger");
|
|
67
|
+
if (toggleBtn) toggleBtn.setAttribute("aria-expanded", willExpand ? "true" : "false");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// One-time setup: attach delegated listeners to the persistent board container.
|
|
71
|
+
// Because events bubble to `kanban`, these survive every innerHTML swap, so
|
|
72
|
+
// renderBoard() does zero listener work regardless of card count.
|
|
73
|
+
export function enableKanbanInteractions() {
|
|
74
|
+
cardActionHandlers = cardActionHandlers || createCardActionHandlers({ navigator: navigate });
|
|
75
|
+
|
|
76
|
+
// --- Drag & drop (HTML5 DnD events bubble, so one set covers all cards) ---
|
|
77
|
+
let dragOverColumn = null;
|
|
78
|
+
const setDragOverColumn = (column) => {
|
|
79
|
+
if (dragOverColumn === column) return;
|
|
80
|
+
if (dragOverColumn) dragOverColumn.classList.remove("drag-over");
|
|
81
|
+
dragOverColumn = column;
|
|
82
|
+
if (dragOverColumn) dragOverColumn.classList.add("drag-over");
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
kanban.addEventListener("dragstart", (event) => {
|
|
86
|
+
const card = event.target instanceof Element ? event.target.closest(".card") : null;
|
|
87
|
+
if (!card) return;
|
|
88
|
+
closeCardActionMenu();
|
|
89
|
+
setCardActionDragInFlight(true);
|
|
90
|
+
event.dataTransfer.setData("text/plain", card.dataset.ticketId);
|
|
91
|
+
event.dataTransfer.effectAllowed = "move";
|
|
50
92
|
});
|
|
51
93
|
|
|
52
|
-
kanban.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
94
|
+
kanban.addEventListener("dragover", (event) => {
|
|
95
|
+
const column = event.target instanceof Element ? event.target.closest(".column") : null;
|
|
96
|
+
if (!column) return;
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
setDragOverColumn(column);
|
|
57
99
|
});
|
|
58
100
|
|
|
59
|
-
kanban.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
101
|
+
kanban.addEventListener("dragend", () => {
|
|
102
|
+
setCardActionDragInFlight(false);
|
|
103
|
+
setDragOverColumn(null);
|
|
104
|
+
suppressClickAfterDrag = true;
|
|
105
|
+
// If no synthetic click follows (e.g. drag cancelled oddly), don't eat the
|
|
106
|
+
// next real click.
|
|
107
|
+
setTimeout(() => {
|
|
108
|
+
suppressClickAfterDrag = false;
|
|
109
|
+
}, 0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
kanban.addEventListener("drop", async (event) => {
|
|
113
|
+
const column = event.target instanceof Element ? event.target.closest(".column") : null;
|
|
114
|
+
if (!column) return;
|
|
115
|
+
event.preventDefault();
|
|
116
|
+
setDragOverColumn(null);
|
|
117
|
+
const ticketId = event.dataTransfer.getData("text/plain");
|
|
118
|
+
if (!ticketId) return;
|
|
119
|
+
closeCreateFlyout();
|
|
120
|
+
await api(withBoardQuery(`/api/tickets/${ticketId}`), {
|
|
121
|
+
method: "PATCH",
|
|
122
|
+
body: { state_id: column.dataset.stateId }
|
|
67
123
|
});
|
|
124
|
+
// Refresh board only; do not open the ticket drawer (DnD also fires a
|
|
125
|
+
// synthetic click on the card — suppressed by suppressClickAfterDrag).
|
|
126
|
+
await load();
|
|
68
127
|
});
|
|
69
128
|
|
|
70
|
-
|
|
71
|
-
kanban.
|
|
72
|
-
const
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
129
|
+
// --- Clicks (precedence: add-button > expand toggle > link > epic header > card) ---
|
|
130
|
+
kanban.addEventListener("click", (event) => {
|
|
131
|
+
const target = event.target instanceof Element ? event.target : event.target?.parentElement;
|
|
132
|
+
if (!target) return;
|
|
133
|
+
|
|
134
|
+
const addBtn = target.closest("[data-add-to-state]");
|
|
135
|
+
if (addBtn) {
|
|
136
|
+
event.stopPropagation();
|
|
137
|
+
openCreateFlyout(addBtn.dataset.addToState || "");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const toggle = target.closest("button.card-expand-trigger");
|
|
142
|
+
if (toggle) {
|
|
143
|
+
event.stopPropagation();
|
|
144
|
+
event.preventDefault();
|
|
145
|
+
toggleCardExpansion(toggle.closest(".card"));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Anchors inside a card keep their native behavior.
|
|
150
|
+
if (target.closest("a")) {
|
|
151
|
+
event.stopPropagation();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const epicHeader = target.closest(".epic-mini-header[data-ticket-id]");
|
|
156
|
+
if (epicHeader) {
|
|
89
157
|
closeCreateFlyout();
|
|
90
|
-
navigate({
|
|
91
|
-
|
|
92
|
-
view: "ticket",
|
|
93
|
-
ticketId: card.dataset.ticketId
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
const toggleBtn = card.querySelector("button.card-expand-trigger");
|
|
97
|
-
if (toggleBtn) {
|
|
98
|
-
toggleBtn.addEventListener("click", (event) => {
|
|
99
|
-
event.stopPropagation();
|
|
100
|
-
event.preventDefault();
|
|
101
|
-
const id = card.dataset.ticketId;
|
|
102
|
-
if (state.expandedCardIds.has(id)) state.expandedCardIds.delete(id);
|
|
103
|
-
else state.expandedCardIds.add(id);
|
|
104
|
-
const expanded = card.classList.toggle("is-expanded");
|
|
105
|
-
toggleBtn.setAttribute("aria-expanded", expanded ? "true" : "false");
|
|
106
|
-
});
|
|
158
|
+
navigate({ boardId: state.boardId, view: "ticket", ticketId: epicHeader.dataset.ticketId });
|
|
159
|
+
return;
|
|
107
160
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
161
|
+
|
|
162
|
+
const card = target.closest(".card");
|
|
163
|
+
if (!card) return;
|
|
164
|
+
if (suppressClickAfterDrag) {
|
|
165
|
+
suppressClickAfterDrag = false;
|
|
166
|
+
event.preventDefault();
|
|
167
|
+
event.stopPropagation();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
closeCreateFlyout();
|
|
171
|
+
navigate({ boardId: state.boardId, view: "ticket", ticketId: card.dataset.ticketId });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// --- Context menu ---
|
|
175
|
+
bindCardContextMenuDelegated(kanban, (id) => boardTicketsById.get(id), {
|
|
176
|
+
handlers: cardActionHandlers,
|
|
177
|
+
onBeforeOpen: closeCreateFlyout
|
|
122
178
|
});
|
|
123
179
|
}
|
|
124
180
|
|
|
@@ -388,18 +444,27 @@ function renderCard(ticket, { asEpicChild = false } = {}) {
|
|
|
388
444
|
: `<div class="card-expand-trigger card-expand-trigger--static">${triggerInner}</div>`}
|
|
389
445
|
</div>
|
|
390
446
|
|
|
391
|
-
${hasExpandable ?
|
|
392
|
-
<div class="card-expandable">
|
|
393
|
-
${hasDescription ? `<div class="card-description markdown-body">${renderMarkdown(ticket.description)}</div>` : ""}
|
|
394
|
-
${renderLabels(ticket.labels)}
|
|
395
|
-
${ticket.parent_ticket ? `<div class="parent-line">-> ${escapeHtml(ticketLabel(ticket.parent_ticket))}</div>` : ""}
|
|
396
|
-
${ticket.child_count ? `<div class="child-count">${ticket.child_count} feature${ticket.child_count === 1 ? "" : "s"} inside</div>` : ""}
|
|
397
|
-
</div>
|
|
398
|
-
` : ""}
|
|
447
|
+
${hasExpandable && isExpanded ? renderCardExpandable(ticket) : ""}
|
|
399
448
|
</article>
|
|
400
449
|
`;
|
|
401
450
|
}
|
|
402
451
|
|
|
452
|
+
// The expandable body holds the only genuinely expensive per-card work: a full
|
|
453
|
+
// markdown parse of the description. It's rendered eagerly only for cards that
|
|
454
|
+
// are already expanded; collapsed cards get it injected on first open (see
|
|
455
|
+
// toggleCardExpansion). Kept as its own function so both paths share one source.
|
|
456
|
+
function renderCardExpandable(ticket) {
|
|
457
|
+
const hasDescription = Boolean(String(ticket.description || "").trim());
|
|
458
|
+
return `
|
|
459
|
+
<div class="card-expandable">
|
|
460
|
+
${hasDescription ? `<div class="card-description markdown-body">${renderMarkdown(ticket.description)}</div>` : ""}
|
|
461
|
+
${renderLabels(ticket.labels)}
|
|
462
|
+
${ticket.parent_ticket ? `<div class="parent-line">-> ${escapeHtml(ticketLabel(ticket.parent_ticket))}</div>` : ""}
|
|
463
|
+
${ticket.child_count ? `<div class="child-count">${ticket.child_count} feature${ticket.child_count === 1 ? "" : "s"} inside</div>` : ""}
|
|
464
|
+
</div>
|
|
465
|
+
`;
|
|
466
|
+
}
|
|
467
|
+
|
|
403
468
|
/**
|
|
404
469
|
* Click-and-drag horizontal panning on the kanban container.
|
|
405
470
|
* Skipped when the pointer starts on a .card (cards own their own click + HTML5 DnD),
|
|
File without changes
|
package/public/js/main.js
CHANGED
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
closeCreateFlyout,
|
|
37
37
|
isCreateFlyoutOpen
|
|
38
38
|
} from "./create-card.js";
|
|
39
|
-
import { enableKanbanDragScroll } from "./kanban.js";
|
|
39
|
+
import { enableKanbanDragScroll, enableKanbanInteractions } from "./kanban.js";
|
|
40
40
|
import { runSearch, hideSearch } from "./search.js";
|
|
41
41
|
import { load } from "./app.js";
|
|
42
42
|
import { edition } from "./config.js";
|
|
@@ -131,6 +131,7 @@ async function init() {
|
|
|
131
131
|
});
|
|
132
132
|
|
|
133
133
|
enableKanbanDragScroll();
|
|
134
|
+
enableKanbanInteractions();
|
|
134
135
|
|
|
135
136
|
const initialRoute = currentRoute();
|
|
136
137
|
if (initialRoute.boardSlug) state.boardSlug = initialRoute.boardSlug;
|
package/public/js/modal.js
CHANGED
|
File without changes
|
|
@@ -47,7 +47,18 @@ export function applyReducedMotionPreference({
|
|
|
47
47
|
storage = safeStorage(),
|
|
48
48
|
mediaMatcher = defaultMediaMatcher
|
|
49
49
|
} = {}) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
// Starscape v3 `data-motion` contract (three-state; shared with the
|
|
51
|
+
// ui-system-v3 package and ss-nebula). Replaces the legacy two-state
|
|
52
|
+
// `data-reduced-motion="reduce|allow"`:
|
|
53
|
+
// • stored reduce → data-motion="reduce" (always reduce)
|
|
54
|
+
// • stored allow → data-motion="full" (ignore OS reduce)
|
|
55
|
+
// • no stored pref → no attribute (follow OS prefers-reduced-motion)
|
|
56
|
+
const root = doc?.documentElement;
|
|
57
|
+
const stored = storedReducedMotionPreference(storage);
|
|
58
|
+
if (root) {
|
|
59
|
+
if (stored === true) root.setAttribute("data-motion", "reduce");
|
|
60
|
+
else if (stored === false) root.setAttribute("data-motion", "full");
|
|
61
|
+
else root.removeAttribute("data-motion");
|
|
62
|
+
}
|
|
63
|
+
return effectiveReducedMotionPreference({ storage, mediaMatcher });
|
|
53
64
|
}
|
package/public/js/router.js
CHANGED
|
File without changes
|
package/public/js/search.js
CHANGED
|
File without changes
|
|
File without changes
|
package/public/js/settings.js
CHANGED
|
@@ -165,8 +165,12 @@ function renderProjectAiTab(context) {
|
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
function renderAgentContextSection(project) {
|
|
168
|
-
const
|
|
168
|
+
const instructionsPlaceholder =
|
|
169
169
|
"This project is about…\n\nHow any AI agent should use this board and work in this repo.";
|
|
170
|
+
const instructions = project.agent_instructions || "";
|
|
171
|
+
const hasInstructions = Boolean(instructions.trim());
|
|
172
|
+
const placeholderClass = hasInstructions ? "" : "is-placeholder";
|
|
173
|
+
const inner = hasInstructions ? renderPreservedText(instructions) : escapeHtml(instructionsPlaceholder);
|
|
170
174
|
return `
|
|
171
175
|
<div class="section ai-subsection ai-context-setup">
|
|
172
176
|
<div class="ai-context-title-row">
|
|
@@ -174,10 +178,16 @@ function renderAgentContextSection(project) {
|
|
|
174
178
|
<h3>Agent Instructions</h3>
|
|
175
179
|
</div>
|
|
176
180
|
<p class="description">This project-level context is provided to agents when they work on tickets.</p>
|
|
177
|
-
<
|
|
178
|
-
<
|
|
179
|
-
|
|
180
|
-
|
|
181
|
+
<div class="inline-md-field">
|
|
182
|
+
<div
|
|
183
|
+
class="inline-md-field-body preserved-text-body editable-field settings-agent-instructions-body ${placeholderClass}"
|
|
184
|
+
data-edit-field="agent_instructions"
|
|
185
|
+
tabindex="0"
|
|
186
|
+
title="Click to edit"
|
|
187
|
+
role="button"
|
|
188
|
+
aria-label="Edit Agent Instructions"
|
|
189
|
+
>${inner}</div>
|
|
190
|
+
</div>
|
|
181
191
|
</div>
|
|
182
192
|
`;
|
|
183
193
|
}
|
|
@@ -381,6 +391,7 @@ function renderProjectRepositoryTab(context) {
|
|
|
381
391
|
|
|
382
392
|
<div class="section">
|
|
383
393
|
<h3>Board Snapshot</h3>
|
|
394
|
+
<p class="description">Import accepts Orbit snapshots or Trello board JSON exports. The default import creates a separate board and leaves this board untouched.</p>
|
|
384
395
|
${features.attachments ? `
|
|
385
396
|
<label class="orbit-check">
|
|
386
397
|
<input type="checkbox" id="exportProjectImages" />
|
|
@@ -394,8 +405,23 @@ function renderProjectRepositoryTab(context) {
|
|
|
394
405
|
<div class="deployment-actions">
|
|
395
406
|
<button type="button" class="ghost" id="exportProject">Export</button>
|
|
396
407
|
<label class="import-button">
|
|
397
|
-
<input type="file" id="
|
|
398
|
-
<span>Import</span>
|
|
408
|
+
<input type="file" id="importSnapshotAsNewFile" accept=".orbit.json,.json,application/json" />
|
|
409
|
+
<span>Import as New Board</span>
|
|
410
|
+
</label>
|
|
411
|
+
</div>
|
|
412
|
+
</div>
|
|
413
|
+
|
|
414
|
+
<div class="section repository-danger-zone">
|
|
415
|
+
<h3>Replace Current Board</h3>
|
|
416
|
+
<p class="description">Advanced: replace this board from an Orbit snapshot or Trello export after making the normal pre-import backup. Type <strong>${escapeHtml(project.slug || "")}</strong> to enable replacement.</p>
|
|
417
|
+
<label class="repository-confirm-field">
|
|
418
|
+
<span>Board slug</span>
|
|
419
|
+
<input id="replaceImportConfirmInput" autocomplete="off" spellcheck="false" placeholder="${escapeHtml(project.slug || "")}" />
|
|
420
|
+
</label>
|
|
421
|
+
<div class="deployment-actions">
|
|
422
|
+
<label class="import-button danger-button">
|
|
423
|
+
<input type="file" id="replaceCurrentBoardImportFile" accept=".orbit.json,.json,application/json" disabled />
|
|
424
|
+
<span>Replace Current Board</span>
|
|
399
425
|
</label>
|
|
400
426
|
</div>
|
|
401
427
|
</div>
|
|
@@ -599,6 +625,18 @@ function renderProjectJournalTab(context) {
|
|
|
599
625
|
`;
|
|
600
626
|
}
|
|
601
627
|
|
|
628
|
+
function parseImportSnapshotText(text) {
|
|
629
|
+
const trimmed = String(text || "").trimStart();
|
|
630
|
+
if (trimmed.startsWith("<")) {
|
|
631
|
+
throw new Error("That file looks like an HTML page instead of JSON. In Trello, use Export JSON/download the board JSON, not Save Page As.");
|
|
632
|
+
}
|
|
633
|
+
try {
|
|
634
|
+
return JSON.parse(text);
|
|
635
|
+
} catch (error) {
|
|
636
|
+
throw new Error(error?.message || "invalid JSON");
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
602
640
|
function bindProjectTabHandlers(context, tab) {
|
|
603
641
|
const project = context.board;
|
|
604
642
|
|
|
@@ -628,19 +666,63 @@ function bindProjectTabHandlers(context, tab) {
|
|
|
628
666
|
toast.success(`Renamed board to ${nextName}. Canonical slug unchanged: ${project.slug}`);
|
|
629
667
|
});
|
|
630
668
|
|
|
631
|
-
|
|
632
|
-
const file = event.currentTarget.files?.[0];
|
|
633
|
-
if (!file) return;
|
|
669
|
+
const importSnapshotFile = async (file, bodyForSnapshot) => {
|
|
634
670
|
const text = await file.text();
|
|
635
|
-
const snapshot =
|
|
636
|
-
|
|
671
|
+
const snapshot = parseImportSnapshotText(text);
|
|
672
|
+
return api("/api/admin/import", {
|
|
637
673
|
method: "POST",
|
|
638
|
-
body:
|
|
674
|
+
body: bodyForSnapshot(snapshot)
|
|
639
675
|
});
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
event.currentTarget
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
$("#importSnapshotAsNewFile")?.addEventListener("change", async (event) => {
|
|
679
|
+
const input = event.currentTarget;
|
|
680
|
+
const file = input.files?.[0];
|
|
681
|
+
if (!file) return;
|
|
682
|
+
try {
|
|
683
|
+
const result = await importSnapshotFile(file, (snapshot) => ({ snapshot, create_new: true, board_id: project.id }));
|
|
684
|
+
state.boardId = result.imported_board_id;
|
|
685
|
+
state.boardSlug = result.imported_board_slug;
|
|
686
|
+
state.detailMode = "empty";
|
|
687
|
+
await load();
|
|
688
|
+
await navigate({ boardId: result.imported_board_id, view: "board" });
|
|
689
|
+
const counts = result.imported_counts || {};
|
|
690
|
+
const cardCount = Number.isFinite(Number(counts.tickets)) ? ` (${counts.tickets} cards)` : "";
|
|
691
|
+
toast.success(result.source_format === "trello-board-export" ? `Trello board imported as new board${cardCount}` : "Board imported as new board");
|
|
692
|
+
} catch (err) {
|
|
693
|
+
toast.error(`Import failed: ${err?.message || "invalid JSON"}`);
|
|
694
|
+
} finally {
|
|
695
|
+
input.value = "";
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
const replaceImportConfirmInput = $("#replaceImportConfirmInput");
|
|
700
|
+
const replaceCurrentBoardImportFile = $("#replaceCurrentBoardImportFile");
|
|
701
|
+
replaceImportConfirmInput?.addEventListener("input", () => {
|
|
702
|
+
if (replaceCurrentBoardImportFile) replaceCurrentBoardImportFile.disabled = replaceImportConfirmInput.value.trim() !== project.slug;
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
replaceCurrentBoardImportFile?.addEventListener("change", async (event) => {
|
|
706
|
+
const input = event.currentTarget;
|
|
707
|
+
const file = input.files?.[0];
|
|
708
|
+
if (!file) return;
|
|
709
|
+
if (replaceImportConfirmInput.value.trim() !== project.slug) {
|
|
710
|
+
toast.error("Type the board slug before replacing this board.");
|
|
711
|
+
input.value = "";
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
try {
|
|
715
|
+
const result = await importSnapshotFile(file, (snapshot) => ({ snapshot, replace_existing: true, board_id: project.id }));
|
|
716
|
+
await load();
|
|
717
|
+
state.detailMode = "settings";
|
|
718
|
+
const counts = result.imported_counts || {};
|
|
719
|
+
const cardCount = Number.isFinite(Number(counts.tickets)) ? ` (${counts.tickets} cards)` : "";
|
|
720
|
+
toast.success(result.source_format === "trello-board-export" ? `Trello board replaced from Trello import${cardCount}` : "Board replaced from snapshot");
|
|
721
|
+
} catch (err) {
|
|
722
|
+
toast.error(`Import failed: ${err?.message || "invalid JSON"}`);
|
|
723
|
+
} finally {
|
|
724
|
+
input.value = "";
|
|
725
|
+
}
|
|
644
726
|
});
|
|
645
727
|
|
|
646
728
|
$("#deleteBoardStart")?.addEventListener("click", async () => {
|
|
@@ -817,17 +899,39 @@ function bindProjectTabHandlers(context, tab) {
|
|
|
817
899
|
});
|
|
818
900
|
});
|
|
819
901
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
902
|
+
const instructionsEl = drawerInner.querySelector('[data-edit-field="agent_instructions"]');
|
|
903
|
+
if (instructionsEl) {
|
|
904
|
+
const edit = () =>
|
|
905
|
+
startInlineEdit(instructionsEl, {
|
|
906
|
+
fieldName: "agent_instructions",
|
|
907
|
+
multiline: true,
|
|
908
|
+
initialValue: project.agent_instructions || "",
|
|
909
|
+
ariaLabel: "Edit Agent Instructions",
|
|
910
|
+
rerender: async () => {
|
|
911
|
+
state.detailMode = "settings";
|
|
912
|
+
await renderProjectDetail();
|
|
913
|
+
},
|
|
914
|
+
commit: async (next) => {
|
|
915
|
+
await api(`/api/boards/${project.id}`, {
|
|
916
|
+
method: "PATCH",
|
|
917
|
+
body: { agent_instructions: cleanText(next) }
|
|
918
|
+
});
|
|
919
|
+
await load();
|
|
920
|
+
state.detailMode = "settings";
|
|
921
|
+
toast.success("Saved");
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
instructionsEl.addEventListener("click", (event) => {
|
|
925
|
+
event.preventDefault();
|
|
926
|
+
edit();
|
|
826
927
|
});
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
928
|
+
instructionsEl.addEventListener("keydown", (event) => {
|
|
929
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
930
|
+
event.preventDefault();
|
|
931
|
+
edit();
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
}
|
|
831
935
|
|
|
832
936
|
$("#aiEnabledToggle")?.addEventListener("change", async (event) => {
|
|
833
937
|
const enabled = !!event.currentTarget.checked;
|
package/public/js/sse.js
CHANGED
|
File without changes
|