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.
@@ -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, "&lt;")
65
+ .replace(/>/g, "&gt;")
66
+ .replace(/"/g, "&quot;")
67
+ .replace(/'/g, "&#39;");
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">&middot;&middot;&middot;</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">&times;</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);