symphifo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +13 -0
- package/README.md +394 -0
- package/SYMPHIFO.md +171 -0
- package/WORKFLOW.md +39 -0
- package/bin/symphifo.js +37 -0
- package/package.json +46 -0
- package/src/cli.ts +213 -0
- package/src/dashboard/app.js +1390 -0
- package/src/dashboard/index.html +139 -0
- package/src/dashboard/styles.css +1528 -0
- package/src/fixtures/local-issues.json +13 -0
- package/src/integrations/catalog.ts +151 -0
- package/src/mcp/server.ts +1237 -0
- package/src/routing/capability-resolver.ts +390 -0
- package/src/runtime/agent.ts +1050 -0
- package/src/runtime/api-server.ts +306 -0
- package/src/runtime/constants.ts +102 -0
- package/src/runtime/helpers.ts +134 -0
- package/src/runtime/issues.ts +456 -0
- package/src/runtime/logger.ts +59 -0
- package/src/runtime/providers.ts +310 -0
- package/src/runtime/run-local.ts +146 -0
- package/src/runtime/scheduler.ts +214 -0
- package/src/runtime/skills.ts +55 -0
- package/src/runtime/store.ts +313 -0
- package/src/runtime/types.ts +274 -0
- package/src/runtime/workflow.ts +185 -0
|
@@ -0,0 +1,1390 @@
|
|
|
1
|
+
const subtitle = document.getElementById("subtitle");
|
|
2
|
+
const healthBadge = document.getElementById("health");
|
|
3
|
+
const refreshBadge = document.getElementById("lastRefresh");
|
|
4
|
+
const overviewEl = document.getElementById("overview");
|
|
5
|
+
const issueListEl = document.getElementById("issue-list");
|
|
6
|
+
const runtimeMeta = document.getElementById("runtime-meta");
|
|
7
|
+
const stateFilter = document.getElementById("state-filter");
|
|
8
|
+
const categoryFilter = document.getElementById("category-filter");
|
|
9
|
+
const queryInput = document.getElementById("query");
|
|
10
|
+
const eventsEl = document.getElementById("events");
|
|
11
|
+
const eventKindFilter = document.getElementById("event-kind-filter");
|
|
12
|
+
const eventIssueFilter = document.getElementById("event-issue-filter");
|
|
13
|
+
const rerunBtn = document.getElementById("rerun");
|
|
14
|
+
const clearEventsBtn = document.getElementById("clear-events");
|
|
15
|
+
const newIssueBtn = document.getElementById("new-issue-btn");
|
|
16
|
+
const createForm = document.getElementById("create-form");
|
|
17
|
+
const detailPanel = document.getElementById("detail-panel");
|
|
18
|
+
const detailPlaceholder = document.getElementById("detail-placeholder");
|
|
19
|
+
|
|
20
|
+
let appState = {};
|
|
21
|
+
let lastEventTimestamp = "";
|
|
22
|
+
let lastStateHash = "";
|
|
23
|
+
let expandedSessions = new Set();
|
|
24
|
+
let activeSplitId = null;
|
|
25
|
+
let selectedDetailId = null;
|
|
26
|
+
let selectedIssues = new Set();
|
|
27
|
+
let lastHealthStatus = null;
|
|
28
|
+
let activeKpiFilter = null;
|
|
29
|
+
|
|
30
|
+
// ── Toast notifications ─────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
function getOrCreateToastContainer() {
|
|
33
|
+
let container = document.querySelector(".toast");
|
|
34
|
+
if (!container) {
|
|
35
|
+
container = document.createElement("div");
|
|
36
|
+
container.className = "toast";
|
|
37
|
+
document.body.appendChild(container);
|
|
38
|
+
}
|
|
39
|
+
return container;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function showToast(message, kind = "error", durationMs = 4000) {
|
|
43
|
+
const container = getOrCreateToastContainer();
|
|
44
|
+
const item = document.createElement("div");
|
|
45
|
+
const cls = kind === "success" ? "toast-success" : kind === "warn" ? "toast-warn" : "";
|
|
46
|
+
item.className = `toast-item ${cls}`.trim();
|
|
47
|
+
item.textContent = message;
|
|
48
|
+
container.appendChild(item);
|
|
49
|
+
|
|
50
|
+
setTimeout(() => {
|
|
51
|
+
item.classList.add("toast-out");
|
|
52
|
+
item.addEventListener("animationend", () => item.remove());
|
|
53
|
+
}, durationMs);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
const stateOrder = ["Todo", "In Progress", "In Review", "Blocked", "Done", "Cancelled"];
|
|
59
|
+
const capabilityOrder = ["security", "bugfix", "backend", "devops", "frontend-ui", "architecture", "documentation", "default", "workflow-disabled"];
|
|
60
|
+
|
|
61
|
+
function escapeHtml(value) {
|
|
62
|
+
return String(value)
|
|
63
|
+
.replace(/&/g, "&")
|
|
64
|
+
.replace(/</g, "<")
|
|
65
|
+
.replace(/>/g, ">")
|
|
66
|
+
.replace(/"/g, """)
|
|
67
|
+
.replace(/'/g, "'");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatDate(value) {
|
|
71
|
+
if (!value) return "-";
|
|
72
|
+
const parsed = new Date(value);
|
|
73
|
+
if (Number.isNaN(parsed.getTime())) return "-";
|
|
74
|
+
return parsed.toLocaleString();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function timeAgo(value) {
|
|
78
|
+
if (!value) return "-";
|
|
79
|
+
const parsed = new Date(value);
|
|
80
|
+
if (Number.isNaN(parsed.getTime())) return "-";
|
|
81
|
+
const elapsed = Date.now() - parsed.getTime();
|
|
82
|
+
if (elapsed < 0) return "just now";
|
|
83
|
+
if (elapsed < 60_000) return `${Math.floor(elapsed / 1000)}s ago`;
|
|
84
|
+
if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)}m ago`;
|
|
85
|
+
if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)}h ago`;
|
|
86
|
+
return `${Math.floor(elapsed / 86_400_000)}d ago`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function formatDuration(ms) {
|
|
90
|
+
if (!ms || ms < 0) return "-";
|
|
91
|
+
if (ms < 1000) return `${ms}ms`;
|
|
92
|
+
const s = Math.round(ms / 1000);
|
|
93
|
+
if (s < 60) return `${s}s`;
|
|
94
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
95
|
+
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function simpleHash(str) {
|
|
99
|
+
let hash = 0;
|
|
100
|
+
for (let i = 0; i < str.length; i++) {
|
|
101
|
+
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
|
|
102
|
+
}
|
|
103
|
+
return String(hash);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isDesktop() {
|
|
107
|
+
return window.matchMedia("(min-width: 900px)").matches;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Loading state wrapper ───────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
async function withLoading(target, asyncFn) {
|
|
113
|
+
if (!(target instanceof HTMLButtonElement)) {
|
|
114
|
+
return asyncFn();
|
|
115
|
+
}
|
|
116
|
+
const originalText = target.textContent;
|
|
117
|
+
target.disabled = true;
|
|
118
|
+
target.textContent = "\u00B7\u00B7\u00B7";
|
|
119
|
+
try {
|
|
120
|
+
return await asyncFn();
|
|
121
|
+
} finally {
|
|
122
|
+
target.disabled = false;
|
|
123
|
+
target.textContent = originalText;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Network ──────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
async function fetchJSON(path) {
|
|
130
|
+
const response = await fetch(path);
|
|
131
|
+
if (!response.ok) throw new Error(`Request failed: ${response.status}`);
|
|
132
|
+
return response.json();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function post(path, payload = {}) {
|
|
136
|
+
const response = await fetch(path, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: { "content-type": "application/json" },
|
|
139
|
+
body: JSON.stringify(payload),
|
|
140
|
+
});
|
|
141
|
+
if (!response.ok) {
|
|
142
|
+
const errorPayload = await response.json().catch(() => ({}));
|
|
143
|
+
throw new Error(errorPayload.error || `Request failed: ${response.status}`);
|
|
144
|
+
}
|
|
145
|
+
return response.json();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── KPI Overview ─────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
function kpiCard(label, value, { accent = "", desc = "", filterKey = "", filterValue = "" } = {}) {
|
|
151
|
+
const cls = accent ? ` ${accent}` : "";
|
|
152
|
+
const clickable = filterKey ? " kpi-clickable" : "";
|
|
153
|
+
const active = activeKpiFilter && activeKpiFilter.key === filterKey && activeKpiFilter.value === filterValue ? " kpi-active" : "";
|
|
154
|
+
const dataAttrs = filterKey ? ` data-kpi-filter="${escapeHtml(filterKey)}" data-kpi-value="${escapeHtml(filterValue)}"` : "";
|
|
155
|
+
return `
|
|
156
|
+
<div class="kpi${clickable}${active}"${dataAttrs}>
|
|
157
|
+
<p class="label">${label}</p>
|
|
158
|
+
<p class="value${cls}">${value}</p>
|
|
159
|
+
${desc ? `<p class="desc">${escapeHtml(desc)}</p>` : ""}
|
|
160
|
+
</div>
|
|
161
|
+
`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function capabilityRank(value) {
|
|
165
|
+
const normalized = String(value || "default");
|
|
166
|
+
const index = capabilityOrder.indexOf(normalized);
|
|
167
|
+
return index === -1 ? 999 : index;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function updateTabTitle(metrics) {
|
|
171
|
+
if (!metrics) return;
|
|
172
|
+
const parts = [];
|
|
173
|
+
if (metrics.inProgress) parts.push(`${metrics.inProgress} running`);
|
|
174
|
+
if (metrics.blocked) parts.push(`${metrics.blocked} blocked`);
|
|
175
|
+
if (metrics.queued) parts.push(`${metrics.queued} queued`);
|
|
176
|
+
document.title = parts.length
|
|
177
|
+
? `(${parts.join(", ")}) Symphifo`
|
|
178
|
+
: "Symphifo";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function renderOverview(metrics, issues = []) {
|
|
182
|
+
if (!metrics) return;
|
|
183
|
+
|
|
184
|
+
updateTabTitle(metrics);
|
|
185
|
+
|
|
186
|
+
const total = metrics.total || 0;
|
|
187
|
+
|
|
188
|
+
if (total === 0) {
|
|
189
|
+
overviewEl.innerHTML = `
|
|
190
|
+
<div class="kpi" style="grid-column: 1 / -1; padding: 24px;">
|
|
191
|
+
<p class="label">No Issues</p>
|
|
192
|
+
<p class="value" style="font-size: 1.2rem;">Create your first issue to get started</p>
|
|
193
|
+
<p class="desc">Use the "+ New" button above or POST to /api/issues</p>
|
|
194
|
+
</div>
|
|
195
|
+
`;
|
|
196
|
+
const existing = document.getElementById("progress-bar");
|
|
197
|
+
if (existing) existing.remove();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const running = metrics.inProgress || 0;
|
|
201
|
+
const blocked = metrics.blocked || 0;
|
|
202
|
+
const done = metrics.done || 0;
|
|
203
|
+
const queued = metrics.queued || 0;
|
|
204
|
+
const cancelled = metrics.cancelled || 0;
|
|
205
|
+
const pctDone = total > 0 ? `${Math.round((done / total) * 100)}% complete` : "";
|
|
206
|
+
const byCapability = issues.reduce((accumulator, issue) => {
|
|
207
|
+
const key = issue.capabilityCategory || "default";
|
|
208
|
+
accumulator[key] = (accumulator[key] || 0) + 1;
|
|
209
|
+
return accumulator;
|
|
210
|
+
}, {});
|
|
211
|
+
const topCapabilities = Object.entries(byCapability)
|
|
212
|
+
.sort((a, b) => {
|
|
213
|
+
const rankDiff = capabilityRank(a[0]) - capabilityRank(b[0]);
|
|
214
|
+
if (rankDiff !== 0) return rankDiff;
|
|
215
|
+
return b[1] - a[1];
|
|
216
|
+
})
|
|
217
|
+
.slice(0, 3);
|
|
218
|
+
const criticalQueue = issues.filter((issue) => issue.capabilityCategory === "security" || issue.capabilityCategory === "bugfix").length;
|
|
219
|
+
|
|
220
|
+
overviewEl.innerHTML = [
|
|
221
|
+
kpiCard("Total", total, { filterKey: "state", filterValue: "all" }),
|
|
222
|
+
kpiCard("Queued", queued, { accent: queued > 0 ? "accent" : "", filterKey: "state", filterValue: "Todo" }),
|
|
223
|
+
kpiCard("Running", running, { accent: running > 0 ? "accent" : "", desc: running > 0 ? "in progress" : "", filterKey: "state", filterValue: "In Progress" }),
|
|
224
|
+
kpiCard("Blocked", blocked, { accent: blocked > 0 ? "danger" : "", desc: blocked > 0 ? "needs attention" : "", filterKey: "state", filterValue: "Blocked" }),
|
|
225
|
+
kpiCard("Done", done, { desc: pctDone, filterKey: "state", filterValue: "Done" }),
|
|
226
|
+
kpiCard("Cancelled", cancelled, { accent: cancelled > 0 ? "warn" : "", filterKey: "state", filterValue: "Cancelled" }),
|
|
227
|
+
kpiCard("Critical", criticalQueue, { accent: criticalQueue > 0 ? "danger" : "", desc: "security + bugfix", filterKey: "capability", filterValue: "critical" }),
|
|
228
|
+
...topCapabilities.map(([category, count]) => kpiCard(category, count, { desc: "capability load", filterKey: "capability", filterValue: category })),
|
|
229
|
+
].join("");
|
|
230
|
+
|
|
231
|
+
// Progress bar
|
|
232
|
+
if (total > 0) {
|
|
233
|
+
const donePct = Math.round((done / total) * 100);
|
|
234
|
+
const runningPct = Math.round((running / total) * 100);
|
|
235
|
+
const blockedPct = Math.round((blocked / total) * 100);
|
|
236
|
+
const existing = document.getElementById("progress-bar");
|
|
237
|
+
if (existing) existing.remove();
|
|
238
|
+
overviewEl.insertAdjacentHTML("afterend", `
|
|
239
|
+
<div id="progress-bar" class="progress-bar">
|
|
240
|
+
<div class="progress-segment progress-done" style="width:${donePct}%" title="Done ${donePct}%"></div>
|
|
241
|
+
<div class="progress-segment progress-running" style="width:${runningPct}%" title="Running ${runningPct}%"></div>
|
|
242
|
+
<div class="progress-segment progress-blocked" style="width:${blockedPct}%" title="Blocked ${blockedPct}%"></div>
|
|
243
|
+
</div>
|
|
244
|
+
`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── KPI click handler ───────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
overviewEl.addEventListener("click", (event) => {
|
|
251
|
+
const kpi = event.target.closest(".kpi-clickable");
|
|
252
|
+
if (!kpi) return;
|
|
253
|
+
|
|
254
|
+
const filterKey = kpi.dataset.kpiFilter;
|
|
255
|
+
const filterValue = kpi.dataset.kpiValue;
|
|
256
|
+
if (!filterKey) return;
|
|
257
|
+
|
|
258
|
+
// Toggle: if same filter is active, reset
|
|
259
|
+
if (activeKpiFilter && activeKpiFilter.key === filterKey && activeKpiFilter.value === filterValue) {
|
|
260
|
+
activeKpiFilter = null;
|
|
261
|
+
stateFilter.value = "all";
|
|
262
|
+
if (categoryFilter) categoryFilter.value = "all";
|
|
263
|
+
} else {
|
|
264
|
+
activeKpiFilter = { key: filterKey, value: filterValue };
|
|
265
|
+
|
|
266
|
+
if (filterKey === "state") {
|
|
267
|
+
if (filterValue === "all") {
|
|
268
|
+
stateFilter.value = "all";
|
|
269
|
+
} else {
|
|
270
|
+
stateFilter.value = filterValue;
|
|
271
|
+
}
|
|
272
|
+
if (categoryFilter) categoryFilter.value = "all";
|
|
273
|
+
} else if (filterKey === "capability") {
|
|
274
|
+
stateFilter.value = "all";
|
|
275
|
+
if (filterValue === "critical") {
|
|
276
|
+
// "Critical" means security+bugfix — no single category filter, we handle in renderIssues
|
|
277
|
+
if (categoryFilter) categoryFilter.value = "all";
|
|
278
|
+
} else if (categoryFilter) {
|
|
279
|
+
categoryFilter.value = filterValue;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
renderOverview(appState.metrics || {}, appState.issues || []);
|
|
285
|
+
renderIssues(appState.issues || []);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ── Issue Actions ────────────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
function actionButton(issueId, label, action, payload = "") {
|
|
291
|
+
return `<button type="button" class="action-button" data-id="${escapeHtml(issueId)}" data-action="${action}" data-payload="${escapeHtml(payload)}">${label}</button>`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function issueActions(issue) {
|
|
295
|
+
const editBtn = actionButton(issue.id, "Edit", "edit");
|
|
296
|
+
const deleteBtn = actionButton(issue.id, "Delete", "delete");
|
|
297
|
+
const splitBtn = actionButton(issue.id, "Split", "split");
|
|
298
|
+
|
|
299
|
+
let primaryHtml = "";
|
|
300
|
+
let secondaryHtml = "";
|
|
301
|
+
|
|
302
|
+
if (issue.state === "Blocked") {
|
|
303
|
+
primaryHtml = `${actionButton(issue.id, "Retry", "retry")} ${actionButton(issue.id, "Set Todo", "state", "Todo")} ${actionButton(issue.id, "Cancel", "cancel")}`;
|
|
304
|
+
secondaryHtml = `${editBtn} ${splitBtn} ${deleteBtn}`;
|
|
305
|
+
} else if (issue.state === "Done" || issue.state === "Cancelled") {
|
|
306
|
+
primaryHtml = actionButton(issue.id, "Retry", "retry");
|
|
307
|
+
secondaryHtml = `${editBtn} ${deleteBtn}`;
|
|
308
|
+
} else if (issue.state === "Todo") {
|
|
309
|
+
primaryHtml = `${actionButton(issue.id, "Mark In Progress", "state", "In Progress")} ${actionButton(issue.id, "Cancel", "state", "Cancelled")}`;
|
|
310
|
+
secondaryHtml = `${editBtn} ${splitBtn} ${deleteBtn}`;
|
|
311
|
+
} else {
|
|
312
|
+
// In Progress, In Review
|
|
313
|
+
primaryHtml = `${actionButton(issue.id, "View Sessions", "sessions")} ${actionButton(issue.id, "Cancel", "cancel")}`;
|
|
314
|
+
secondaryHtml = editBtn;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const moreBtn = `<button type="button" class="btn-more" data-action="more" data-id="${escapeHtml(issue.id)}" title="More actions">···</button>`;
|
|
318
|
+
|
|
319
|
+
return `${primaryHtml} ${moreBtn} <span class="actions-secondary" data-secondary-for="${escapeHtml(issue.id)}">${secondaryHtml}</span>`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function stateClass(value) {
|
|
323
|
+
return `state-badge state-${String(value).replace(/\s+/g, "_")}`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── Issue Rendering ──────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
function renderIssues(issues = []) {
|
|
329
|
+
const selectedState = stateFilter.value;
|
|
330
|
+
const selectedCategory = categoryFilter?.value || "all";
|
|
331
|
+
const search = queryInput.value.trim().toLowerCase();
|
|
332
|
+
|
|
333
|
+
const filtered = issues.filter((issue) => {
|
|
334
|
+
if (selectedState !== "all" && issue.state !== selectedState) return false;
|
|
335
|
+
if (selectedCategory !== "all" && issue.capabilityCategory !== selectedCategory) return false;
|
|
336
|
+
// Handle critical KPI filter (security+bugfix)
|
|
337
|
+
if (activeKpiFilter && activeKpiFilter.key === "capability" && activeKpiFilter.value === "critical") {
|
|
338
|
+
if (issue.capabilityCategory !== "security" && issue.capabilityCategory !== "bugfix") return false;
|
|
339
|
+
}
|
|
340
|
+
if (search) {
|
|
341
|
+
const target = `${issue.identifier} ${issue.title} ${issue.description || ""} ${issue.id}`.toLowerCase();
|
|
342
|
+
if (!target.includes(search)) return false;
|
|
343
|
+
}
|
|
344
|
+
return true;
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Populate category filter dynamically
|
|
348
|
+
if (categoryFilter) {
|
|
349
|
+
const categories = new Set(issues.map((i) => i.capabilityCategory).filter(Boolean));
|
|
350
|
+
const currentOptions = new Set();
|
|
351
|
+
for (const opt of categoryFilter.options) currentOptions.add(opt.value);
|
|
352
|
+
for (const cat of categories) {
|
|
353
|
+
if (!currentOptions.has(cat)) {
|
|
354
|
+
const opt = document.createElement("option");
|
|
355
|
+
opt.value = cat;
|
|
356
|
+
opt.textContent = cat;
|
|
357
|
+
categoryFilter.appendChild(opt);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Issue count + batch toolbar
|
|
363
|
+
const countEl = document.getElementById("issue-count");
|
|
364
|
+
if (countEl) {
|
|
365
|
+
if (selectedIssues.size > 0) {
|
|
366
|
+
countEl.innerHTML = `<span class="batch-info">${selectedIssues.size} selected</span> `
|
|
367
|
+
+ `<button type="button" class="action-button" id="batch-retry">Retry All</button> `
|
|
368
|
+
+ `<button type="button" class="action-button" id="batch-cancel">Cancel All</button> `
|
|
369
|
+
+ `<button type="button" class="action-button" id="batch-clear">Clear</button>`;
|
|
370
|
+
} else {
|
|
371
|
+
countEl.textContent = filtered.length === issues.length
|
|
372
|
+
? `${issues.length} issues`
|
|
373
|
+
: `${filtered.length} / ${issues.length} issues`;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (!filtered.length) {
|
|
378
|
+
issueListEl.innerHTML = '<p class="muted">No issues match this filter.</p>';
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
issueListEl.innerHTML = filtered
|
|
383
|
+
.sort((a, b) => {
|
|
384
|
+
const sa = stateOrder.indexOf(a.state);
|
|
385
|
+
const sb = stateOrder.indexOf(b.state);
|
|
386
|
+
if (sa !== sb) return sa - sb;
|
|
387
|
+
const priorityDiff = (a.priority || 999) - (b.priority || 999);
|
|
388
|
+
if (priorityDiff !== 0) return priorityDiff;
|
|
389
|
+
const capabilityDiff = capabilityRank(a.capabilityCategory) - capabilityRank(b.capabilityCategory);
|
|
390
|
+
if (capabilityDiff !== 0) return capabilityDiff;
|
|
391
|
+
return String(a.identifier || "").localeCompare(String(b.identifier || ""));
|
|
392
|
+
})
|
|
393
|
+
.map((issue) => {
|
|
394
|
+
const historyEntries = Array.isArray(issue.history) ? issue.history : [];
|
|
395
|
+
const recentHistory = historyEntries.slice(-3).map((entry) => `<li class="mono">${escapeHtml(entry)}</li>`).join("");
|
|
396
|
+
const olderHistory = historyEntries.slice(0, -3).map((entry) => `<li class="mono">${escapeHtml(entry)}</li>`).join("");
|
|
397
|
+
const history = historyEntries.length > 3
|
|
398
|
+
? `<details class="history-detail"><summary>${historyEntries.length - 3} older entries</summary><ul class="history">${olderHistory}</ul></details>${recentHistory}`
|
|
399
|
+
: recentHistory;
|
|
400
|
+
const labels = Array.isArray(issue.labels)
|
|
401
|
+
? issue.labels.map((l) => `<span class="tag">${escapeHtml(l)}</span>`).join("")
|
|
402
|
+
: "";
|
|
403
|
+
const paths = Array.isArray(issue.paths) && issue.paths.length
|
|
404
|
+
? issue.paths.map((p) => `<span class="tag mono">${escapeHtml(p)}</span>`).join("")
|
|
405
|
+
: "";
|
|
406
|
+
const overlays = Array.isArray(issue.capabilityOverlays) && issue.capabilityOverlays.length
|
|
407
|
+
? issue.capabilityOverlays.map((o) => `<span class="tag">${escapeHtml(`overlay:${o}`)}</span>`).join("")
|
|
408
|
+
: "";
|
|
409
|
+
const category = issue.capabilityCategory
|
|
410
|
+
? `<span class="tag">${escapeHtml(`capability:${issue.capabilityCategory}`)}</span>`
|
|
411
|
+
: "";
|
|
412
|
+
|
|
413
|
+
const blockedByHtml = Array.isArray(issue.blockedBy) && issue.blockedBy.length
|
|
414
|
+
? issue.blockedBy.map((dep) => `<span class="blocked-by">${escapeHtml(dep)}</span>`).join("")
|
|
415
|
+
: "";
|
|
416
|
+
|
|
417
|
+
const errorHtml = issue.lastError
|
|
418
|
+
? `<details class="error-detail">
|
|
419
|
+
<summary>Last error</summary>
|
|
420
|
+
<pre class="mono">${escapeHtml(issue.lastError)}</pre>
|
|
421
|
+
</details>`
|
|
422
|
+
: "";
|
|
423
|
+
|
|
424
|
+
// On mobile, show inline session panel; on desktop, sessions go to detail panel
|
|
425
|
+
const sessionHtml = expandedSessions.has(issue.id)
|
|
426
|
+
? `<div class="session-panel" id="session-${escapeHtml(issue.id)}"><div class="session-loading">Loading sessions...</div></div>`
|
|
427
|
+
: "";
|
|
428
|
+
|
|
429
|
+
const noteHtml = (issue.state === "Blocked" || issue.state === "Todo")
|
|
430
|
+
? `<div class="note-form">
|
|
431
|
+
<input type="text" placeholder="Add note for next retry..." data-note-for="${escapeHtml(issue.id)}" />
|
|
432
|
+
<button type="button" class="action-button" data-id="${escapeHtml(issue.id)}" data-action="note">Send</button>
|
|
433
|
+
</div>`
|
|
434
|
+
: "";
|
|
435
|
+
|
|
436
|
+
const splitHtml = activeSplitId === issue.id
|
|
437
|
+
? `<div class="split-form">
|
|
438
|
+
<label class="split-label">Sub-task titles (one per line)</label>
|
|
439
|
+
<textarea data-split-for="${escapeHtml(issue.id)}" rows="3" placeholder="Fix the header layout\nUpdate the unit tests\nAdd error handling"></textarea>
|
|
440
|
+
<div class="split-form-actions">
|
|
441
|
+
<button type="button" class="action-button" data-id="${escapeHtml(issue.id)}" data-action="split-cancel">Cancel</button>
|
|
442
|
+
<button type="button" class="action-button btn-accent" data-id="${escapeHtml(issue.id)}" data-action="split-submit">Create Sub-tasks</button>
|
|
443
|
+
</div>
|
|
444
|
+
</div>`
|
|
445
|
+
: "";
|
|
446
|
+
|
|
447
|
+
const editHtml = activeEditId === issue.id ? renderEditForm(issue) : "";
|
|
448
|
+
|
|
449
|
+
const deleteHtml = pendingDeleteId === issue.id
|
|
450
|
+
? `<div class="delete-confirm">
|
|
451
|
+
<span>Delete ${escapeHtml(issue.identifier)}? This cannot be undone.</span>
|
|
452
|
+
<button type="button" class="action-button btn-danger" data-id="${escapeHtml(issue.id)}" data-action="delete-confirm">Confirm Delete</button>
|
|
453
|
+
<button type="button" class="action-button" data-id="${escapeHtml(issue.id)}" data-action="delete-cancel">Cancel</button>
|
|
454
|
+
</div>`
|
|
455
|
+
: "";
|
|
456
|
+
|
|
457
|
+
const isRunning = issue.state === "In Progress";
|
|
458
|
+
const isDetailSelected = selectedDetailId === issue.id;
|
|
459
|
+
|
|
460
|
+
return `
|
|
461
|
+
<article class="issue-card${isRunning ? " issue-running" : ""}${isDetailSelected ? " issue-selected" : ""}" data-issue-id="${escapeHtml(issue.id)}">
|
|
462
|
+
<h3 class="issue-title">
|
|
463
|
+
<label class="issue-select"><input type="checkbox" data-select-issue="${escapeHtml(issue.id)}" ${selectedIssues.has(issue.id) ? "checked" : ""} /><span class="issue-checkbox"></span></label>
|
|
464
|
+
${escapeHtml(issue.identifier)} — ${escapeHtml(issue.title)}
|
|
465
|
+
</h3>
|
|
466
|
+
<p class="muted">${escapeHtml(issue.description || "No description")}</p>
|
|
467
|
+
<div class="meta">
|
|
468
|
+
<span class="${stateClass(issue.state)}">${escapeHtml(issue.state)}</span>
|
|
469
|
+
${blockedByHtml}
|
|
470
|
+
<span>Priority ${escapeHtml(issue.priority)}</span>
|
|
471
|
+
<span>Attempts ${escapeHtml(issue.attempts || 0)}/${escapeHtml(issue.maxAttempts || 1)}</span>
|
|
472
|
+
${issue.durationMs ? `<span>Duration ${formatDuration(issue.durationMs)}</span>` : ""}
|
|
473
|
+
</div>
|
|
474
|
+
<div class="meta">${labels}</div>
|
|
475
|
+
${(category || overlays) ? `<div class="meta">${category}${overlays}</div>` : ""}
|
|
476
|
+
${paths ? `<div class="meta">${paths}</div>` : ""}
|
|
477
|
+
<div class="meta">
|
|
478
|
+
<span title="${escapeHtml(formatDate(issue.updatedAt))}">Updated: ${timeAgo(issue.updatedAt)}</span>
|
|
479
|
+
<span>Workspace: ${escapeHtml(issue.workspacePath || "pending")}</span>
|
|
480
|
+
</div>
|
|
481
|
+
${errorHtml}
|
|
482
|
+
<div class="actions">${issueActions(issue)}</div>
|
|
483
|
+
${noteHtml}
|
|
484
|
+
${splitHtml}
|
|
485
|
+
${editHtml}
|
|
486
|
+
${deleteHtml}
|
|
487
|
+
${sessionHtml}
|
|
488
|
+
<ul class="history">${history}</ul>
|
|
489
|
+
</article>
|
|
490
|
+
`;
|
|
491
|
+
})
|
|
492
|
+
.join("");
|
|
493
|
+
|
|
494
|
+
for (const issueId of expandedSessions) {
|
|
495
|
+
loadSessionsForIssue(issueId);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Refresh detail panel if selected issue is still in the list
|
|
499
|
+
if (selectedDetailId && isDesktop()) {
|
|
500
|
+
const issue = issues.find((i) => i.id === selectedDetailId);
|
|
501
|
+
if (issue) {
|
|
502
|
+
renderDetailPanel(issue);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ── Detail panel (desktop right panel) ──────────────────────────────────────
|
|
508
|
+
|
|
509
|
+
function renderDetailPanel(issue) {
|
|
510
|
+
if (!detailPanel) return;
|
|
511
|
+
detailPanel.innerHTML = `
|
|
512
|
+
<div class="detail-issue-header">
|
|
513
|
+
<span class="mono">${escapeHtml(issue.identifier)}</span> ${escapeHtml(issue.title)}
|
|
514
|
+
<button type="button" class="btn-close-detail" id="close-detail" title="Close">×</button>
|
|
515
|
+
</div>
|
|
516
|
+
<div class="meta" style="margin-top:0">
|
|
517
|
+
<span class="${stateClass(issue.state)}">${escapeHtml(issue.state)}</span>
|
|
518
|
+
<span>Priority ${escapeHtml(issue.priority)}</span>
|
|
519
|
+
${issue.durationMs ? `<span>Duration ${formatDuration(issue.durationMs)}</span>` : ""}
|
|
520
|
+
</div>
|
|
521
|
+
<div class="session-panel" id="detail-session-panel">
|
|
522
|
+
<div class="session-loading">Loading sessions...</div>
|
|
523
|
+
</div>
|
|
524
|
+
`;
|
|
525
|
+
document.getElementById("close-detail")?.addEventListener("click", () => {
|
|
526
|
+
clearDetailPanel();
|
|
527
|
+
renderIssues(appState.issues || []);
|
|
528
|
+
});
|
|
529
|
+
loadSessionsForPanel(issue.id, "detail-session-panel");
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function clearDetailPanel() {
|
|
533
|
+
selectedDetailId = null;
|
|
534
|
+
if (detailPanel) {
|
|
535
|
+
detailPanel.innerHTML = '<div class="detail-placeholder">Select an issue to view sessions</div>';
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function loadSessionsForPanel(issueId, panelElementId) {
|
|
540
|
+
const panel = document.getElementById(panelElementId);
|
|
541
|
+
if (!panel) return;
|
|
542
|
+
|
|
543
|
+
try {
|
|
544
|
+
const data = await fetchJSON(`/api/issue/${encodeURIComponent(issueId)}/sessions`);
|
|
545
|
+
let html = "";
|
|
546
|
+
|
|
547
|
+
if (data.pipeline) {
|
|
548
|
+
const pipeline = data.pipeline;
|
|
549
|
+
html += '<div class="session-header">Pipeline</div>';
|
|
550
|
+
html += `<div class="pipeline-step">
|
|
551
|
+
<span class="step-provider">attempt ${escapeHtml(pipeline.attempt || "\u2014")}</span>
|
|
552
|
+
<span class="step-status">cycle ${escapeHtml(pipeline.cycle || "\u2014")}</span>
|
|
553
|
+
<span class="step-status">active index ${escapeHtml(pipeline.activeIndex || 0)}</span>
|
|
554
|
+
</div>`;
|
|
555
|
+
|
|
556
|
+
if (Array.isArray(pipeline.history) && pipeline.history.length) {
|
|
557
|
+
html += `<details class="history-detail" open>
|
|
558
|
+
<summary>${pipeline.history.length} pipeline events</summary>
|
|
559
|
+
<ul class="history">${pipeline.history.map((entry) => `<li class="mono">${escapeHtml(entry)}</li>`).join("")}</ul>
|
|
560
|
+
</details>`;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (data.sessions && Array.isArray(data.sessions) && data.sessions.length) {
|
|
565
|
+
html += '<div class="session-header" style="margin-top:10px">Sessions</div>';
|
|
566
|
+
for (const item of data.sessions.slice(-6)) {
|
|
567
|
+
const session = item.session || {};
|
|
568
|
+
const turns = Array.isArray(session.turns) ? session.turns : [];
|
|
569
|
+
const lastTurn = turns.length ? turns[turns.length - 1] : null;
|
|
570
|
+
html += `<div class="pipeline-step">
|
|
571
|
+
<span class="step-provider">${escapeHtml(`${item.role || "agent"}:${item.provider || "unknown"}`)}</span>
|
|
572
|
+
<span class="step-status">${escapeHtml(session.status || "\u2014")}</span>
|
|
573
|
+
<span class="step-status">cycle ${escapeHtml(item.cycle || "\u2014")}</span>
|
|
574
|
+
<span class="step-status">${escapeHtml(`${turns.length}/${session.maxTurns || "?"} turns`)}</span>
|
|
575
|
+
</div>`;
|
|
576
|
+
if (session.lastDirectiveStatus || session.lastDirectiveSummary) {
|
|
577
|
+
html += `<div class="session-output">${escapeHtml(
|
|
578
|
+
`${session.lastDirectiveStatus || "status"}${session.lastDirectiveSummary ? `: ${session.lastDirectiveSummary}` : ""}`,
|
|
579
|
+
)}</div>`;
|
|
580
|
+
}
|
|
581
|
+
if (lastTurn?.output || session.lastOutput) {
|
|
582
|
+
const output = lastTurn?.output || session.lastOutput || "";
|
|
583
|
+
const truncated = output.length > 2000 ? `\u2026${output.slice(-2000)}` : output;
|
|
584
|
+
html += `<details class="history-detail">
|
|
585
|
+
<summary>Latest output</summary>
|
|
586
|
+
<div class="session-output">${escapeHtml(truncated)}</div>
|
|
587
|
+
</details>`;
|
|
588
|
+
}
|
|
589
|
+
if (turns.length) {
|
|
590
|
+
html += `<details class="history-detail">
|
|
591
|
+
<summary>${turns.length} turns</summary>
|
|
592
|
+
<ul class="history">${turns.map((turn) => `<li class="mono">#${escapeHtml(turn.turn)} ${escapeHtml(turn.directiveStatus || "\u2014")} ${escapeHtml(turn.directiveSummary || "")}</li>`).join("")}</ul>
|
|
593
|
+
</details>`;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
panel.innerHTML = html || '<div class="session-loading">No session data available yet.</div>';
|
|
599
|
+
} catch (error) {
|
|
600
|
+
panel.innerHTML = `<div class="session-loading">Failed to load: ${escapeHtml(error.message)}</div>`;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// ── Session/Pipeline Loading (inline, for mobile) ───────────────────────────
|
|
605
|
+
|
|
606
|
+
async function loadSessionsForIssue(issueId) {
|
|
607
|
+
const panel = document.getElementById(`session-${issueId}`);
|
|
608
|
+
if (!panel) return;
|
|
609
|
+
|
|
610
|
+
try {
|
|
611
|
+
const data = await fetchJSON(`/api/issue/${encodeURIComponent(issueId)}/sessions`);
|
|
612
|
+
let html = "";
|
|
613
|
+
|
|
614
|
+
if (data.pipeline) {
|
|
615
|
+
const pipeline = data.pipeline;
|
|
616
|
+
html += '<div class="session-header">Pipeline</div>';
|
|
617
|
+
html += `<div class="pipeline-step">
|
|
618
|
+
<span class="step-provider">attempt ${escapeHtml(pipeline.attempt || "\u2014")}</span>
|
|
619
|
+
<span class="step-status">cycle ${escapeHtml(pipeline.cycle || "\u2014")}</span>
|
|
620
|
+
<span class="step-status">active index ${escapeHtml(pipeline.activeIndex || 0)}</span>
|
|
621
|
+
</div>`;
|
|
622
|
+
|
|
623
|
+
if (Array.isArray(pipeline.history) && pipeline.history.length) {
|
|
624
|
+
html += `<details class="history-detail" open>
|
|
625
|
+
<summary>${pipeline.history.length} pipeline events</summary>
|
|
626
|
+
<ul class="history">${pipeline.history.map((entry) => `<li class="mono">${escapeHtml(entry)}</li>`).join("")}</ul>
|
|
627
|
+
</details>`;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (data.sessions && Array.isArray(data.sessions) && data.sessions.length) {
|
|
632
|
+
html += '<div class="session-header" style="margin-top:10px">Sessions</div>';
|
|
633
|
+
for (const item of data.sessions.slice(-6)) {
|
|
634
|
+
const session = item.session || {};
|
|
635
|
+
const turns = Array.isArray(session.turns) ? session.turns : [];
|
|
636
|
+
const lastTurn = turns.length ? turns[turns.length - 1] : null;
|
|
637
|
+
html += `<div class="pipeline-step">
|
|
638
|
+
<span class="step-provider">${escapeHtml(`${item.role || "agent"}:${item.provider || "unknown"}`)}</span>
|
|
639
|
+
<span class="step-status">${escapeHtml(session.status || "\u2014")}</span>
|
|
640
|
+
<span class="step-status">cycle ${escapeHtml(item.cycle || "\u2014")}</span>
|
|
641
|
+
<span class="step-status">${escapeHtml(`${turns.length}/${session.maxTurns || "?"} turns`)}</span>
|
|
642
|
+
</div>`;
|
|
643
|
+
if (session.lastDirectiveStatus || session.lastDirectiveSummary) {
|
|
644
|
+
html += `<div class="session-output">${escapeHtml(
|
|
645
|
+
`${session.lastDirectiveStatus || "status"}${session.lastDirectiveSummary ? `: ${session.lastDirectiveSummary}` : ""}`,
|
|
646
|
+
)}</div>`;
|
|
647
|
+
}
|
|
648
|
+
if (lastTurn?.output || session.lastOutput) {
|
|
649
|
+
const output = lastTurn?.output || session.lastOutput || "";
|
|
650
|
+
const truncated = output.length > 2000 ? `\u2026${output.slice(-2000)}` : output;
|
|
651
|
+
html += `<details class="history-detail">
|
|
652
|
+
<summary>Latest output</summary>
|
|
653
|
+
<div class="session-output">${escapeHtml(truncated)}</div>
|
|
654
|
+
</details>`;
|
|
655
|
+
}
|
|
656
|
+
if (turns.length) {
|
|
657
|
+
html += `<details class="history-detail">
|
|
658
|
+
<summary>${turns.length} turns</summary>
|
|
659
|
+
<ul class="history">${turns.map((turn) => `<li class="mono">#${escapeHtml(turn.turn)} ${escapeHtml(turn.directiveStatus || "\u2014")} ${escapeHtml(turn.directiveSummary || "")}</li>`).join("")}</ul>
|
|
660
|
+
</details>`;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
panel.innerHTML = html || '<div class="session-loading">No session data available yet.</div>';
|
|
666
|
+
} catch (error) {
|
|
667
|
+
panel.innerHTML = `<div class="session-loading">Failed to load: ${escapeHtml(error.message)}</div>`;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// ── Split Issue ──────────────────────────────────────────────────────────────
|
|
672
|
+
|
|
673
|
+
function toggleSplitForm(issueId) {
|
|
674
|
+
const issue = (appState.issues || []).find((i) => i.id === issueId);
|
|
675
|
+
if (!issue) return;
|
|
676
|
+
|
|
677
|
+
// Close if already open for this issue
|
|
678
|
+
if (activeSplitId === issueId) {
|
|
679
|
+
activeSplitId = null;
|
|
680
|
+
renderIssues(appState.issues || []);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
activeSplitId = issueId;
|
|
685
|
+
renderIssues(appState.issues || []);
|
|
686
|
+
|
|
687
|
+
// Focus the textarea after render
|
|
688
|
+
setTimeout(() => {
|
|
689
|
+
const textarea = document.querySelector(`[data-split-for="${issueId}"]`);
|
|
690
|
+
if (textarea) textarea.focus();
|
|
691
|
+
}, 50);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async function submitSplit(issueId, target) {
|
|
695
|
+
const textarea = document.querySelector(`[data-split-for="${issueId}"]`);
|
|
696
|
+
if (!textarea || !textarea.value.trim()) {
|
|
697
|
+
showToast("Enter at least one sub-task title", "warn");
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const issue = (appState.issues || []).find((i) => i.id === issueId);
|
|
702
|
+
if (!issue) return;
|
|
703
|
+
|
|
704
|
+
const titles = textarea.value.split("\n").map((t) => t.trim()).filter(Boolean);
|
|
705
|
+
if (!titles.length) return;
|
|
706
|
+
|
|
707
|
+
await withLoading(target, async () => {
|
|
708
|
+
try {
|
|
709
|
+
const created = [];
|
|
710
|
+
for (const title of titles) {
|
|
711
|
+
const result = await post("/api/issues", {
|
|
712
|
+
title,
|
|
713
|
+
description: `Sub-task of ${issue.identifier}: ${issue.title}`,
|
|
714
|
+
priority: issue.priority,
|
|
715
|
+
labels: [...(issue.labels || []), `parent:${issue.identifier}`],
|
|
716
|
+
paths: issue.paths || [],
|
|
717
|
+
maxAttempts: issue.maxAttempts || 3,
|
|
718
|
+
});
|
|
719
|
+
if (result.ok && result.issue) created.push(result.issue.identifier);
|
|
720
|
+
}
|
|
721
|
+
activeSplitId = null;
|
|
722
|
+
showToast(`Created ${created.length} sub-tasks: ${created.join(", ")}`, "success");
|
|
723
|
+
await loadState();
|
|
724
|
+
} catch (error) {
|
|
725
|
+
showToast(`Split failed: ${error.message}`);
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// ── Add Note ─────────────────────────────────────────────────────────────────
|
|
731
|
+
|
|
732
|
+
async function addNote(issueId, target) {
|
|
733
|
+
const input = document.querySelector(`[data-note-for="${issueId}"]`);
|
|
734
|
+
if (!input || !input.value.trim()) return;
|
|
735
|
+
|
|
736
|
+
const issue = (appState.issues || []).find((i) => i.id === issueId);
|
|
737
|
+
const currentState = issue?.state || "Todo";
|
|
738
|
+
const note = input.value.trim();
|
|
739
|
+
|
|
740
|
+
await withLoading(target, async () => {
|
|
741
|
+
try {
|
|
742
|
+
await post(`/api/issue/${encodeURIComponent(issueId)}/state`, { state: currentState, reason: note });
|
|
743
|
+
input.value = "";
|
|
744
|
+
showToast("Note added", "success", 2000);
|
|
745
|
+
await loadState();
|
|
746
|
+
} catch (error) {
|
|
747
|
+
showToast(`Note failed: ${error.message}`);
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// ── Runtime Meta ─────────────────────────────────────────────────────────────
|
|
753
|
+
|
|
754
|
+
function renderRuntimeMeta(state) {
|
|
755
|
+
runtimeMeta.innerHTML = `
|
|
756
|
+
<div class="meta">
|
|
757
|
+
<span>Repository: ${escapeHtml(state.sourceRepoUrl || "local")}</span>
|
|
758
|
+
<span>Workflow: ${escapeHtml(state.workflowPath || "local")}</span>
|
|
759
|
+
<span>Tracker: ${escapeHtml(state.trackerKind || "filesystem")}</span>
|
|
760
|
+
<span>Agent: ${escapeHtml(state.config?.agentProvider || "auto")} (${escapeHtml(state.config?.agentCommand || "auto-detect")})</span>
|
|
761
|
+
<span class="concurrency-control">
|
|
762
|
+
Concurrency:
|
|
763
|
+
<input type="number" id="concurrency-input" class="concurrency-input" min="1" max="16" value="${escapeHtml(state.config?.workerConcurrency ?? 2)}" />
|
|
764
|
+
<button type="button" class="action-button concurrency-btn" id="save-concurrency-btn">Set</button>
|
|
765
|
+
</span>
|
|
766
|
+
</div>
|
|
767
|
+
<div id="providers-panel" class="meta" style="margin-top:4px"></div>
|
|
768
|
+
<div id="parallelism-panel" class="meta" style="margin-top:4px"></div>
|
|
769
|
+
<p class="muted">Started at ${formatDate(state.startedAt)}</p>
|
|
770
|
+
`;
|
|
771
|
+
|
|
772
|
+
document.getElementById("save-concurrency-btn")?.addEventListener("click", async (e) => {
|
|
773
|
+
const input = document.getElementById("concurrency-input");
|
|
774
|
+
const num = parseInt(input?.value, 10);
|
|
775
|
+
if (!num || num < 1 || num > 16) { showToast("Must be 1-16", "warn"); return; }
|
|
776
|
+
await withLoading(e.target, async () => {
|
|
777
|
+
try {
|
|
778
|
+
await post("/api/config/concurrency", { concurrency: num });
|
|
779
|
+
showToast(`Concurrency set to ${num}`, "success");
|
|
780
|
+
await loadState();
|
|
781
|
+
} catch (err) { showToast(err.message); }
|
|
782
|
+
});
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
loadProviders();
|
|
786
|
+
loadParallelism();
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
async function loadProviders() {
|
|
790
|
+
const panel = document.getElementById("providers-panel");
|
|
791
|
+
if (!panel) return;
|
|
792
|
+
try {
|
|
793
|
+
const data = await fetchJSON("/api/providers");
|
|
794
|
+
if (!data.providers || !data.providers.length) { panel.innerHTML = ""; return; }
|
|
795
|
+
panel.innerHTML = "Providers: " + data.providers.map((p) =>
|
|
796
|
+
`<span class="tag ${p.available ? "tag-ok" : "tag-missing"}">${escapeHtml(p.name)}: ${p.available ? "available" : "not found"}</span>`
|
|
797
|
+
).join(" ");
|
|
798
|
+
} catch { panel.innerHTML = ""; }
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async function loadParallelism() {
|
|
802
|
+
const panel = document.getElementById("parallelism-panel");
|
|
803
|
+
if (!panel) return;
|
|
804
|
+
try {
|
|
805
|
+
const data = await fetchJSON("/api/parallelism");
|
|
806
|
+
if (!data.reason) { panel.innerHTML = ""; return; }
|
|
807
|
+
const badge = data.canParallelize ? "tag-ok" : "tag-missing";
|
|
808
|
+
panel.innerHTML = `Parallelism: <span class="tag ${badge}">max safe=${data.maxSafeParallelism}</span> <span class="muted">${escapeHtml(data.reason)}</span>`;
|
|
809
|
+
} catch { panel.innerHTML = ""; }
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// ── Events ───────────────────────────────────────────────────────────────────
|
|
813
|
+
|
|
814
|
+
let allEvents = [];
|
|
815
|
+
|
|
816
|
+
function renderEvents(events = []) {
|
|
817
|
+
// Merge new events (dedup, cap at 200)
|
|
818
|
+
if (events.length) {
|
|
819
|
+
const seen = new Set(allEvents.map((e) => e.at + e.issueId + e.message));
|
|
820
|
+
for (const e of events) {
|
|
821
|
+
if (!seen.has(e.at + e.issueId + e.message)) allEvents.unshift(e);
|
|
822
|
+
}
|
|
823
|
+
if (allEvents.length > 200) allEvents.length = 200;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (!allEvents.length) {
|
|
827
|
+
eventsEl.innerHTML = '<p class="muted">No events yet.</p>';
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Filtering
|
|
832
|
+
const kindFilter = eventKindFilter?.value || "all";
|
|
833
|
+
const issueFilter = eventIssueFilter?.value || "all";
|
|
834
|
+
|
|
835
|
+
const filtered = allEvents.filter((e) => {
|
|
836
|
+
if (kindFilter !== "all" && (e.kind || "info") !== kindFilter) return false;
|
|
837
|
+
if (issueFilter !== "all" && e.issueId !== issueFilter) return false;
|
|
838
|
+
return true;
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
if (!filtered.length) {
|
|
842
|
+
eventsEl.innerHTML = '<p class="muted">No events match this filter.</p>';
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const hadEvents = eventsEl.children.length > 0;
|
|
847
|
+
eventsEl.innerHTML = filtered
|
|
848
|
+
.slice(0, 80)
|
|
849
|
+
.map((event) => `
|
|
850
|
+
<div class="event event-${event.kind || "info"}">
|
|
851
|
+
<div class="mono" title="${escapeHtml(formatDate(event.at))}">${timeAgo(event.at)} ${escapeHtml(event.issueId || "system")}</div>
|
|
852
|
+
<div>${escapeHtml(event.message || "")}</div>
|
|
853
|
+
</div>
|
|
854
|
+
`)
|
|
855
|
+
.join("");
|
|
856
|
+
|
|
857
|
+
// Auto-scroll to top when new events arrive
|
|
858
|
+
if (events.length && hadEvents) eventsEl.scrollTop = 0;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// ── State Management ─────────────────────────────────────────────────────────
|
|
862
|
+
|
|
863
|
+
async function setIssueState(issueId, nextState, target) {
|
|
864
|
+
await withLoading(target, async () => {
|
|
865
|
+
await post(`/api/issue/${encodeURIComponent(issueId)}/state`, { state: nextState });
|
|
866
|
+
await loadState();
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async function retryIssue(issueId, target) {
|
|
871
|
+
await withLoading(target, async () => {
|
|
872
|
+
await post(`/api/issue/${encodeURIComponent(issueId)}/retry`);
|
|
873
|
+
await loadState();
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
async function cancelIssue(issueId, target) {
|
|
878
|
+
await withLoading(target, async () => {
|
|
879
|
+
await post(`/api/issue/${encodeURIComponent(issueId)}/cancel`);
|
|
880
|
+
await loadState();
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// ── Create Issue ─────────────────────────────────────────────────────────────
|
|
885
|
+
|
|
886
|
+
function toggleCreateForm() {
|
|
887
|
+
createForm.hidden = !createForm.hidden;
|
|
888
|
+
if (!createForm.hidden) document.getElementById("cf-title").focus();
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
async function submitCreateForm(target) {
|
|
892
|
+
const title = document.getElementById("cf-title").value.trim();
|
|
893
|
+
if (!title) { showToast("Title is required", "warn"); return; }
|
|
894
|
+
|
|
895
|
+
const payload = {
|
|
896
|
+
title,
|
|
897
|
+
description: document.getElementById("cf-desc").value.trim(),
|
|
898
|
+
priority: Number(document.getElementById("cf-priority").value) || 1,
|
|
899
|
+
maxAttempts: Number(document.getElementById("cf-attempts").value) || 3,
|
|
900
|
+
labels: document.getElementById("cf-labels").value.split(",").map((s) => s.trim()).filter(Boolean),
|
|
901
|
+
paths: document.getElementById("cf-paths").value.split(",").map((s) => s.trim()).filter(Boolean),
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
await withLoading(target, async () => {
|
|
905
|
+
try {
|
|
906
|
+
const result = await post("/api/issues", payload);
|
|
907
|
+
showToast(`Created ${result.issue?.identifier || "issue"}`, "success");
|
|
908
|
+
createForm.hidden = true;
|
|
909
|
+
document.getElementById("cf-title").value = "";
|
|
910
|
+
document.getElementById("cf-desc").value = "";
|
|
911
|
+
document.getElementById("cf-priority").value = "1";
|
|
912
|
+
document.getElementById("cf-attempts").value = "3";
|
|
913
|
+
document.getElementById("cf-labels").value = "";
|
|
914
|
+
document.getElementById("cf-paths").value = "";
|
|
915
|
+
await loadState();
|
|
916
|
+
} catch (error) {
|
|
917
|
+
showToast(`Create failed: ${error.message}`);
|
|
918
|
+
}
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// ── Edit / Delete ────────────────────────────────────────────────────────────
|
|
923
|
+
|
|
924
|
+
let activeEditId = null;
|
|
925
|
+
let pendingDeleteId = null;
|
|
926
|
+
|
|
927
|
+
function toggleEditForm(issueId) {
|
|
928
|
+
activeEditId = activeEditId === issueId ? null : issueId;
|
|
929
|
+
renderIssues(appState.issues || []);
|
|
930
|
+
if (activeEditId) {
|
|
931
|
+
setTimeout(() => {
|
|
932
|
+
const el = document.querySelector(`[data-edit-title-for="${issueId}"]`);
|
|
933
|
+
if (el) el.focus();
|
|
934
|
+
}, 50);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function renderEditForm(issue) {
|
|
939
|
+
return `
|
|
940
|
+
<div class="edit-form">
|
|
941
|
+
<div class="create-form-grid">
|
|
942
|
+
<div class="form-group span-2">
|
|
943
|
+
<label>Title</label>
|
|
944
|
+
<input data-edit-title-for="${escapeHtml(issue.id)}" type="text" value="${escapeHtml(issue.title)}" />
|
|
945
|
+
</div>
|
|
946
|
+
<div class="form-group span-2">
|
|
947
|
+
<label>Description</label>
|
|
948
|
+
<textarea data-edit-desc-for="${escapeHtml(issue.id)}" rows="2">${escapeHtml(issue.description || "")}</textarea>
|
|
949
|
+
</div>
|
|
950
|
+
<div class="form-group">
|
|
951
|
+
<label>Priority (1-10)</label>
|
|
952
|
+
<input data-edit-priority-for="${escapeHtml(issue.id)}" type="number" min="1" max="10" value="${escapeHtml(issue.priority)}" />
|
|
953
|
+
</div>
|
|
954
|
+
<div class="form-group">
|
|
955
|
+
<label>Labels <span class="hint">comma-separated</span></label>
|
|
956
|
+
<input data-edit-labels-for="${escapeHtml(issue.id)}" type="text" value="${escapeHtml((issue.labels || []).join(", "))}" />
|
|
957
|
+
</div>
|
|
958
|
+
<div class="form-group span-2">
|
|
959
|
+
<label>Paths <span class="hint">comma-separated</span></label>
|
|
960
|
+
<input data-edit-paths-for="${escapeHtml(issue.id)}" type="text" value="${escapeHtml((issue.paths || []).join(", "))}" />
|
|
961
|
+
</div>
|
|
962
|
+
<div class="form-group span-2">
|
|
963
|
+
<label>Blocked by <span class="hint">comma-separated issue IDs</span></label>
|
|
964
|
+
<input data-edit-blocked-for="${escapeHtml(issue.id)}" type="text" value="${escapeHtml((issue.blockedBy || []).join(", "))}" />
|
|
965
|
+
</div>
|
|
966
|
+
</div>
|
|
967
|
+
<div class="create-form-actions">
|
|
968
|
+
<button type="button" class="action-button" data-id="${escapeHtml(issue.id)}" data-action="edit-cancel">Cancel</button>
|
|
969
|
+
<button type="button" class="action-button btn-accent" data-id="${escapeHtml(issue.id)}" data-action="edit-submit">Save</button>
|
|
970
|
+
</div>
|
|
971
|
+
</div>
|
|
972
|
+
`;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
async function submitEdit(issueId, target) {
|
|
976
|
+
const get = (attr) => document.querySelector(`[data-edit-${attr}-for="${issueId}"]`);
|
|
977
|
+
const titleEl = get("title");
|
|
978
|
+
if (!titleEl) return;
|
|
979
|
+
|
|
980
|
+
await withLoading(target, async () => {
|
|
981
|
+
try {
|
|
982
|
+
const response = await fetch(`/api/issues/${encodeURIComponent(issueId)}`, {
|
|
983
|
+
method: "PUT",
|
|
984
|
+
headers: { "content-type": "application/json" },
|
|
985
|
+
body: JSON.stringify({
|
|
986
|
+
title: titleEl.value.trim() || undefined,
|
|
987
|
+
description: get("desc")?.value.trim() ?? "",
|
|
988
|
+
priority: Math.max(1, Math.min(10, parseInt(get("priority")?.value, 10) || 1)),
|
|
989
|
+
labels: (get("labels")?.value || "").split(",").map((s) => s.trim()).filter(Boolean),
|
|
990
|
+
paths: (get("paths")?.value || "").split(",").map((s) => s.trim()).filter(Boolean),
|
|
991
|
+
blockedBy: (get("blocked")?.value || "").split(",").map((s) => s.trim()).filter(Boolean),
|
|
992
|
+
}),
|
|
993
|
+
});
|
|
994
|
+
if (!response.ok) throw new Error(`Failed: ${response.status}`);
|
|
995
|
+
activeEditId = null;
|
|
996
|
+
showToast("Issue updated", "success");
|
|
997
|
+
await loadState();
|
|
998
|
+
} catch (e) {
|
|
999
|
+
showToast(`Edit failed: ${e.message}`);
|
|
1000
|
+
}
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function requestDelete(issueId) {
|
|
1005
|
+
pendingDeleteId = pendingDeleteId === issueId ? null : issueId;
|
|
1006
|
+
renderIssues(appState.issues || []);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
async function confirmDelete(issueId, target) {
|
|
1010
|
+
await withLoading(target, async () => {
|
|
1011
|
+
try {
|
|
1012
|
+
const response = await fetch(`/api/issues/${encodeURIComponent(issueId)}`, { method: "DELETE" });
|
|
1013
|
+
if (!response.ok) throw new Error(`Failed: ${response.status}`);
|
|
1014
|
+
const issue = (appState.issues || []).find((i) => i.id === issueId);
|
|
1015
|
+
pendingDeleteId = null;
|
|
1016
|
+
if (selectedDetailId === issueId) clearDetailPanel();
|
|
1017
|
+
showToast(`Deleted ${issue?.identifier || issueId}`, "success");
|
|
1018
|
+
await loadState();
|
|
1019
|
+
} catch (e) {
|
|
1020
|
+
pendingDeleteId = null;
|
|
1021
|
+
showToast(`Delete failed: ${e.message}`);
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// ── Wire Actions ─────────────────────────────────────────────────────────────
|
|
1027
|
+
|
|
1028
|
+
function wireActions() {
|
|
1029
|
+
issueListEl.addEventListener("click", async (event) => {
|
|
1030
|
+
const target = event.target;
|
|
1031
|
+
if (!(target instanceof HTMLButtonElement)) return;
|
|
1032
|
+
|
|
1033
|
+
const action = target.dataset.action;
|
|
1034
|
+
const id = target.dataset.id;
|
|
1035
|
+
const payload = target.dataset.payload || "";
|
|
1036
|
+
if (!action || !id) return;
|
|
1037
|
+
|
|
1038
|
+
try {
|
|
1039
|
+
if (action === "state") await setIssueState(id, payload, target);
|
|
1040
|
+
else if (action === "retry") await retryIssue(id, target);
|
|
1041
|
+
else if (action === "cancel") await cancelIssue(id, target);
|
|
1042
|
+
else if (action === "sessions") {
|
|
1043
|
+
if (isDesktop()) {
|
|
1044
|
+
// Desktop: show in detail panel
|
|
1045
|
+
if (selectedDetailId === id) {
|
|
1046
|
+
clearDetailPanel();
|
|
1047
|
+
} else {
|
|
1048
|
+
selectedDetailId = id;
|
|
1049
|
+
const issue = (appState.issues || []).find((i) => i.id === id);
|
|
1050
|
+
if (issue) renderDetailPanel(issue);
|
|
1051
|
+
}
|
|
1052
|
+
renderIssues(appState.issues || []);
|
|
1053
|
+
} else {
|
|
1054
|
+
// Mobile: inline toggle
|
|
1055
|
+
if (expandedSessions.has(id)) expandedSessions.delete(id);
|
|
1056
|
+
else expandedSessions.add(id);
|
|
1057
|
+
renderIssues(appState.issues || []);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
else if (action === "more") {
|
|
1061
|
+
// Toggle secondary actions visibility
|
|
1062
|
+
const secondary = target.closest(".actions")?.querySelector(`[data-secondary-for="${id}"]`);
|
|
1063
|
+
if (secondary) {
|
|
1064
|
+
secondary.classList.toggle("actions-secondary-visible");
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
else if (action === "split") toggleSplitForm(id);
|
|
1068
|
+
else if (action === "split-submit") await submitSplit(id, target);
|
|
1069
|
+
else if (action === "split-cancel") { activeSplitId = null; renderIssues(appState.issues || []); }
|
|
1070
|
+
else if (action === "note") await addNote(id, target);
|
|
1071
|
+
else if (action === "edit") toggleEditForm(id);
|
|
1072
|
+
else if (action === "edit-submit") await submitEdit(id, target);
|
|
1073
|
+
else if (action === "edit-cancel") { activeEditId = null; renderIssues(appState.issues || []); }
|
|
1074
|
+
else if (action === "delete") requestDelete(id);
|
|
1075
|
+
else if (action === "delete-confirm") await confirmDelete(id, target);
|
|
1076
|
+
else if (action === "delete-cancel") { pendingDeleteId = null; renderIssues(appState.issues || []); }
|
|
1077
|
+
} catch (error) {
|
|
1078
|
+
showToast(error.message || "Action failed.");
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
// Checkbox selection for batch actions
|
|
1083
|
+
issueListEl.addEventListener("change", (event) => {
|
|
1084
|
+
const checkbox = event.target;
|
|
1085
|
+
if (!checkbox.dataset.selectIssue) return;
|
|
1086
|
+
const id = checkbox.dataset.selectIssue;
|
|
1087
|
+
if (checkbox.checked) selectedIssues.add(id);
|
|
1088
|
+
else selectedIssues.delete(id);
|
|
1089
|
+
// Re-render just the count/batch toolbar without full re-render
|
|
1090
|
+
const countEl = document.getElementById("issue-count");
|
|
1091
|
+
if (countEl && selectedIssues.size > 0) {
|
|
1092
|
+
countEl.innerHTML = `<span class="batch-info">${selectedIssues.size} selected</span> `
|
|
1093
|
+
+ `<button type="button" class="action-button" id="batch-retry">Retry All</button> `
|
|
1094
|
+
+ `<button type="button" class="action-button" id="batch-cancel">Cancel All</button> `
|
|
1095
|
+
+ `<button type="button" class="action-button" id="batch-clear">Clear</button>`;
|
|
1096
|
+
} else if (countEl) {
|
|
1097
|
+
const issues = appState.issues || [];
|
|
1098
|
+
countEl.textContent = `${issues.length} issues`;
|
|
1099
|
+
}
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
// Batch action buttons (delegated from issue-count container's parent)
|
|
1103
|
+
document.addEventListener("click", async (event) => {
|
|
1104
|
+
const target = event.target;
|
|
1105
|
+
if (target.id === "batch-retry") {
|
|
1106
|
+
const ids = [...selectedIssues];
|
|
1107
|
+
for (const id of ids) {
|
|
1108
|
+
try { await post(`/api/issue/${encodeURIComponent(id)}/retry`); } catch {}
|
|
1109
|
+
}
|
|
1110
|
+
selectedIssues.clear();
|
|
1111
|
+
showToast(`Retried ${ids.length} issues`, "success");
|
|
1112
|
+
await loadState();
|
|
1113
|
+
} else if (target.id === "batch-cancel") {
|
|
1114
|
+
const ids = [...selectedIssues];
|
|
1115
|
+
for (const id of ids) {
|
|
1116
|
+
try { await post(`/api/issue/${encodeURIComponent(id)}/cancel`); } catch {}
|
|
1117
|
+
}
|
|
1118
|
+
selectedIssues.clear();
|
|
1119
|
+
showToast(`Cancelled ${ids.length} issues`, "success");
|
|
1120
|
+
await loadState();
|
|
1121
|
+
} else if (target.id === "batch-clear") {
|
|
1122
|
+
selectedIssues.clear();
|
|
1123
|
+
renderIssues(appState.issues || []);
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
issueListEl.addEventListener("keydown", (event) => {
|
|
1128
|
+
if (event.key === "Enter" && event.target.dataset.noteFor) {
|
|
1129
|
+
addNote(event.target.dataset.noteFor);
|
|
1130
|
+
}
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
rerunBtn?.addEventListener("click", () => loadState());
|
|
1134
|
+
clearEventsBtn?.addEventListener("click", () => {
|
|
1135
|
+
allEvents = [];
|
|
1136
|
+
lastEventTimestamp = "";
|
|
1137
|
+
eventsEl.innerHTML = '<p class="muted">Event history cleared.</p>';
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
newIssueBtn?.addEventListener("click", toggleCreateForm);
|
|
1141
|
+
document.getElementById("cf-cancel")?.addEventListener("click", () => { createForm.hidden = true; });
|
|
1142
|
+
document.getElementById("cf-submit")?.addEventListener("click", (e) => submitCreateForm(e.target));
|
|
1143
|
+
document.getElementById("cf-title")?.addEventListener("keydown", (e) => {
|
|
1144
|
+
if (e.key === "Enter") submitCreateForm(document.getElementById("cf-submit"));
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// ── Data Loading ─────────────────────────────────────────────────────────────
|
|
1149
|
+
|
|
1150
|
+
async function loadEvents() {
|
|
1151
|
+
try {
|
|
1152
|
+
const params = new URLSearchParams();
|
|
1153
|
+
if (lastEventTimestamp) params.set("since", lastEventTimestamp);
|
|
1154
|
+
if (eventKindFilter?.value && eventKindFilter.value !== "all") params.set("kind", eventKindFilter.value);
|
|
1155
|
+
if (eventIssueFilter?.value && eventIssueFilter.value !== "all") params.set("issueId", eventIssueFilter.value);
|
|
1156
|
+
const query = params.size > 0 ? `?${params.toString()}` : "";
|
|
1157
|
+
const payload = await fetchJSON(`/api/events${query}`);
|
|
1158
|
+
const events = Array.isArray(payload.events) ? payload.events : [];
|
|
1159
|
+
|
|
1160
|
+
if (events.length > 0) {
|
|
1161
|
+
const latest = events[0];
|
|
1162
|
+
if (latest && latest.at) lastEventTimestamp = latest.at;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
renderEvents(events);
|
|
1166
|
+
} catch (error) {
|
|
1167
|
+
// ignore intermittent polling errors
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
async function loadState() {
|
|
1172
|
+
const payload = await fetchJSON("/api/state");
|
|
1173
|
+
|
|
1174
|
+
// Diff: skip re-render if nothing changed
|
|
1175
|
+
const hash = simpleHash(JSON.stringify(payload.issues) + JSON.stringify(payload.metrics));
|
|
1176
|
+
if (hash === lastStateHash) {
|
|
1177
|
+
refreshBadge.textContent = `refresh: ${new Date().toLocaleTimeString()}`;
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
lastStateHash = hash;
|
|
1181
|
+
|
|
1182
|
+
appState = payload;
|
|
1183
|
+
const issues = Array.isArray(payload.issues) ? payload.issues : [];
|
|
1184
|
+
if (eventIssueFilter) {
|
|
1185
|
+
const previousValue = eventIssueFilter.value || "all";
|
|
1186
|
+
const options = [
|
|
1187
|
+
{ value: "all", label: "All" },
|
|
1188
|
+
...issues
|
|
1189
|
+
.map((issue) => ({ value: issue.id, label: issue.identifier || issue.id }))
|
|
1190
|
+
.filter((entry) => entry.value)
|
|
1191
|
+
.sort((a, b) => String(a.label).localeCompare(String(b.label))),
|
|
1192
|
+
];
|
|
1193
|
+
eventIssueFilter.innerHTML = options
|
|
1194
|
+
.map((entry) => `<option value="${escapeHtml(entry.value)}">${escapeHtml(entry.label)}</option>`)
|
|
1195
|
+
.join("");
|
|
1196
|
+
eventIssueFilter.value = options.some((entry) => entry.value === previousValue) ? previousValue : "all";
|
|
1197
|
+
}
|
|
1198
|
+
renderOverview(payload.metrics || {}, issues);
|
|
1199
|
+
renderIssues(issues);
|
|
1200
|
+
renderRuntimeMeta(payload);
|
|
1201
|
+
|
|
1202
|
+
const sourceRepo = (payload.sourceRepoUrl || "local").toString().split("/").slice(-1)[0] || "local";
|
|
1203
|
+
subtitle.textContent = `Runtime local: ${sourceRepo}`;
|
|
1204
|
+
refreshBadge.textContent = `refresh: ${new Date(payload.updatedAt || Date.now()).toLocaleTimeString()}`;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
async function loadHealth() {
|
|
1208
|
+
try {
|
|
1209
|
+
const payload = await fetchJSON("/api/health");
|
|
1210
|
+
const status = payload.status || "ok";
|
|
1211
|
+
healthBadge.textContent = `status: ${status}`;
|
|
1212
|
+
healthBadge.className = `badge badge-health-${status === "ok" ? "ok" : "warn"}`;
|
|
1213
|
+
|
|
1214
|
+
// Notify on status transitions
|
|
1215
|
+
if (lastHealthStatus && lastHealthStatus !== status) {
|
|
1216
|
+
showToast(`Health: ${lastHealthStatus} → ${status}`, status === "ok" ? "success" : "warn", 3000);
|
|
1217
|
+
}
|
|
1218
|
+
lastHealthStatus = status;
|
|
1219
|
+
} catch (error) {
|
|
1220
|
+
healthBadge.textContent = "status: offline";
|
|
1221
|
+
healthBadge.className = "badge badge-health-offline";
|
|
1222
|
+
if (lastHealthStatus !== "offline") {
|
|
1223
|
+
showToast("Connection lost", "error", 3000);
|
|
1224
|
+
}
|
|
1225
|
+
lastHealthStatus = "offline";
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
async function refreshSessions() {
|
|
1230
|
+
// Auto-refresh expanded session panels for running issues
|
|
1231
|
+
for (const issueId of expandedSessions) {
|
|
1232
|
+
const issue = (appState.issues || []).find((i) => i.id === issueId);
|
|
1233
|
+
if (issue && (issue.state === "In Progress" || issue.state === "In Review")) {
|
|
1234
|
+
loadSessionsForIssue(issueId);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
// Auto-refresh detail panel for running issues
|
|
1238
|
+
if (selectedDetailId && isDesktop()) {
|
|
1239
|
+
const issue = (appState.issues || []).find((i) => i.id === selectedDetailId);
|
|
1240
|
+
if (issue && (issue.state === "In Progress" || issue.state === "In Review")) {
|
|
1241
|
+
loadSessionsForPanel(selectedDetailId, "detail-session-panel");
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
async function refresh() {
|
|
1247
|
+
refreshBadge.classList.add("badge-refreshing");
|
|
1248
|
+
try {
|
|
1249
|
+
await loadState();
|
|
1250
|
+
await loadEvents();
|
|
1251
|
+
await refreshSessions();
|
|
1252
|
+
} catch (error) {
|
|
1253
|
+
issueListEl.innerHTML = `<p class="muted">Error loading runtime state: ${escapeHtml(error.message || error)}</p>`;
|
|
1254
|
+
} finally {
|
|
1255
|
+
refreshBadge.classList.remove("badge-refreshing");
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// ── Filters ──────────────────────────────────────────────────────────────────
|
|
1260
|
+
|
|
1261
|
+
stateFilter.addEventListener("change", () => {
|
|
1262
|
+
// Clear KPI active state when manually changing filters
|
|
1263
|
+
activeKpiFilter = null;
|
|
1264
|
+
renderOverview(appState.metrics || {}, appState.issues || []);
|
|
1265
|
+
renderIssues(appState.issues || []);
|
|
1266
|
+
});
|
|
1267
|
+
categoryFilter?.addEventListener("change", () => {
|
|
1268
|
+
activeKpiFilter = null;
|
|
1269
|
+
renderOverview(appState.metrics || {}, appState.issues || []);
|
|
1270
|
+
renderIssues(appState.issues || []);
|
|
1271
|
+
});
|
|
1272
|
+
queryInput.addEventListener("input", () => renderIssues(appState.issues || []));
|
|
1273
|
+
eventKindFilter?.addEventListener("change", async () => {
|
|
1274
|
+
allEvents = [];
|
|
1275
|
+
lastEventTimestamp = "";
|
|
1276
|
+
await loadEvents();
|
|
1277
|
+
});
|
|
1278
|
+
eventIssueFilter?.addEventListener("change", async () => {
|
|
1279
|
+
allEvents = [];
|
|
1280
|
+
lastEventTimestamp = "";
|
|
1281
|
+
await loadEvents();
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
// ── Keyboard shortcuts ───────────────────────────────────────────────────
|
|
1285
|
+
|
|
1286
|
+
document.addEventListener("keydown", (event) => {
|
|
1287
|
+
// Skip keyboard nav if typing in an input/textarea
|
|
1288
|
+
const tag = document.activeElement?.tagName;
|
|
1289
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") {
|
|
1290
|
+
if (event.key === "Escape") {
|
|
1291
|
+
document.activeElement.blur();
|
|
1292
|
+
}
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// j/k or ArrowDown/ArrowUp to navigate issues
|
|
1297
|
+
if (event.key === "j" || event.key === "ArrowDown" || event.key === "k" || event.key === "ArrowUp") {
|
|
1298
|
+
const cards = [...issueListEl.querySelectorAll(".issue-card")];
|
|
1299
|
+
if (!cards.length) return;
|
|
1300
|
+
const currentIdx = cards.findIndex((c) => c.dataset.issueId === selectedDetailId);
|
|
1301
|
+
let nextIdx;
|
|
1302
|
+
if (event.key === "j" || event.key === "ArrowDown") {
|
|
1303
|
+
nextIdx = currentIdx < cards.length - 1 ? currentIdx + 1 : 0;
|
|
1304
|
+
} else {
|
|
1305
|
+
nextIdx = currentIdx > 0 ? currentIdx - 1 : cards.length - 1;
|
|
1306
|
+
}
|
|
1307
|
+
const nextId = cards[nextIdx]?.dataset.issueId;
|
|
1308
|
+
if (nextId) {
|
|
1309
|
+
selectedDetailId = nextId;
|
|
1310
|
+
const issue = (appState.issues || []).find((i) => i.id === nextId);
|
|
1311
|
+
if (issue && isDesktop()) renderDetailPanel(issue);
|
|
1312
|
+
renderIssues(appState.issues || []);
|
|
1313
|
+
cards[nextIdx]?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
1314
|
+
}
|
|
1315
|
+
event.preventDefault();
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// Enter to toggle sessions for current selection
|
|
1320
|
+
if (event.key === "Enter" && selectedDetailId) {
|
|
1321
|
+
if (!isDesktop()) {
|
|
1322
|
+
if (expandedSessions.has(selectedDetailId)) expandedSessions.delete(selectedDetailId);
|
|
1323
|
+
else expandedSessions.add(selectedDetailId);
|
|
1324
|
+
renderIssues(appState.issues || []);
|
|
1325
|
+
}
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// r to retry selected
|
|
1330
|
+
if (event.key === "r" && selectedDetailId) {
|
|
1331
|
+
retryIssue(selectedDetailId);
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// n to focus new issue form
|
|
1336
|
+
if (event.key === "n") {
|
|
1337
|
+
toggleCreateForm();
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
if (event.key !== "Escape") return;
|
|
1342
|
+
|
|
1343
|
+
// Close create form
|
|
1344
|
+
if (!createForm.hidden) {
|
|
1345
|
+
createForm.hidden = true;
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// Close edit form
|
|
1350
|
+
if (activeEditId !== null) {
|
|
1351
|
+
activeEditId = null;
|
|
1352
|
+
renderIssues(appState.issues || []);
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// Close delete confirm
|
|
1357
|
+
if (pendingDeleteId !== null) {
|
|
1358
|
+
pendingDeleteId = null;
|
|
1359
|
+
renderIssues(appState.issues || []);
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// Close split form
|
|
1364
|
+
if (activeSplitId !== null) {
|
|
1365
|
+
activeSplitId = null;
|
|
1366
|
+
renderIssues(appState.issues || []);
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// Close detail panel
|
|
1371
|
+
if (selectedDetailId !== null) {
|
|
1372
|
+
clearDetailPanel();
|
|
1373
|
+
renderIssues(appState.issues || []);
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// Collapse all sessions
|
|
1378
|
+
if (expandedSessions.size > 0) {
|
|
1379
|
+
expandedSessions.clear();
|
|
1380
|
+
renderIssues(appState.issues || []);
|
|
1381
|
+
}
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1384
|
+
// ── Boot ─────────────────────────────────────────────────────────────────────
|
|
1385
|
+
|
|
1386
|
+
loadEvents();
|
|
1387
|
+
wireActions();
|
|
1388
|
+
loadHealth();
|
|
1389
|
+
refresh();
|
|
1390
|
+
setInterval(() => { refresh(); loadHealth(); }, 3000);
|