acdev 1.0.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/public/app.js ADDED
@@ -0,0 +1,3291 @@
1
+ /** @typedef {'overview' | 'runs' | 'review' | 'logs' | 'alerts' | 'settings'} ViewId */
2
+
3
+ const VIEW_TITLES = {
4
+ overview: 'Overview',
5
+ runs: 'Runs',
6
+ review: 'Review',
7
+ logs: 'Logs',
8
+ alerts: 'Alerts',
9
+ settings: 'Settings',
10
+ };
11
+
12
+ const DEFAULT_KNOWN_TOOLS = ['Read', 'Glob', 'Grep', 'Edit', 'Write', 'Bash'];
13
+
14
+ const RUNNING_STATUSES = new Set([
15
+ 'syncing',
16
+ 'preparing_worktree',
17
+ 'running',
18
+ 'applying_feedback',
19
+ ]);
20
+ const RUN_STATUSES = new Set([
21
+ 'syncing',
22
+ 'preparing_worktree',
23
+ 'running',
24
+ 'applying_feedback',
25
+ 'awaiting_review',
26
+ 'pr_opened',
27
+ 'failed',
28
+ ]);
29
+ const CLEARABLE_STATUSES = new Set([
30
+ 'queued',
31
+ 'awaiting_review',
32
+ 'pr_opened',
33
+ 'discarded',
34
+ 'failed',
35
+ ]);
36
+
37
+ const PIPELINE_KEYS = [
38
+ 'queued',
39
+ 'syncing',
40
+ 'preparing_worktree',
41
+ 'running',
42
+ 'applying_feedback',
43
+ 'awaiting_review',
44
+ ];
45
+
46
+ const STATUS_LABELS = {
47
+ queued: 'Queued',
48
+ syncing: 'Syncing base branch…',
49
+ preparing_worktree: 'Preparing worktree…',
50
+ running: 'Agent running…',
51
+ applying_feedback: 'Applying review feedback…',
52
+ awaiting_review: 'Awaiting review',
53
+ pr_opened: 'PR opened',
54
+ discarded: 'Discarded',
55
+ failed: 'Failed',
56
+ 'retry queued': 'Retry queued',
57
+ };
58
+
59
+ const THEME_KEY = 'acdev-theme';
60
+ const DISMISS_KEY = 'acdev-dismissed-alerts';
61
+ const REVIEW_FILTER_KEY = 'acdev-review-filter';
62
+ const REVIEW_SEARCH_KEY = 'acdev-review-search';
63
+ const REVIEW_DRAFT_PREFIX = 'acdev-review-draft:';
64
+ const REVIEW_FILES_PREFIX = 'acdev-review-files:';
65
+
66
+ /** Review-pipeline statuses shown on the Review page. */
67
+ const REVIEW_STATUSES = new Set([
68
+ 'awaiting_review',
69
+ 'pr_opened',
70
+ 'discarded',
71
+ 'failed',
72
+ ]);
73
+
74
+ /** @typedef {'all' | 'pending' | 'approved' | 'rejected' | 'failed'} ReviewFilterId */
75
+
76
+ const REVIEW_FILTERS = {
77
+ all: { label: 'All', statuses: null },
78
+ pending: { label: 'Pending', statuses: new Set(['awaiting_review']) },
79
+ approved: { label: 'Approved', statuses: new Set(['pr_opened']) },
80
+ rejected: { label: 'Rejected', statuses: new Set(['discarded']) },
81
+ failed: { label: 'Failed', statuses: new Set(['failed']) },
82
+ };
83
+
84
+ /** @type {ViewId} */
85
+ let currentView = 'overview';
86
+ /** @type {string | null} */
87
+ let selectedRunId = null;
88
+ /** @type {string | null} */
89
+ let selectedReviewId = null;
90
+ /** @type {EventSource | null} */
91
+ let eventSource = null;
92
+ /** Follow live logs only while the user is near the bottom of the pane. */
93
+ let logsPinnedToBottom = true;
94
+ /** Scroll offset restored across log pane rebuilds when unpinned. */
95
+ let logsScrollTop = 0;
96
+ const LOG_PIN_THRESHOLD_PX = 100;
97
+ /** @type {Record<string, object>} */
98
+ let jobsById = {};
99
+ /** @type {string[]} */
100
+ let dismissedAlertIds = loadDismissed();
101
+ /** @type {string} */
102
+ let logFilterJobId = '';
103
+ /** @type {ReviewFilterId} */
104
+ let reviewFilter = loadReviewFilter();
105
+ /** @type {string} */
106
+ let reviewSearch = loadReviewSearch();
107
+ /** @type {string | null} */
108
+ let activeInlineCommentKey = null;
109
+ /** @type {Record<string, {
110
+ * generalComment: string,
111
+ * lineComments: Array<{ id: string, path: string, line: number, side: 'LEFT' | 'RIGHT', body: string }>
112
+ * }>} */
113
+ let reviewDrafts = {};
114
+ /** @type {Record<string, string[]>} changed file paths per job (from API or parsed diff) */
115
+ let reviewFileLists = {};
116
+ /** @type {Record<string, Set<string>>} excluded paths per job while reviewing */
117
+ let reviewExcludedByJob = {};
118
+ let modelSaving = false;
119
+ let settingsSaving = false;
120
+ /** @type {string} */
121
+ let currentModel = 'claude-sonnet-5';
122
+ /** @type {'github' | 'jira'} */
123
+ let ticketSource = 'github';
124
+ /** @type {{
125
+ * repoName?: string,
126
+ * baseBranch?: string,
127
+ * testCommand?: string | null,
128
+ * maxAgentTurns?: number,
129
+ * agentTimeoutMs?: number,
130
+ * allowedTools?: string[],
131
+ * knownTools?: string[],
132
+ * models?: Array<{id:string,label:string}>,
133
+ * model?: string,
134
+ * ticketSource?: 'github' | 'jira',
135
+ * jiraBaseUrl?: string,
136
+ * jiraEmail?: string | null,
137
+ * jiraApiTokenSet?: boolean,
138
+ * jiraApiTokenMasked?: string | null,
139
+ * jiraConfigured?: boolean,
140
+ * jiraPrLinkPhrase?: string,
141
+ * }} */
142
+ let appConfig = {};
143
+
144
+ const els = {
145
+ app: document.getElementById('app'),
146
+ viewTitle: document.getElementById('view-title'),
147
+ themeToggle: document.getElementById('theme-toggle'),
148
+ repoName: document.getElementById('repo-name'),
149
+ repoBranch: document.getElementById('repo-branch'),
150
+ ticketSourceMeta: document.getElementById('ticket-source-meta'),
151
+ modelToggle: document.getElementById('model-toggle'),
152
+ navBadgeOverview: document.getElementById('nav-badge-overview'),
153
+ navBadgeRuns: document.getElementById('nav-badge-runs'),
154
+ navBadgeReview: document.getElementById('nav-badge-review'),
155
+ navBadgeAlerts: document.getElementById('nav-badge-alerts'),
156
+ issueUrls: document.getElementById('issue-urls'),
157
+ issueUrlsLabel: document.getElementById('issue-urls-label'),
158
+ enqueueFeedback: document.getElementById('enqueue-feedback'),
159
+ enqueueHint: document.getElementById('enqueue-hint'),
160
+ addBtn: document.getElementById('add-btn'),
161
+ statsGrid: document.getElementById('stats-grid'),
162
+ overviewRunning: document.getElementById('overview-running'),
163
+ overviewRunningEmpty: document.getElementById('overview-running-empty'),
164
+ overviewReview: document.getElementById('overview-review'),
165
+ overviewReviewEmpty: document.getElementById('overview-review-empty'),
166
+ overviewQueue: document.getElementById('overview-queue'),
167
+ overviewQueueEmpty: document.getElementById('overview-queue-empty'),
168
+ runsList: document.getElementById('runs-list'),
169
+ runsEmpty: document.getElementById('runs-empty'),
170
+ reviewEmpty: document.getElementById('review-empty'),
171
+ reviewNoMatches: document.getElementById('review-no-matches'),
172
+ reviewToolbar: document.getElementById('review-toolbar'),
173
+ reviewFilters: document.getElementById('review-filters'),
174
+ reviewSearch: document.getElementById('review-search'),
175
+ reviewLayout: document.getElementById('review-layout'),
176
+ reviewList: document.getElementById('review-list'),
177
+ reviewEmptyDetail: document.getElementById('review-empty-detail'),
178
+ reviewDetailContent: document.getElementById('review-detail-content'),
179
+ reviewActions: document.getElementById('review-actions'),
180
+ reviewFeedbackSection: document.getElementById('review-feedback-section'),
181
+ reviewGeneralComment: document.getElementById('review-general-comment'),
182
+ reviewPendingList: document.getElementById('review-pending-list'),
183
+ reviewPendingEmpty: document.getElementById('review-pending-empty'),
184
+ submitReviewBtn: document.getElementById('submit-review-btn'),
185
+ reviewPrSection: document.getElementById('review-pr-section'),
186
+ reviewPrLink: document.getElementById('review-pr-link'),
187
+ reviewClearBtn: document.getElementById('review-clear-btn'),
188
+ reviewPrClearBtn: document.getElementById('review-pr-clear-btn'),
189
+ reviewTerminalSection: document.getElementById('review-terminal-section'),
190
+ reviewTerminalMessage: document.getElementById('review-terminal-message'),
191
+ reviewRetryBtn: document.getElementById('review-retry-btn'),
192
+ reviewTerminalClearBtn: document.getElementById('review-terminal-clear-btn'),
193
+ prTitle: document.getElementById('pr-title'),
194
+ prBody: document.getElementById('pr-body'),
195
+ reviewFilesSection: document.getElementById('review-files-section'),
196
+ reviewFilesList: document.getElementById('review-files-list'),
197
+ reviewFilesCount: document.getElementById('review-files-count'),
198
+ reviewFilesSelectAll: document.getElementById('review-files-select-all'),
199
+ reviewFilesDeselectAll: document.getElementById('review-files-deselect-all'),
200
+ diffHeader: document.getElementById('diff-header'),
201
+ diffViewer: document.getElementById('diff-viewer'),
202
+ approveDraftBtn: document.getElementById('approve-draft-btn'),
203
+ approveReadyBtn: document.getElementById('approve-ready-btn'),
204
+ rejectBtn: document.getElementById('reject-btn'),
205
+ logJobFilter: document.getElementById('log-job-filter'),
206
+ activityLog: document.getElementById('activity-log'),
207
+ logEmpty: document.getElementById('log-empty'),
208
+ alertsList: document.getElementById('alerts-list'),
209
+ alertsEmpty: document.getElementById('alerts-empty'),
210
+ settingsForm: document.getElementById('settings-form'),
211
+ settingsBaseBranch: document.getElementById('settings-base-branch'),
212
+ settingsModel: document.getElementById('settings-model'),
213
+ settingsMaxTurns: document.getElementById('settings-max-turns'),
214
+ settingsTimeout: document.getElementById('settings-timeout'),
215
+ settingsTimeoutMs: document.getElementById('settings-timeout-ms'),
216
+ settingsTestCommand: document.getElementById('settings-test-command'),
217
+ settingsTools: document.getElementById('settings-tools'),
218
+ settingsFeedback: document.getElementById('settings-feedback'),
219
+ settingsSaveBtn: document.getElementById('settings-save-btn'),
220
+ settingsTicketSource: document.getElementById('settings-ticket-source'),
221
+ jiraConnectPanel: document.getElementById('jira-connect-panel'),
222
+ settingsJiraBaseUrl: document.getElementById('settings-jira-base-url'),
223
+ settingsJiraEmail: document.getElementById('settings-jira-email'),
224
+ settingsJiraToken: document.getElementById('settings-jira-token'),
225
+ settingsJiraTokenHint: document.getElementById('settings-jira-token-hint'),
226
+ settingsJiraPrPhrase: document.getElementById('settings-jira-pr-phrase'),
227
+ jiraTestBtn: document.getElementById('jira-test-btn'),
228
+ jiraStatus: document.getElementById('jira-status'),
229
+ settingsJiraRuleEnabled: document.getElementById('settings-jira-rule-enabled'),
230
+ settingsJiraRuleStatus: document.getElementById('settings-jira-rule-status'),
231
+ settingsGithubRuleEnabled: document.getElementById('settings-github-rule-enabled'),
232
+ settingsGithubRuleAction: document.getElementById('settings-github-rule-action'),
233
+ settingsGithubRuleLabel: document.getElementById('settings-github-rule-label'),
234
+ settingsGithubRuleLabelField: document.getElementById('settings-github-rule-label-field'),
235
+ jiraRulesPanel: document.getElementById('jira-rules-panel'),
236
+ githubRulesPanel: document.getElementById('github-rules-panel'),
237
+ };
238
+
239
+ function loadDismissed() {
240
+ try {
241
+ const raw = localStorage.getItem(DISMISS_KEY);
242
+ const parsed = raw ? JSON.parse(raw) : [];
243
+ return Array.isArray(parsed) ? parsed.map(String) : [];
244
+ } catch {
245
+ return [];
246
+ }
247
+ }
248
+
249
+ function saveDismissed() {
250
+ localStorage.setItem(DISMISS_KEY, JSON.stringify(dismissedAlertIds));
251
+ }
252
+
253
+ /** @returns {ReviewFilterId} */
254
+ function loadReviewFilter() {
255
+ try {
256
+ const raw = sessionStorage.getItem(REVIEW_FILTER_KEY);
257
+ if (raw && raw in REVIEW_FILTERS) return /** @type {ReviewFilterId} */ (raw);
258
+ } catch {
259
+ /* ignore */
260
+ }
261
+ return 'pending';
262
+ }
263
+
264
+ function saveReviewFilter() {
265
+ try {
266
+ sessionStorage.setItem(REVIEW_FILTER_KEY, reviewFilter);
267
+ } catch {
268
+ /* ignore */
269
+ }
270
+ }
271
+
272
+ function loadReviewSearch() {
273
+ try {
274
+ return sessionStorage.getItem(REVIEW_SEARCH_KEY) || '';
275
+ } catch {
276
+ return '';
277
+ }
278
+ }
279
+
280
+ function saveReviewSearch() {
281
+ try {
282
+ sessionStorage.setItem(REVIEW_SEARCH_KEY, reviewSearch);
283
+ } catch {
284
+ /* ignore */
285
+ }
286
+ }
287
+
288
+ /** @param {string} jobId */
289
+ function reviewDraftStorageKey(jobId) {
290
+ return `${REVIEW_DRAFT_PREFIX}${jobId}`;
291
+ }
292
+
293
+ /**
294
+ * @param {string} jobId
295
+ * @returns {{
296
+ * generalComment: string,
297
+ * lineComments: Array<{ id: string, path: string, line: number, side: 'LEFT' | 'RIGHT', body: string }>
298
+ * }}
299
+ */
300
+ function emptyReviewDraft() {
301
+ return { generalComment: '', lineComments: [] };
302
+ }
303
+
304
+ /** @param {string} jobId */
305
+ function loadReviewDraft(jobId) {
306
+ if (reviewDrafts[jobId]) return reviewDrafts[jobId];
307
+ try {
308
+ const raw = sessionStorage.getItem(reviewDraftStorageKey(jobId));
309
+ if (raw) {
310
+ const parsed = JSON.parse(raw);
311
+ const draft = {
312
+ generalComment:
313
+ typeof parsed?.generalComment === 'string' ? parsed.generalComment : '',
314
+ lineComments: Array.isArray(parsed?.lineComments)
315
+ ? parsed.lineComments
316
+ .filter(
317
+ (c) =>
318
+ c &&
319
+ typeof c.path === 'string' &&
320
+ typeof c.body === 'string' &&
321
+ (c.side === 'LEFT' || c.side === 'RIGHT') &&
322
+ Number.isInteger(Number(c.line))
323
+ )
324
+ .map((c) => ({
325
+ id: typeof c.id === 'string' ? c.id : `lc-${Date.now()}-${Math.random()}`,
326
+ path: c.path,
327
+ line: Number(c.line),
328
+ side: /** @type {'LEFT' | 'RIGHT'} */ (c.side),
329
+ body: c.body,
330
+ }))
331
+ : [],
332
+ };
333
+ reviewDrafts[jobId] = draft;
334
+ return draft;
335
+ }
336
+ } catch {
337
+ /* ignore */
338
+ }
339
+ const draft = emptyReviewDraft();
340
+ reviewDrafts[jobId] = draft;
341
+ return draft;
342
+ }
343
+
344
+ /** @param {string} jobId */
345
+ function saveReviewDraft(jobId) {
346
+ const draft = reviewDrafts[jobId] || emptyReviewDraft();
347
+ reviewDrafts[jobId] = draft;
348
+ try {
349
+ sessionStorage.setItem(reviewDraftStorageKey(jobId), JSON.stringify(draft));
350
+ } catch {
351
+ /* ignore */
352
+ }
353
+ }
354
+
355
+ /** @param {string} jobId */
356
+ function clearReviewDraft(jobId) {
357
+ delete reviewDrafts[jobId];
358
+ activeInlineCommentKey = null;
359
+ try {
360
+ sessionStorage.removeItem(reviewDraftStorageKey(jobId));
361
+ } catch {
362
+ /* ignore */
363
+ }
364
+ }
365
+
366
+ /** @param {string} jobId */
367
+ function reviewFilesStorageKey(jobId) {
368
+ return `${REVIEW_FILES_PREFIX}${jobId}`;
369
+ }
370
+
371
+ /**
372
+ * @param {string} jobId
373
+ * @returns {Set<string>}
374
+ */
375
+ function loadExcludedPaths(jobId) {
376
+ if (reviewExcludedByJob[jobId]) return reviewExcludedByJob[jobId];
377
+ try {
378
+ const raw = sessionStorage.getItem(reviewFilesStorageKey(jobId));
379
+ if (raw) {
380
+ const parsed = JSON.parse(raw);
381
+ const list = Array.isArray(parsed?.excludedPaths)
382
+ ? parsed.excludedPaths.filter((p) => typeof p === 'string' && p.trim())
383
+ : [];
384
+ reviewExcludedByJob[jobId] = new Set(list.map((p) => p.trim()));
385
+ return reviewExcludedByJob[jobId];
386
+ }
387
+ } catch {
388
+ /* ignore */
389
+ }
390
+ reviewExcludedByJob[jobId] = new Set();
391
+ return reviewExcludedByJob[jobId];
392
+ }
393
+
394
+ /** @param {string} jobId */
395
+ function saveExcludedPaths(jobId) {
396
+ const set = reviewExcludedByJob[jobId] || new Set();
397
+ try {
398
+ sessionStorage.setItem(
399
+ reviewFilesStorageKey(jobId),
400
+ JSON.stringify({ excludedPaths: [...set] })
401
+ );
402
+ } catch {
403
+ /* ignore */
404
+ }
405
+ }
406
+
407
+ /** @param {string} jobId */
408
+ function clearExcludedPaths(jobId) {
409
+ delete reviewExcludedByJob[jobId];
410
+ try {
411
+ sessionStorage.removeItem(reviewFilesStorageKey(jobId));
412
+ } catch {
413
+ /* ignore */
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Extract changed file paths from a unified diff (`diff --git a/… b/…`).
419
+ * @param {string} diff
420
+ * @returns {string[]}
421
+ */
422
+ function parseChangedFilesFromDiff(diff) {
423
+ const seen = new Set();
424
+ /** @type {string[]} */
425
+ const paths = [];
426
+ for (const line of String(diff || '').split('\n')) {
427
+ if (!line.startsWith('diff --git ')) continue;
428
+ const m = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
429
+ if (!m) continue;
430
+ const aPath = m[1];
431
+ const bPath = m[2];
432
+ const chosen =
433
+ bPath && bPath !== '/dev/null' ? bPath : aPath && aPath !== '/dev/null' ? aPath : null;
434
+ if (!chosen || seen.has(chosen)) continue;
435
+ seen.add(chosen);
436
+ paths.push(chosen);
437
+ }
438
+ return paths;
439
+ }
440
+
441
+ /**
442
+ * Keep only unified-diff file sections whose path is in `included`.
443
+ * @param {string} diff
444
+ * @param {Set<string> | string[]} included
445
+ * @returns {string}
446
+ */
447
+ function filterDiffByIncludedPaths(diff, included) {
448
+ const allow = included instanceof Set ? included : new Set(included);
449
+ if (!diff || allow.size === 0) return '';
450
+ const lines = String(diff).split('\n');
451
+ /** @type {string[]} */
452
+ const out = [];
453
+ let keep = true;
454
+ let sawFile = false;
455
+
456
+ for (const line of lines) {
457
+ if (line.startsWith('diff --git ')) {
458
+ sawFile = true;
459
+ const m = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
460
+ const aPath = m?.[1];
461
+ const bPath = m?.[2];
462
+ const chosen =
463
+ bPath && bPath !== '/dev/null'
464
+ ? bPath
465
+ : aPath && aPath !== '/dev/null'
466
+ ? aPath
467
+ : null;
468
+ keep = Boolean(chosen && allow.has(chosen));
469
+ if (keep) out.push(line);
470
+ continue;
471
+ }
472
+ if (!sawFile || keep) out.push(line);
473
+ }
474
+ return out.join('\n');
475
+ }
476
+
477
+ /**
478
+ * @param {string} jobId
479
+ * @returns {string[]}
480
+ */
481
+ function getReviewFilePaths(jobId) {
482
+ if (reviewFileLists[jobId]?.length) return reviewFileLists[jobId];
483
+ const job = jobsById[jobId];
484
+ return parseChangedFilesFromDiff(job?.diff || '');
485
+ }
486
+
487
+ /**
488
+ * @param {string} jobId
489
+ * @param {string[]} files
490
+ */
491
+ function setReviewFilePaths(jobId, files) {
492
+ reviewFileLists[jobId] = [...files];
493
+ }
494
+
495
+ /**
496
+ * @param {string} jobId
497
+ * @returns {string[]}
498
+ */
499
+ function getExcludedPathsForApprove(jobId) {
500
+ const files = getReviewFilePaths(jobId);
501
+ const excluded = loadExcludedPaths(jobId);
502
+ return files.filter((p) => excluded.has(p));
503
+ }
504
+
505
+ /** @param {string} jobId */
506
+ function reviewDraftHasContent(jobId) {
507
+ const draft = loadReviewDraft(jobId);
508
+ if (draft.generalComment.trim()) return true;
509
+ return draft.lineComments.some((c) => c.body.trim());
510
+ }
511
+
512
+ /** @param {string} path @param {number} line @param {'LEFT' | 'RIGHT'} side */
513
+ function lineCommentKey(path, line, side) {
514
+ return `${side}:${path}:${line}`;
515
+ }
516
+
517
+ function loadTheme() {
518
+ try {
519
+ const t = localStorage.getItem(THEME_KEY);
520
+ return t === 'dark' || t === 'light' ? t : 'light';
521
+ } catch {
522
+ return 'light';
523
+ }
524
+ }
525
+
526
+ function applyTheme(theme) {
527
+ els.app.dataset.theme = theme;
528
+ localStorage.setItem(THEME_KEY, theme);
529
+ const isDark = theme === 'dark';
530
+ els.themeToggle.querySelector('.theme-icon-moon')?.classList.toggle('hidden', isDark);
531
+ els.themeToggle.querySelector('.theme-icon-sun')?.classList.toggle('hidden', !isDark);
532
+ }
533
+
534
+ function statusLabel(status) {
535
+ return String(status || '').replace(/_/g, ' ');
536
+ }
537
+
538
+ function parseIssueRef(url) {
539
+ const match = String(url || '').match(/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/i);
540
+ if (!match) return null;
541
+ return { owner: match[1], repo: match[2], number: match[3], full: `${match[1]}/${match[2]}` };
542
+ }
543
+
544
+ function parseJiraKeyFromJob(job) {
545
+ if (job?.jiraKey) return String(job.jiraKey).toUpperCase();
546
+ const url = String(job?.issueUrl || '');
547
+ const browse = url.match(/\/browse\/([A-Z][A-Z0-9]+-\d+)/i);
548
+ if (browse) return browse[1].toUpperCase();
549
+ if (/^[A-Z][A-Z0-9]+-\d+$/i.test(url.trim())) return url.trim().toUpperCase();
550
+ return null;
551
+ }
552
+
553
+ function isJiraJob(job) {
554
+ return job?.ticketSource === 'jira' || Boolean(job?.jiraKey) || Boolean(parseJiraKeyFromJob(job));
555
+ }
556
+
557
+ function jobTicketLabel(job) {
558
+ if (isJiraJob(job)) {
559
+ return parseJiraKeyFromJob(job) || 'Jira';
560
+ }
561
+ const ref = parseIssueRef(job.issueUrl);
562
+ if (ref) return `#${ref.number}`;
563
+ if (job.issueNumber != null) return `#${job.issueNumber}`;
564
+ return '—';
565
+ }
566
+
567
+ function jobTitle(job) {
568
+ return job.issueTitle || truncate(job.issueUrl, 48);
569
+ }
570
+
571
+ function jobRepoLine(job) {
572
+ if (isJiraJob(job)) {
573
+ const key = parseJiraKeyFromJob(job) || '—';
574
+ const branch = job.branchName ? ` · ${job.branchName}` : '';
575
+ return { repo: 'Jira', number: key, branch, text: `Jira ${key}${branch}`, isJira: true };
576
+ }
577
+ const ref = parseIssueRef(job.issueUrl);
578
+ const repo = ref?.full || '—';
579
+ const num = ref?.number || job.issueNumber || '—';
580
+ const branch = job.branchName ? ` · ${job.branchName}` : '';
581
+ return { repo, number: num, branch, text: `${repo} #${num}${branch}`, isJira: false };
582
+ }
583
+
584
+ function jobSubLine(job) {
585
+ const meta = jobRepoLine(job);
586
+ if (meta.isJira) {
587
+ return `${meta.number}${meta.branch}`;
588
+ }
589
+ return `${meta.repo} #${meta.number}${meta.branch}`;
590
+ }
591
+
592
+ function issueBadge(job) {
593
+ const t = job.issueType === 'feat' ? 'feature' : job.issueType === 'fix' ? 'bug' : null;
594
+ if (!t) return null;
595
+ return t;
596
+ }
597
+
598
+ function truncate(str, max = 40) {
599
+ if (!str || str.length <= max) return str || '';
600
+ return str.slice(0, max - 3) + '...';
601
+ }
602
+
603
+ function splitIssueUrls(text) {
604
+ return String(text || '')
605
+ .split(/[\n,]+/)
606
+ .map((u) => u.trim())
607
+ .filter(Boolean);
608
+ }
609
+
610
+ function sortedJobs() {
611
+ return Object.values(jobsById).sort(
612
+ (a, b) => new Date(b.createdAt) - new Date(a.createdAt)
613
+ );
614
+ }
615
+
616
+ function formatTime(ts) {
617
+ try {
618
+ return new Date(ts).toLocaleTimeString([], {
619
+ hour: '2-digit',
620
+ minute: '2-digit',
621
+ second: '2-digit',
622
+ hour12: false,
623
+ });
624
+ } catch {
625
+ return '';
626
+ }
627
+ }
628
+
629
+ function formatElapsed(job) {
630
+ const start = new Date(job.createdAt || job.updatedAt || Date.now()).getTime();
631
+ const end =
632
+ job.status === 'failed' ||
633
+ job.status === 'awaiting_review' ||
634
+ job.status === 'pr_opened' ||
635
+ job.status === 'discarded'
636
+ ? new Date(job.updatedAt || job.createdAt).getTime()
637
+ : Date.now();
638
+ const sec = Math.max(0, Math.floor((end - start) / 1000));
639
+ const m = Math.floor(sec / 60);
640
+ const s = sec % 60;
641
+ return `${m}m ${String(s).padStart(2, '0')}s`;
642
+ }
643
+
644
+ /** @param {number | undefined | null} n */
645
+ function formatUsd(n) {
646
+ if (typeof n !== 'number' || !Number.isFinite(n)) return null;
647
+ if (n > 0 && n < 0.01) return `$${n.toFixed(4)}`;
648
+ return `$${n.toFixed(2)}`;
649
+ }
650
+
651
+ /** @param {number | undefined | null} n */
652
+ function formatTokenCount(n) {
653
+ if (typeof n !== 'number' || !Number.isFinite(n)) return null;
654
+ if (n >= 1_000_000) {
655
+ const m = n / 1_000_000;
656
+ return `${m >= 10 ? m.toFixed(0) : m.toFixed(1)}M`;
657
+ }
658
+ if (n >= 1000) {
659
+ const k = n / 1000;
660
+ return `${k >= 100 ? k.toFixed(0) : k.toFixed(1)}k`;
661
+ }
662
+ return String(Math.round(n));
663
+ }
664
+
665
+ /** @param {number | undefined | null} ms */
666
+ function formatDurationMs(ms) {
667
+ if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return null;
668
+ const sec = Math.floor(ms / 1000);
669
+ const m = Math.floor(sec / 60);
670
+ const s = sec % 60;
671
+ if (m <= 0) return `${s}s`;
672
+ return `${m}m ${s}s`;
673
+ }
674
+
675
+ /**
676
+ * Compact chip for Overview rows: `$1.17 · 18.5k tok`
677
+ * @param {object | null | undefined} usage
678
+ */
679
+ function formatUsageCompact(usage) {
680
+ if (!usage) return null;
681
+ const parts = [];
682
+ const cost = formatUsd(usage.totalCostUsd);
683
+ if (cost) parts.push(cost);
684
+ const inTok = usage.inputTokens || 0;
685
+ const outTok = usage.outputTokens || 0;
686
+ const total = inTok + outTok;
687
+ if (total > 0) {
688
+ const t = formatTokenCount(total);
689
+ if (t) parts.push(`${t} tok`);
690
+ }
691
+ return parts.length ? parts.join(' · ') : null;
692
+ }
693
+
694
+ /**
695
+ * Full line for Runs: `$1.17 · 12.4k in / 6.1k out · 27 turns · 2m 16s`
696
+ * @param {object | null | undefined} usage
697
+ */
698
+ function formatUsageDetail(usage) {
699
+ if (!usage) return null;
700
+ const parts = [];
701
+ const cost = formatUsd(usage.totalCostUsd);
702
+ if (cost) parts.push(cost);
703
+
704
+ const inTok = formatTokenCount(usage.inputTokens);
705
+ const outTok = formatTokenCount(usage.outputTokens);
706
+ if (inTok || outTok) {
707
+ parts.push(`${inTok || '0'} in / ${outTok || '0'} out`);
708
+ }
709
+
710
+ if (typeof usage.numTurns === 'number' && Number.isFinite(usage.numTurns)) {
711
+ parts.push(`${usage.numTurns} turn${usage.numTurns === 1 ? '' : 's'}`);
712
+ }
713
+
714
+ const dur = formatDurationMs(usage.durationMs);
715
+ if (dur) parts.push(dur);
716
+
717
+ return parts.length ? parts.join(' · ') : null;
718
+ }
719
+
720
+ /** Sum cost / tokens across jobs that have usage (this app's jobs only). */
721
+ function aggregateJobUsage(jobs) {
722
+ let totalCostUsd = 0;
723
+ let totalTokens = 0;
724
+ let withCost = 0;
725
+ let withTokens = 0;
726
+ for (const job of jobs) {
727
+ const u = job.usage;
728
+ if (!u) continue;
729
+ if (typeof u.totalCostUsd === 'number' && Number.isFinite(u.totalCostUsd)) {
730
+ totalCostUsd += u.totalCostUsd;
731
+ withCost += 1;
732
+ }
733
+ const tok = (u.inputTokens || 0) + (u.outputTokens || 0);
734
+ if (tok > 0) {
735
+ totalTokens += tok;
736
+ withTokens += 1;
737
+ }
738
+ }
739
+ return { totalCostUsd, totalTokens, withCost, withTokens };
740
+ }
741
+
742
+ /** Five progress dots: sync → worktree → agent → review → PR */
743
+ function pipelineStates(job) {
744
+ if (job.status === 'pr_opened') {
745
+ return ['done', 'done', 'done', 'done', 'done'];
746
+ }
747
+ if (job.status === 'awaiting_review') {
748
+ return ['done', 'done', 'done', 'pending', 'pending'];
749
+ }
750
+ if (job.status === 'queued') {
751
+ return ['active', 'pending', 'pending', 'pending', 'pending'];
752
+ }
753
+ if (job.status === 'syncing') {
754
+ return ['done', 'active', 'pending', 'pending', 'pending'];
755
+ }
756
+ if (job.status === 'preparing_worktree') {
757
+ return ['done', 'done', 'active', 'pending', 'pending'];
758
+ }
759
+ if (job.status === 'running') {
760
+ return ['done', 'done', 'active', 'pending', 'pending'];
761
+ }
762
+ if (job.status === 'failed') {
763
+ const order = PIPELINE_KEYS;
764
+ let idx = Math.max(
765
+ 0,
766
+ order.findIndex((k) =>
767
+ (job.logs || []).some((l) => l.type === 'status' && l.payload === k)
768
+ )
769
+ );
770
+ if (idx < 0) idx = 0;
771
+ return [0, 1, 2, 3, 4].map((i) => (i < idx ? 'done' : i === idx ? 'active' : 'pending'));
772
+ }
773
+ return ['pending', 'pending', 'pending', 'pending', 'pending'];
774
+ }
775
+
776
+ function currentStepLabel(job) {
777
+ if (job.status === 'failed') return job.error || 'failed';
778
+ if (job.status === 'awaiting_review') return 'awaiting review';
779
+ if (job.status === 'pr_opened') return 'PR opened';
780
+ const logs = job.logs || [];
781
+ for (let i = logs.length - 1; i >= 0; i--) {
782
+ const formatted = formatLogEvent(logs[i]);
783
+ if (formatted.kind === 'tool' || formatted.kind === 'thinking' || formatted.kind === 'msg') {
784
+ return truncate(formatted.text, 72);
785
+ }
786
+ }
787
+ return STATUS_LABELS[job.status] || statusLabel(job.status);
788
+ }
789
+
790
+ function formatRawPayload(value) {
791
+ if (value === undefined) return '';
792
+ if (typeof value === 'string') return value;
793
+ try {
794
+ return JSON.stringify(value, null, 2);
795
+ } catch {
796
+ return String(value);
797
+ }
798
+ }
799
+
800
+ function summarizeToolInput(input) {
801
+ if (!input || typeof input !== 'object') return '';
802
+ if (typeof input.command === 'string' && input.command) {
803
+ return truncate(input.command, 80);
804
+ }
805
+ if (typeof input.pattern === 'string' && input.pattern) {
806
+ const scope =
807
+ (typeof input.path === 'string' && input.path) ||
808
+ (typeof input.glob === 'string' && input.glob) ||
809
+ '';
810
+ return truncate(scope ? `${input.pattern} · ${scope}` : input.pattern, 80);
811
+ }
812
+ const pathKeys = ['file_path', 'path', 'file', 'filename', 'target_file', 'notebook_path'];
813
+ for (const key of pathKeys) {
814
+ if (typeof input[key] === 'string' && input[key]) {
815
+ return truncate(String(input[key]), 72);
816
+ }
817
+ }
818
+ if (typeof input.query === 'string' && input.query) return truncate(input.query, 72);
819
+ if (typeof input.url === 'string' && input.url) return truncate(input.url, 72);
820
+ if (typeof input.prompt === 'string' && input.prompt) return truncate(input.prompt, 72);
821
+ if (typeof input.description === 'string' && input.description) {
822
+ return truncate(input.description, 72);
823
+ }
824
+ if (typeof input.content === 'string' && input.content) {
825
+ return truncate(input.content.replace(/\s+/g, ' '), 60);
826
+ }
827
+ const keys = Object.keys(input);
828
+ if (keys.length === 0) return '';
829
+ return truncate(JSON.stringify(input), 80);
830
+ }
831
+
832
+ function formatToolUseLine(block) {
833
+ const name = block.name || block.tool || 'tool';
834
+ const summary = summarizeToolInput(block.input);
835
+ return summary ? `${name} · ${summary}` : name;
836
+ }
837
+
838
+ function summarizeContentBlocks(content) {
839
+ if (!Array.isArray(content)) return [];
840
+ /** @type {{ kind: string, text: string }[]} */
841
+ const lines = [];
842
+ for (const block of content) {
843
+ if (!block || typeof block !== 'object') continue;
844
+ const type = block.type;
845
+ if (type === 'text' && typeof block.text === 'string') {
846
+ const text = block.text.trim();
847
+ if (text) lines.push({ kind: 'msg', text: truncate(text.replace(/\s+/g, ' '), 220) });
848
+ } else if (type === 'thinking' || type === 'reasoning') {
849
+ const raw =
850
+ (typeof block.thinking === 'string' && block.thinking) ||
851
+ (typeof block.text === 'string' && block.text) ||
852
+ '';
853
+ const text = String(raw).trim();
854
+ if (text) {
855
+ lines.push({ kind: 'thinking', text: truncate(text.replace(/\s+/g, ' '), 180) });
856
+ }
857
+ } else if (type === 'tool_use') {
858
+ lines.push({ kind: 'tool', text: formatToolUseLine(block) });
859
+ } else if (type === 'tool_result') {
860
+ const preview =
861
+ typeof block.content === 'string'
862
+ ? block.content
863
+ : Array.isArray(block.content)
864
+ ? block.content
865
+ .map((c) => (c?.type === 'text' ? c.text : JSON.stringify(c)))
866
+ .filter(Boolean)
867
+ .join(' ')
868
+ : formatRawPayload(block.content);
869
+ const isErr = Boolean(block.is_error);
870
+ lines.push({
871
+ kind: isErr ? 'error' : 'result',
872
+ text: truncate(
873
+ (isErr ? 'Tool error · ' : 'Tool result · ') +
874
+ String(preview || '').replace(/\s+/g, ' ').trim(),
875
+ 200
876
+ ),
877
+ });
878
+ }
879
+ }
880
+ return lines;
881
+ }
882
+
883
+ function formatLogEvent(event) {
884
+ const raw = formatRawPayload(
885
+ event.type === 'agent_event' ? event.payload : event.payload ?? event
886
+ );
887
+
888
+ if (event.type === 'status') {
889
+ const key = String(event.payload || '');
890
+ const text = STATUS_LABELS[key] || statusLabel(key) || key || 'Status update';
891
+ return { kind: 'status', label: 'Status', text, raw };
892
+ }
893
+
894
+ if (event.type === 'error') {
895
+ return {
896
+ kind: 'error',
897
+ label: 'Error',
898
+ text: String(event.payload || 'Unknown error'),
899
+ raw,
900
+ };
901
+ }
902
+
903
+ if (event.type === 'agent_event') {
904
+ const msg = event.payload;
905
+ if (msg && typeof msg === 'object') {
906
+ if (msg.type === 'assistant' && Array.isArray(msg.message?.content)) {
907
+ const parts = summarizeContentBlocks(msg.message.content);
908
+ if (parts.length) {
909
+ const hasTool = parts.some((p) => p.kind === 'tool');
910
+ const hasThinking = parts.some((p) => p.kind === 'thinking');
911
+ const hasText = parts.some((p) => p.kind === 'msg');
912
+ const kind = hasTool ? 'tool' : hasThinking ? 'thinking' : parts[0].kind;
913
+ const label = hasTool
914
+ ? hasText || hasThinking
915
+ ? 'Assistant'
916
+ : 'Tool'
917
+ : hasThinking
918
+ ? 'Thinking'
919
+ : 'Assistant';
920
+ return { kind, label, text: parts.map((p) => p.text).join(' · '), raw };
921
+ }
922
+ return { kind: 'msg', label: 'Assistant', text: '(empty message)', raw };
923
+ }
924
+
925
+ if (msg.type === 'user' && Array.isArray(msg.message?.content)) {
926
+ const parts = summarizeContentBlocks(msg.message.content);
927
+ if (parts.length) {
928
+ const kind = parts[0].kind === 'error' ? 'error' : 'result';
929
+ return {
930
+ kind,
931
+ label: kind === 'error' ? 'Tool error' : 'Result',
932
+ text: parts.map((p) => p.text).join(' · '),
933
+ raw,
934
+ };
935
+ }
936
+ }
937
+
938
+ if (msg.type === 'result') {
939
+ const ok = msg.subtype === 'success';
940
+ const detail =
941
+ (typeof msg.result === 'string' && msg.result.trim()) ||
942
+ (typeof msg.error === 'string' && msg.error) ||
943
+ msg.subtype ||
944
+ '';
945
+ const usageHint = formatUsageCompact({
946
+ totalCostUsd: msg.total_cost_usd,
947
+ inputTokens: msg.usage?.input_tokens,
948
+ outputTokens: msg.usage?.output_tokens,
949
+ });
950
+ const base = ok
951
+ ? truncate(String(detail).replace(/\s+/g, ' '), 180) || 'Agent finished successfully'
952
+ : truncate(String(detail || 'Agent failed').replace(/\s+/g, ' '), 200);
953
+ return {
954
+ kind: ok ? 'success' : 'error',
955
+ label: ok ? 'Done' : 'Failed',
956
+ text: usageHint ? `${base} · ${usageHint}` : base,
957
+ raw,
958
+ };
959
+ }
960
+
961
+ if (msg.type === 'system') {
962
+ const subtype = msg.subtype ? ` (${msg.subtype})` : '';
963
+ return {
964
+ kind: 'status',
965
+ label: 'System',
966
+ text: truncate(
967
+ (typeof msg.content === 'string' && msg.content) || `System event${subtype}`,
968
+ 180
969
+ ),
970
+ raw,
971
+ };
972
+ }
973
+
974
+ if (msg.type === 'tool_use' || msg.name) {
975
+ return { kind: 'tool', label: 'Tool', text: formatToolUseLine(msg), raw };
976
+ }
977
+
978
+ if (msg.type === 'stream_event' && msg.event) {
979
+ const ev = msg.event;
980
+ if (ev.type === 'content_block_start' && ev.content_block?.type === 'tool_use') {
981
+ return {
982
+ kind: 'tool',
983
+ label: 'Tool',
984
+ text: formatToolUseLine(ev.content_block),
985
+ raw,
986
+ };
987
+ }
988
+ return {
989
+ kind: 'msg',
990
+ label: 'Stream',
991
+ text: truncate(ev.type || 'stream event', 120),
992
+ raw,
993
+ };
994
+ }
995
+ }
996
+
997
+ if (typeof msg === 'string') {
998
+ return { kind: 'msg', label: 'Event', text: truncate(msg, 220), raw };
999
+ }
1000
+
1001
+ return {
1002
+ kind: 'msg',
1003
+ label: 'Event',
1004
+ text: truncate(typeof msg?.type === 'string' ? String(msg.type) : JSON.stringify(msg), 200),
1005
+ raw,
1006
+ };
1007
+ }
1008
+
1009
+ return {
1010
+ kind: 'msg',
1011
+ label: 'Event',
1012
+ text: truncate(JSON.stringify(event), 200),
1013
+ raw: formatRawPayload(event),
1014
+ };
1015
+ }
1016
+
1017
+ function buildLogRow(event) {
1018
+ const { kind, label, text, raw } = formatLogEvent(event);
1019
+ const row = document.createElement('div');
1020
+ row.className = `log-line is-${kind}`;
1021
+
1022
+ const ts = document.createElement('span');
1023
+ ts.className = 'log-ts';
1024
+ ts.textContent = formatTime(event.ts);
1025
+
1026
+ const body = document.createElement('div');
1027
+ body.className = 'log-body';
1028
+
1029
+ const primary = document.createElement('div');
1030
+ primary.className = 'log-primary';
1031
+
1032
+ const kindEl = document.createElement('span');
1033
+ kindEl.className = 'log-kind';
1034
+ kindEl.textContent = label;
1035
+
1036
+ const msg = document.createElement('span');
1037
+ msg.className = 'log-msg';
1038
+ msg.textContent = text;
1039
+
1040
+ primary.appendChild(kindEl);
1041
+ primary.appendChild(msg);
1042
+ body.appendChild(primary);
1043
+
1044
+ if (raw && raw.trim()) {
1045
+ const details = document.createElement('details');
1046
+ details.className = 'log-raw';
1047
+ const summary = document.createElement('summary');
1048
+ summary.textContent = 'Raw';
1049
+ const pre = document.createElement('pre');
1050
+ pre.className = 'log-raw-pre';
1051
+ pre.textContent = raw;
1052
+ details.appendChild(summary);
1053
+ details.appendChild(pre);
1054
+ body.appendChild(details);
1055
+ }
1056
+
1057
+ row.appendChild(ts);
1058
+ row.appendChild(body);
1059
+ return row;
1060
+ }
1061
+
1062
+ function diffStats(diff) {
1063
+ let additions = 0;
1064
+ let deletions = 0;
1065
+ const files = new Set();
1066
+ if (!diff) return { additions, deletions, files: 0 };
1067
+ for (const line of diff.split('\n')) {
1068
+ if (line.startsWith('+++ ') || line.startsWith('--- ')) {
1069
+ const path = line.slice(4).replace(/^[ab]\//, '').trim();
1070
+ if (path && path !== '/dev/null') files.add(path);
1071
+ } else if (line.startsWith('+') && !line.startsWith('+++')) {
1072
+ additions += 1;
1073
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
1074
+ deletions += 1;
1075
+ }
1076
+ }
1077
+ return { additions, deletions, files: files.size || (diff ? 1 : 0) };
1078
+ }
1079
+
1080
+ /**
1081
+ * Render unified diff as side-by-side column panes (GitHub-style) with optional
1082
+ * line-comment anchors. Each file uses `.diff-split` with left|right panes;
1083
+ * horizontal scroll syncs across panes of the same `.diff-file` only.
1084
+ * @param {string} diff
1085
+ * @param {{ interactive?: boolean, jobId?: string | null }} [opts]
1086
+ */
1087
+ function renderDiff(diff, opts = {}) {
1088
+ const interactive = Boolean(opts.interactive && opts.jobId);
1089
+ const jobId = opts.jobId || null;
1090
+ const draft = jobId ? loadReviewDraft(jobId) : emptyReviewDraft();
1091
+
1092
+ els.diffViewer.innerHTML = '';
1093
+ els.diffViewer.className = 'diff-viewer amcp-scroll';
1094
+ if (!diff) {
1095
+ const empty = document.createElement('div');
1096
+ empty.className = 'diff-hunk-meta';
1097
+ empty.textContent = 'No diff available.';
1098
+ els.diffViewer.appendChild(empty);
1099
+ return;
1100
+ }
1101
+
1102
+ /** @type {HTMLElement | null} */
1103
+ let fileEl = null;
1104
+ /** @type {HTMLElement | null} */
1105
+ let splitEl = null;
1106
+ /** @type {HTMLElement | null} */
1107
+ let leftPane = null;
1108
+ /** @type {HTMLElement | null} */
1109
+ let rightPane = null;
1110
+ let leftNo = 0;
1111
+ let rightNo = 0;
1112
+ let currentPath = '';
1113
+
1114
+ const flushSplit = () => {
1115
+ splitEl = null;
1116
+ leftPane = null;
1117
+ rightPane = null;
1118
+ };
1119
+
1120
+ const ensureFile = () => {
1121
+ if (!fileEl) {
1122
+ fileEl = document.createElement('div');
1123
+ fileEl.className = 'diff-file';
1124
+ els.diffViewer.appendChild(fileEl);
1125
+ }
1126
+ };
1127
+
1128
+ const ensureSplit = () => {
1129
+ ensureFile();
1130
+ if (!splitEl || !leftPane || !rightPane) {
1131
+ splitEl = document.createElement('div');
1132
+ splitEl.className = 'diff-split';
1133
+ leftPane = document.createElement('div');
1134
+ leftPane.className = 'diff-pane diff-pane-left';
1135
+ rightPane = document.createElement('div');
1136
+ rightPane.className = 'diff-pane diff-pane-right';
1137
+ splitEl.appendChild(leftPane);
1138
+ splitEl.appendChild(rightPane);
1139
+ fileEl.appendChild(splitEl);
1140
+ }
1141
+ };
1142
+
1143
+ /**
1144
+ * @param {HTMLElement} cell
1145
+ * @param {string} path
1146
+ * @param {number} line
1147
+ * @param {'LEFT' | 'RIGHT'} side
1148
+ */
1149
+ const makeCommentable = (cell, path, line, side) => {
1150
+ if (!interactive || !path || !line) return;
1151
+ cell.classList.add('commentable');
1152
+ cell.dataset.path = path;
1153
+ cell.dataset.line = String(line);
1154
+ cell.dataset.side = side;
1155
+
1156
+ const plus = document.createElement('button');
1157
+ plus.type = 'button';
1158
+ plus.className = 'diff-add-comment';
1159
+ plus.title = 'Add line comment';
1160
+ plus.setAttribute('aria-label', `Add comment on ${path}:${line}`);
1161
+ plus.textContent = '+';
1162
+ plus.addEventListener('click', (e) => {
1163
+ e.stopPropagation();
1164
+ activeInlineCommentKey = lineCommentKey(path, line, side);
1165
+ renderDiff(diff, opts);
1166
+ renderPendingCommentList(jobId);
1167
+ syncSubmitReviewButton(jobId);
1168
+ });
1169
+ cell.appendChild(plus);
1170
+
1171
+ cell.addEventListener('click', (e) => {
1172
+ if (e.target.closest('button, textarea, a')) return;
1173
+ activeInlineCommentKey = lineCommentKey(path, line, side);
1174
+ renderDiff(diff, opts);
1175
+ renderPendingCommentList(jobId);
1176
+ syncSubmitReviewButton(jobId);
1177
+ });
1178
+ };
1179
+
1180
+ /**
1181
+ * @param {string | null | undefined} text
1182
+ * @param {string} cls
1183
+ * @param {number | ''} num
1184
+ * @param {'LEFT' | 'RIGHT'} side
1185
+ */
1186
+ const createLine = (text, cls, num, side) => {
1187
+ const el = document.createElement('div');
1188
+ const placeholder = !num;
1189
+ el.className = ['diff-line', cls || '', placeholder ? 'diff-line-empty' : '']
1190
+ .filter(Boolean)
1191
+ .join(' ');
1192
+ const ln = document.createElement('span');
1193
+ ln.className = 'diff-ln';
1194
+ ln.textContent = num ? String(num) : '';
1195
+ el.appendChild(ln);
1196
+ el.appendChild(document.createTextNode(text ?? ''));
1197
+
1198
+ if (!placeholder && interactive && currentPath && num) {
1199
+ makeCommentable(el, currentPath, num, side);
1200
+ }
1201
+ return el;
1202
+ };
1203
+
1204
+ /**
1205
+ * @param {string} path
1206
+ * @param {number} line
1207
+ * @param {'LEFT' | 'RIGHT'} side
1208
+ * @param {Array<{ id: string, path: string, line: number, side: string, body: string }>} existing
1209
+ */
1210
+ const appendThread = (path, line, side, existing) => {
1211
+ if (!interactive || !fileEl) return;
1212
+ const key = lineCommentKey(path, line, side);
1213
+ const showForm = activeInlineCommentKey === key;
1214
+ if (!showForm && existing.length === 0) return;
1215
+
1216
+ // Full-width thread between split segments (keeps left|right row heights aligned).
1217
+ flushSplit();
1218
+
1219
+ const thread = document.createElement('div');
1220
+ thread.className = 'diff-inline-thread';
1221
+
1222
+ const meta = document.createElement('div');
1223
+ meta.className = 'diff-inline-meta';
1224
+ meta.textContent = `${path}:${line} · ${side}`;
1225
+ thread.appendChild(meta);
1226
+
1227
+ for (const c of existing) {
1228
+ const chip = document.createElement('div');
1229
+ chip.className = 'diff-pending-chip';
1230
+ const body = document.createElement('div');
1231
+ body.className = 'diff-pending-chip-body';
1232
+ body.textContent = c.body;
1233
+ const actions = document.createElement('div');
1234
+ actions.className = 'diff-pending-chip-actions';
1235
+ const remove = document.createElement('button');
1236
+ remove.type = 'button';
1237
+ remove.className = 'btn btn-muted-text btn-sm';
1238
+ remove.textContent = 'Remove';
1239
+ remove.addEventListener('click', () => {
1240
+ const d = loadReviewDraft(jobId);
1241
+ d.lineComments = d.lineComments.filter((x) => x.id !== c.id);
1242
+ saveReviewDraft(jobId);
1243
+ renderDiff(diff, opts);
1244
+ renderPendingCommentList(jobId);
1245
+ syncSubmitReviewButton(jobId);
1246
+ });
1247
+ actions.appendChild(remove);
1248
+ chip.appendChild(body);
1249
+ chip.appendChild(actions);
1250
+ thread.appendChild(chip);
1251
+ }
1252
+
1253
+ if (showForm) {
1254
+ const form = document.createElement('div');
1255
+ form.className = 'diff-inline-form';
1256
+ const ta = document.createElement('textarea');
1257
+ ta.className = 'input textarea';
1258
+ ta.rows = 3;
1259
+ ta.placeholder = 'Leave a comment on this line…';
1260
+ const actions = document.createElement('div');
1261
+ actions.className = 'diff-inline-actions';
1262
+ const save = document.createElement('button');
1263
+ save.type = 'button';
1264
+ save.className = 'btn btn-primary btn-sm';
1265
+ save.textContent = 'Add comment';
1266
+ const cancel = document.createElement('button');
1267
+ cancel.type = 'button';
1268
+ cancel.className = 'btn btn-secondary btn-sm';
1269
+ cancel.textContent = 'Cancel';
1270
+ cancel.addEventListener('click', () => {
1271
+ activeInlineCommentKey = null;
1272
+ renderDiff(diff, opts);
1273
+ });
1274
+ save.addEventListener('click', () => {
1275
+ const body = ta.value.trim();
1276
+ if (!body) return;
1277
+ const d = loadReviewDraft(jobId);
1278
+ d.lineComments.push({
1279
+ id: `lc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
1280
+ path,
1281
+ line,
1282
+ side,
1283
+ body,
1284
+ });
1285
+ saveReviewDraft(jobId);
1286
+ activeInlineCommentKey = null;
1287
+ renderDiff(diff, opts);
1288
+ renderPendingCommentList(jobId);
1289
+ syncSubmitReviewButton(jobId);
1290
+ });
1291
+ actions.appendChild(save);
1292
+ actions.appendChild(cancel);
1293
+ form.appendChild(ta);
1294
+ form.appendChild(actions);
1295
+ thread.appendChild(form);
1296
+ requestAnimationFrame(() => ta.focus());
1297
+ }
1298
+
1299
+ fileEl.appendChild(thread);
1300
+ };
1301
+
1302
+ const addPair = (left, right, leftCls, rightCls, lNum, rNum) => {
1303
+ ensureSplit();
1304
+ if (!leftPane || !rightPane) return;
1305
+ leftPane.appendChild(createLine(left, leftCls, lNum, 'LEFT'));
1306
+ rightPane.appendChild(createLine(right, rightCls, rNum, 'RIGHT'));
1307
+
1308
+ /** @type {{ path: string, line: number, side: 'LEFT' | 'RIGHT', existing: any[] } | null} */
1309
+ let leftInfo = null;
1310
+ /** @type {{ path: string, line: number, side: 'LEFT' | 'RIGHT', existing: any[] } | null} */
1311
+ let rightInfo = null;
1312
+
1313
+ if (interactive && currentPath) {
1314
+ if (lNum) {
1315
+ leftInfo = {
1316
+ path: currentPath,
1317
+ line: lNum,
1318
+ side: 'LEFT',
1319
+ existing: draft.lineComments.filter(
1320
+ (c) => c.path === currentPath && c.line === lNum && c.side === 'LEFT'
1321
+ ),
1322
+ };
1323
+ }
1324
+ if (rNum) {
1325
+ rightInfo = {
1326
+ path: currentPath,
1327
+ line: rNum,
1328
+ side: 'RIGHT',
1329
+ existing: draft.lineComments.filter(
1330
+ (c) => c.path === currentPath && c.line === rNum && c.side === 'RIGHT'
1331
+ ),
1332
+ };
1333
+ }
1334
+ }
1335
+
1336
+ // Prefer showing a thread for the side that is actively being edited,
1337
+ // otherwise for whichever side has pending comments (RIGHT first for context).
1338
+ if (leftInfo && activeInlineCommentKey === lineCommentKey(leftInfo.path, leftInfo.line, 'LEFT')) {
1339
+ appendThread(leftInfo.path, leftInfo.line, 'LEFT', leftInfo.existing);
1340
+ } else if (
1341
+ rightInfo &&
1342
+ activeInlineCommentKey === lineCommentKey(rightInfo.path, rightInfo.line, 'RIGHT')
1343
+ ) {
1344
+ appendThread(rightInfo.path, rightInfo.line, 'RIGHT', rightInfo.existing);
1345
+ } else if (rightInfo?.existing.length) {
1346
+ appendThread(rightInfo.path, rightInfo.line, 'RIGHT', rightInfo.existing);
1347
+ } else if (leftInfo?.existing.length) {
1348
+ appendThread(leftInfo.path, leftInfo.line, 'LEFT', leftInfo.existing);
1349
+ }
1350
+ };
1351
+
1352
+ for (const line of diff.split('\n')) {
1353
+ if (line.startsWith('diff --git ')) {
1354
+ flushSplit();
1355
+ fileEl = document.createElement('div');
1356
+ fileEl.className = 'diff-file';
1357
+ const header = document.createElement('div');
1358
+ header.className = 'diff-file-header';
1359
+ const parts = line.split(' ');
1360
+ const raw = parts[parts.length - 1]?.replace(/^b\//, '') || line;
1361
+ currentPath = raw;
1362
+ header.textContent = raw;
1363
+ fileEl.appendChild(header);
1364
+ els.diffViewer.appendChild(fileEl);
1365
+ continue;
1366
+ }
1367
+
1368
+ if (line.startsWith('+++ ')) {
1369
+ const raw = line.slice(4).trim();
1370
+ if (raw !== '/dev/null') {
1371
+ currentPath = raw.replace(/^b\//, '');
1372
+ }
1373
+ continue;
1374
+ }
1375
+
1376
+ if (line.startsWith('@@')) {
1377
+ const m = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
1378
+ if (m) {
1379
+ leftNo = Number(m[1]);
1380
+ rightNo = Number(m[2]);
1381
+ }
1382
+ flushSplit();
1383
+ ensureFile();
1384
+ const meta = document.createElement('div');
1385
+ meta.className = 'diff-hunk-meta';
1386
+ meta.textContent = line;
1387
+ fileEl.appendChild(meta);
1388
+ continue;
1389
+ }
1390
+
1391
+ if (
1392
+ line.startsWith('index ') ||
1393
+ line.startsWith('---') ||
1394
+ line.startsWith('new file') ||
1395
+ line.startsWith('deleted file')
1396
+ ) {
1397
+ continue;
1398
+ }
1399
+
1400
+ if (line.startsWith('+') && !line.startsWith('+++')) {
1401
+ addPair('', line.slice(1), '', 'add', '', rightNo++);
1402
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
1403
+ addPair(line.slice(1), '', 'del', '', leftNo++, '');
1404
+ } else {
1405
+ const text = line.startsWith(' ') ? line.slice(1) : line;
1406
+ addPair(text, text, '', '', leftNo++, rightNo++);
1407
+ }
1408
+ }
1409
+
1410
+ bindDiffHorizontalScrollSync(els.diffViewer);
1411
+ }
1412
+
1413
+ /**
1414
+ * Sync horizontal scroll across left|right panes within each `.diff-file`
1415
+ * (all splits in that file). Does not sync across different files.
1416
+ * Pane lists are captured at bind time — scroll handlers never re-query the DOM.
1417
+ * No-op for unified diffs (`.diff-unified`). Vertical scroll is untouched.
1418
+ * @param {HTMLElement | null} root
1419
+ */
1420
+ function bindDiffHorizontalScrollSync(root) {
1421
+ if (!root || root.classList.contains('diff-unified')) return;
1422
+
1423
+ const files = root.querySelectorAll(':scope > .diff-file');
1424
+ const scopes = files.length > 0 ? files : [root];
1425
+
1426
+ scopes.forEach((scope) => {
1427
+ const panes = [
1428
+ ...scope.querySelectorAll('.diff-pane-left'),
1429
+ ...scope.querySelectorAll('.diff-pane-right'),
1430
+ ];
1431
+ if (panes.length < 2) return;
1432
+
1433
+ let syncing = false;
1434
+ let raf = 0;
1435
+ /** @type {HTMLElement | null} */
1436
+ let pendingSource = null;
1437
+
1438
+ const apply = () => {
1439
+ raf = 0;
1440
+ const source = pendingSource;
1441
+ pendingSource = null;
1442
+ if (!source || syncing || root.classList.contains('diff-unified')) return;
1443
+ syncing = true;
1444
+ const x = source.scrollLeft;
1445
+ for (const other of panes) {
1446
+ if (other !== source && other.scrollLeft !== x) {
1447
+ other.scrollLeft = x;
1448
+ }
1449
+ }
1450
+ syncing = false;
1451
+ };
1452
+
1453
+ for (const pane of panes) {
1454
+ pane.addEventListener(
1455
+ 'scroll',
1456
+ () => {
1457
+ if (syncing) return;
1458
+ pendingSource = /** @type {HTMLElement} */ (pane);
1459
+ if (!raf) raf = requestAnimationFrame(apply);
1460
+ },
1461
+ { passive: true }
1462
+ );
1463
+ }
1464
+ });
1465
+ }
1466
+
1467
+ /** @param {string | null} jobId */
1468
+ function renderPendingCommentList(jobId) {
1469
+ if (!els.reviewPendingList) return;
1470
+ els.reviewPendingList.innerHTML = '';
1471
+ if (!jobId) {
1472
+ if (els.reviewPendingEmpty) {
1473
+ els.reviewPendingList.appendChild(els.reviewPendingEmpty);
1474
+ els.reviewPendingEmpty.classList.remove('hidden');
1475
+ }
1476
+ return;
1477
+ }
1478
+
1479
+ const draft = loadReviewDraft(jobId);
1480
+ if (draft.lineComments.length === 0) {
1481
+ const empty = document.createElement('div');
1482
+ empty.className = 'review-pending-empty';
1483
+ empty.id = 'review-pending-empty';
1484
+ empty.textContent = 'No line comments yet. Click a diff line to add one.';
1485
+ els.reviewPendingEmpty = empty;
1486
+ els.reviewPendingList.appendChild(empty);
1487
+ return;
1488
+ }
1489
+
1490
+ for (const c of draft.lineComments) {
1491
+ const item = document.createElement('div');
1492
+ item.className = 'review-pending-item';
1493
+ const main = document.createElement('div');
1494
+ main.className = 'review-pending-item-main';
1495
+ const loc = document.createElement('div');
1496
+ loc.className = 'review-pending-item-loc';
1497
+ loc.textContent = `${c.path}:${c.line} (${c.side})`;
1498
+ const body = document.createElement('div');
1499
+ body.className = 'review-pending-item-body';
1500
+ body.textContent = c.body;
1501
+ main.appendChild(loc);
1502
+ main.appendChild(body);
1503
+ const remove = document.createElement('button');
1504
+ remove.type = 'button';
1505
+ remove.className = 'btn btn-muted-text btn-sm';
1506
+ remove.textContent = 'Remove';
1507
+ remove.addEventListener('click', () => {
1508
+ const d = loadReviewDraft(jobId);
1509
+ d.lineComments = d.lineComments.filter((x) => x.id !== c.id);
1510
+ saveReviewDraft(jobId);
1511
+ const job = jobsById[jobId];
1512
+ if (job) {
1513
+ const diff =
1514
+ job.status === 'awaiting_review'
1515
+ ? filterDiffForReviewJob(job)
1516
+ : job.diff || '';
1517
+ renderDiff(diff, { interactive: job.status === 'awaiting_review', jobId });
1518
+ }
1519
+ renderPendingCommentList(jobId);
1520
+ syncSubmitReviewButton(jobId);
1521
+ });
1522
+ item.appendChild(main);
1523
+ item.appendChild(remove);
1524
+ els.reviewPendingList.appendChild(item);
1525
+ }
1526
+ }
1527
+
1528
+ /** @param {string | null} jobId */
1529
+ function syncSubmitReviewButton(jobId) {
1530
+ if (!els.submitReviewBtn) return;
1531
+ const enabled = Boolean(jobId && reviewDraftHasContent(jobId));
1532
+ els.submitReviewBtn.disabled = !enabled;
1533
+ }
1534
+
1535
+ function setView(view) {
1536
+ currentView = view;
1537
+ els.viewTitle.textContent = VIEW_TITLES[view] || view;
1538
+
1539
+ document.querySelectorAll('.nav-item').forEach((btn) => {
1540
+ const active = btn.dataset.view === view;
1541
+ btn.classList.toggle('active', active);
1542
+ if (active) btn.setAttribute('aria-current', 'page');
1543
+ else btn.removeAttribute('aria-current');
1544
+ });
1545
+
1546
+ document.querySelectorAll('[data-view-panel]').forEach((panel) => {
1547
+ panel.classList.toggle('hidden', panel.dataset.viewPanel !== view);
1548
+ });
1549
+
1550
+ if (view === 'runs' && selectedRunId) {
1551
+ connectSSE(selectedRunId);
1552
+ }
1553
+
1554
+ if (view === 'settings') {
1555
+ fillSettingsForm(appConfig);
1556
+ setSettingsFeedback(null);
1557
+ }
1558
+
1559
+ renderAll();
1560
+ }
1561
+
1562
+ function setNavBadge(el, count) {
1563
+ if (!el) return;
1564
+ if (count > 0) {
1565
+ el.textContent = String(count);
1566
+ el.classList.remove('hidden');
1567
+ } else {
1568
+ el.textContent = '';
1569
+ el.classList.add('hidden');
1570
+ }
1571
+ }
1572
+
1573
+ function updateNavBadges(jobs) {
1574
+ const queued = jobs.filter((j) => j.status === 'queued').length;
1575
+ const running = jobs.filter((j) => RUNNING_STATUSES.has(j.status)).length;
1576
+ const review = jobs.filter((j) => j.status === 'awaiting_review').length;
1577
+ const alerts = collectAlerts(jobs).length;
1578
+ setNavBadge(els.navBadgeOverview, queued);
1579
+ setNavBadge(els.navBadgeRuns, running);
1580
+ setNavBadge(els.navBadgeReview, review);
1581
+ setNavBadge(els.navBadgeAlerts, alerts);
1582
+ }
1583
+
1584
+ function collectAlerts(jobs) {
1585
+ /** @type {Array<{id: string, jobId: string, title: string, text: string, repo: string, number: string, time: string, actions: string[]}>} */
1586
+ const alerts = [];
1587
+ for (const job of jobs) {
1588
+ const meta = jobRepoLine(job);
1589
+ if (job.status === 'failed') {
1590
+ alerts.push({
1591
+ id: `failed:${job.id}:${job.updatedAt || job.createdAt}`,
1592
+ jobId: job.id,
1593
+ title: job.error ? truncate(job.error, 80) : `${jobTitle(job)} failed`,
1594
+ text: job.error || 'Unknown error',
1595
+ repo: meta.repo,
1596
+ number: String(meta.number),
1597
+ time: formatTime(job.updatedAt || job.createdAt),
1598
+ actions: ['retry', 'clear'],
1599
+ });
1600
+ }
1601
+ for (const log of job.logs || []) {
1602
+ if (log.type !== 'error' && log.type !== 'warn') continue;
1603
+ const payload = String(log.payload || '');
1604
+ const notable =
1605
+ /dirty|timeout|max.?turns|restart|recover|timed out|EADDRINUSE|post-pr|ticket rule|transition|jira/i.test(
1606
+ payload
1607
+ );
1608
+ if (!notable && job.status !== 'failed' && log.type !== 'warn') continue;
1609
+ if (job.status === 'failed' && payload === job.error) continue;
1610
+ alerts.push({
1611
+ id: `log:${job.id}:${log.ts}:${payload.slice(0, 40)}`,
1612
+ jobId: job.id,
1613
+ title: `${jobTitle(job)} warning`,
1614
+ text: payload,
1615
+ repo: meta.repo,
1616
+ number: String(meta.number),
1617
+ time: formatTime(log.ts),
1618
+ actions: job.status === 'failed' ? ['retry', 'clear'] : ['runs'],
1619
+ });
1620
+ }
1621
+ }
1622
+ return alerts.filter((a) => !dismissedAlertIds.includes(a.id));
1623
+ }
1624
+
1625
+ function renderStats(jobs) {
1626
+ const queued = jobs.filter((j) => j.status === 'queued').length;
1627
+ const running = jobs.filter((j) => RUNNING_STATUSES.has(j.status)).length;
1628
+ const review = jobs.filter((j) => j.status === 'awaiting_review').length;
1629
+ const alerts = collectAlerts(jobs).length;
1630
+ const agg = aggregateJobUsage(jobs);
1631
+ const costLabel = formatUsd(agg.withCost ? agg.totalCostUsd : null) || '—';
1632
+ const tokLabel = agg.withTokens ? formatTokenCount(agg.totalTokens) || '—' : '—';
1633
+
1634
+ const items = [
1635
+ { label: 'Queued', value: queued, color: 'var(--text-muted)', dot: false },
1636
+ { label: 'Running', value: running, color: 'var(--amber)', dot: true },
1637
+ { label: 'Ready for review', value: review, color: 'var(--primary)', dot: false },
1638
+ { label: 'Alerts', value: alerts, color: 'var(--accent)', dot: false },
1639
+ {
1640
+ label: 'Job cost',
1641
+ value: costLabel,
1642
+ color: 'var(--primary)',
1643
+ dot: false,
1644
+ hint: 'These jobs only',
1645
+ },
1646
+ {
1647
+ label: 'Tokens',
1648
+ value: tokLabel,
1649
+ color: 'var(--text-muted)',
1650
+ dot: false,
1651
+ hint: 'In + out',
1652
+ },
1653
+ ];
1654
+
1655
+ els.statsGrid.innerHTML = items
1656
+ .map(
1657
+ (s) => `
1658
+ <div class="stat-card">
1659
+ <div class="stat-label" style="color:${s.color}">
1660
+ ${s.dot ? `<span class="stat-dot" style="background:${s.color}"></span>` : ''}
1661
+ ${s.label}
1662
+ </div>
1663
+ <div class="stat-value${typeof s.value === 'string' ? ' stat-value-sm' : ''}">${s.value}</div>
1664
+ ${s.hint ? `<div class="stat-hint">${s.hint}</div>` : ''}
1665
+ </div>`
1666
+ )
1667
+ .join('');
1668
+ }
1669
+
1670
+ function stepDotsHtml(states) {
1671
+ return `<div class="step-dots">${states
1672
+ .map((st) => `<span class="step-dot ${st}"></span>`)
1673
+ .join('')}</div>`;
1674
+ }
1675
+
1676
+ function typeBadgeEl(job) {
1677
+ const t = issueBadge(job);
1678
+ if (!t) return null;
1679
+ const span = document.createElement('span');
1680
+ span.className = `type-badge ${t}`;
1681
+ span.textContent = t;
1682
+ return span;
1683
+ }
1684
+
1685
+ function statusPill(job) {
1686
+ const span = document.createElement('span');
1687
+ span.className = `status-pill ${job.status}`;
1688
+ if (RUNNING_STATUSES.has(job.status)) {
1689
+ span.innerHTML = `<span class="dot"></span>${statusLabel(job.status)}`;
1690
+ } else {
1691
+ span.textContent = statusLabel(job.status);
1692
+ }
1693
+ return span;
1694
+ }
1695
+
1696
+ function renderOverview(jobs) {
1697
+ const running = jobs.filter((j) => RUNNING_STATUSES.has(j.status));
1698
+ const review = jobs.filter((j) => j.status === 'awaiting_review');
1699
+ const queued = jobs.filter((j) => j.status === 'queued');
1700
+
1701
+ els.overviewRunning.innerHTML = '';
1702
+ els.overviewRunningEmpty.classList.toggle('hidden', running.length > 0);
1703
+ for (const job of running) {
1704
+ const row = document.createElement('div');
1705
+ row.className = 'job-row';
1706
+ row.innerHTML = `
1707
+ ${stepDotsHtml(pipelineStates(job))}
1708
+ <div class="job-main">
1709
+ <div class="job-title"></div>
1710
+ <div class="job-sub mono"></div>
1711
+ </div>
1712
+ `;
1713
+ row.querySelector('.job-title').textContent = jobTitle(job);
1714
+ row.querySelector('.job-sub').textContent = jobSubLine(job);
1715
+ const badge = typeBadgeEl(job);
1716
+ if (badge) row.appendChild(badge);
1717
+ const usageText = formatUsageCompact(job.usage);
1718
+ if (usageText) {
1719
+ const usageEl = document.createElement('span');
1720
+ usageEl.className = 'usage-chip';
1721
+ usageEl.textContent = usageText;
1722
+ usageEl.title = formatUsageDetail(job.usage) || usageText;
1723
+ row.appendChild(usageEl);
1724
+ }
1725
+ const elapsed = document.createElement('span');
1726
+ elapsed.className = 'elapsed';
1727
+ elapsed.textContent = formatElapsed(job);
1728
+ row.appendChild(elapsed);
1729
+ row.appendChild(statusPill(job));
1730
+ row.style.cursor = 'pointer';
1731
+ row.addEventListener('click', () => {
1732
+ setView('runs');
1733
+ selectRun(job.id);
1734
+ });
1735
+ els.overviewRunning.appendChild(row);
1736
+ }
1737
+
1738
+ els.overviewReview.innerHTML = '';
1739
+ els.overviewReviewEmpty.classList.toggle('hidden', review.length > 0);
1740
+ for (const job of review) {
1741
+ const row = document.createElement('div');
1742
+ row.className = 'job-row';
1743
+ row.innerHTML = `
1744
+ ${stepDotsHtml(pipelineStates(job))}
1745
+ <div class="job-main">
1746
+ <div class="job-title"></div>
1747
+ <div class="job-sub mono"></div>
1748
+ </div>
1749
+ `;
1750
+ row.querySelector('.job-title').textContent = jobTitle(job);
1751
+ row.querySelector('.job-sub').textContent = jobSubLine(job);
1752
+ const badge = typeBadgeEl(job);
1753
+ if (badge) row.appendChild(badge);
1754
+ const usageText = formatUsageCompact(job.usage);
1755
+ if (usageText) {
1756
+ const usageEl = document.createElement('span');
1757
+ usageEl.className = 'usage-chip';
1758
+ usageEl.textContent = usageText;
1759
+ usageEl.title = formatUsageDetail(job.usage) || usageText;
1760
+ row.appendChild(usageEl);
1761
+ }
1762
+ const btn = document.createElement('button');
1763
+ btn.type = 'button';
1764
+ btn.className = 'btn btn-primary btn-sm';
1765
+ btn.textContent = 'Review';
1766
+ btn.addEventListener('click', () => {
1767
+ setView('review');
1768
+ selectReview(job.id);
1769
+ });
1770
+ row.appendChild(btn);
1771
+ const link = document.createElement('button');
1772
+ link.type = 'button';
1773
+ link.className = 'btn-link';
1774
+ link.textContent = 'Review →';
1775
+ link.addEventListener('click', () => {
1776
+ setView('review');
1777
+ selectReview(job.id);
1778
+ });
1779
+ row.appendChild(link);
1780
+ els.overviewReview.appendChild(row);
1781
+ }
1782
+
1783
+ els.overviewQueue.innerHTML = '';
1784
+ els.overviewQueueEmpty.classList.toggle('hidden', queued.length > 0);
1785
+ queued.forEach((job, i) => {
1786
+ const row = document.createElement('div');
1787
+ row.className = 'job-row job-row-queue';
1788
+ row.innerHTML = `
1789
+ <span class="job-pos">${i + 1}</span>
1790
+ <div class="job-main">
1791
+ <div class="job-title"></div>
1792
+ <div class="job-sub mono"></div>
1793
+ </div>
1794
+ `;
1795
+ row.querySelector('.job-title').textContent = jobTitle(job);
1796
+ row.querySelector('.job-sub').textContent = jobSubLine(job);
1797
+ const badge = typeBadgeEl(job);
1798
+ if (badge) row.appendChild(badge);
1799
+ const pill = document.createElement('span');
1800
+ pill.className = 'status-pill queued';
1801
+ pill.textContent = 'Queued';
1802
+ row.appendChild(pill);
1803
+ const remove = document.createElement('button');
1804
+ remove.type = 'button';
1805
+ remove.className = 'btn-remove';
1806
+ remove.setAttribute('aria-label', 'Remove');
1807
+ remove.textContent = '✕';
1808
+ remove.addEventListener('click', (e) => {
1809
+ e.stopPropagation();
1810
+ clearJob(job.id);
1811
+ });
1812
+ row.appendChild(remove);
1813
+ els.overviewQueue.appendChild(row);
1814
+ });
1815
+ }
1816
+
1817
+ function renderRuns(jobs) {
1818
+ const runs = jobs.filter((j) => RUN_STATUSES.has(j.status)).slice(0, 40);
1819
+ els.runsList.innerHTML = '';
1820
+ els.runsEmpty.classList.toggle('hidden', runs.length > 0);
1821
+
1822
+ for (const job of runs) {
1823
+ const card = document.createElement('button');
1824
+ card.type = 'button';
1825
+ card.className = 'run-card' + (job.id === selectedRunId ? ' selected' : '');
1826
+ const previewLogs = (job.logs || []).slice(-4);
1827
+
1828
+ const top = document.createElement('div');
1829
+ top.className = 'run-card-top';
1830
+ top.innerHTML = `
1831
+ <div class="job-main">
1832
+ <div class="job-title"></div>
1833
+ <div class="job-sub mono"></div>
1834
+ </div>
1835
+ `;
1836
+ top.querySelector('.job-title').textContent = jobTitle(job);
1837
+ top.querySelector('.job-sub').textContent = jobSubLine(job);
1838
+ top.appendChild(statusPill(job));
1839
+ const elapsed = document.createElement('span');
1840
+ elapsed.className = 'elapsed';
1841
+ elapsed.textContent = formatElapsed(job);
1842
+ top.appendChild(elapsed);
1843
+ card.appendChild(top);
1844
+
1845
+ const usageLine = document.createElement('div');
1846
+ usageLine.className = 'run-usage mono';
1847
+ const usageDetail = formatUsageDetail(job.usage);
1848
+ usageLine.textContent = usageDetail || '—';
1849
+ if (!usageDetail) usageLine.classList.add('muted');
1850
+ card.appendChild(usageLine);
1851
+
1852
+ const now = document.createElement('div');
1853
+ now.className = 'run-now';
1854
+ now.innerHTML = `<span class="run-now-label">Now:</span><span class="mono"></span>`;
1855
+ now.querySelector('.mono').textContent = currentStepLabel(job);
1856
+ card.appendChild(now);
1857
+
1858
+ if (job.id !== selectedRunId && previewLogs.length) {
1859
+ const preview = document.createElement('div');
1860
+ preview.className = 'run-log-preview';
1861
+ for (const ev of previewLogs) {
1862
+ const line = document.createElement('div');
1863
+ const f = formatLogEvent(ev);
1864
+ line.textContent = `[${formatTime(ev.ts)}] ${f.text}`;
1865
+ preview.appendChild(line);
1866
+ }
1867
+ card.appendChild(preview);
1868
+ }
1869
+
1870
+ if (job.id === selectedRunId) {
1871
+ const detail = document.createElement('div');
1872
+ detail.className = 'run-detail';
1873
+ detail.addEventListener('click', (e) => e.stopPropagation());
1874
+
1875
+ const logWrap = document.createElement('div');
1876
+ logWrap.className = 'log-pane-wrap';
1877
+ const logPane = document.createElement('div');
1878
+ logPane.className = 'log-pane amcp-scroll';
1879
+ logPane.id = 'runs-log';
1880
+ logPane.setAttribute('role', 'log');
1881
+ logPane.setAttribute('aria-live', 'polite');
1882
+ logPane.addEventListener('scroll', () => updateLogsPinFromScroll(logPane), { passive: true });
1883
+ const jumpBtn = document.createElement('button');
1884
+ jumpBtn.type = 'button';
1885
+ jumpBtn.className = 'log-jump-latest hidden';
1886
+ jumpBtn.id = 'runs-log-jump';
1887
+ jumpBtn.textContent = 'Jump to latest';
1888
+ jumpBtn.addEventListener('click', (e) => {
1889
+ e.stopPropagation();
1890
+ scrollLogPaneToBottom(logPane);
1891
+ });
1892
+ logWrap.appendChild(logPane);
1893
+ logWrap.appendChild(jumpBtn);
1894
+ detail.appendChild(logWrap);
1895
+
1896
+ if (job.status === 'pr_opened' && job.prUrl) {
1897
+ const banner = document.createElement('div');
1898
+ banner.className = 'pr-banner';
1899
+ const stats = diffStats(job.diff);
1900
+ banner.innerHTML = `
1901
+ <span>${stats.files} files · +${stats.additions} −${stats.deletions}</span>
1902
+ <a class="btn btn-primary btn-sm" href="${job.prUrl}" target="_blank" rel="noopener">View Pull Request</a>
1903
+ `;
1904
+ detail.appendChild(banner);
1905
+ }
1906
+
1907
+ if (job.status === 'failed') {
1908
+ const err = document.createElement('div');
1909
+ err.className = 'error-banner';
1910
+ err.textContent = job.error || 'Unknown error';
1911
+ detail.appendChild(err);
1912
+ const actions = document.createElement('div');
1913
+ actions.className = 'actions';
1914
+ const retry = document.createElement('button');
1915
+ retry.type = 'button';
1916
+ retry.className = 'btn btn-warn';
1917
+ retry.textContent = 'Retry';
1918
+ retry.addEventListener('click', async () => {
1919
+ await fetch(`/api/jobs/${job.id}/retry`, { method: 'POST' });
1920
+ await fetchJobs();
1921
+ });
1922
+ const clear = document.createElement('button');
1923
+ clear.type = 'button';
1924
+ clear.className = 'btn btn-secondary';
1925
+ clear.textContent = 'Clear';
1926
+ clear.addEventListener('click', () => clearJob(job.id));
1927
+ actions.appendChild(retry);
1928
+ actions.appendChild(clear);
1929
+ detail.appendChild(actions);
1930
+ } else if (CLEARABLE_STATUSES.has(job.status) && job.status !== 'queued') {
1931
+ const actions = document.createElement('div');
1932
+ actions.className = 'actions';
1933
+ const clear = document.createElement('button');
1934
+ clear.type = 'button';
1935
+ clear.className = 'btn btn-secondary btn-sm';
1936
+ clear.textContent = 'Clear from history';
1937
+ clear.addEventListener('click', () => clearJob(job.id));
1938
+ actions.appendChild(clear);
1939
+ detail.appendChild(actions);
1940
+ }
1941
+
1942
+ card.appendChild(detail);
1943
+ }
1944
+
1945
+ card.addEventListener('click', () => selectRun(job.id));
1946
+ els.runsList.appendChild(card);
1947
+ }
1948
+
1949
+ // Re-paint selected run logs after DOM rebuild (polling / re-render)
1950
+ if (selectedRunId && jobsById[selectedRunId] && currentView === 'runs') {
1951
+ const job = jobsById[selectedRunId];
1952
+ requestAnimationFrame(() => renderLogPane(job.logs || []));
1953
+ }
1954
+ }
1955
+
1956
+ function isNearLogBottom(pane) {
1957
+ return pane.scrollHeight - pane.scrollTop - pane.clientHeight <= LOG_PIN_THRESHOLD_PX;
1958
+ }
1959
+
1960
+ function updateLogsPinFromScroll(pane) {
1961
+ logsScrollTop = pane.scrollTop;
1962
+ logsPinnedToBottom = isNearLogBottom(pane);
1963
+ syncJumpToLatestButton();
1964
+ }
1965
+
1966
+ function syncJumpToLatestButton() {
1967
+ const jump = document.getElementById('runs-log-jump');
1968
+ if (!jump) return;
1969
+ jump.classList.toggle('hidden', logsPinnedToBottom);
1970
+ }
1971
+
1972
+ function scrollLogPaneToBottom(pane) {
1973
+ pane.scrollTop = pane.scrollHeight;
1974
+ logsScrollTop = pane.scrollTop;
1975
+ logsPinnedToBottom = true;
1976
+ syncJumpToLatestButton();
1977
+ }
1978
+
1979
+ function applyLogPaneScroll(pane) {
1980
+ if (logsPinnedToBottom) {
1981
+ scrollLogPaneToBottom(pane);
1982
+ } else {
1983
+ pane.scrollTop = logsScrollTop;
1984
+ syncJumpToLatestButton();
1985
+ }
1986
+ }
1987
+
1988
+ function appendLogLine(event, shouldFollow = true) {
1989
+ const pane = document.getElementById('runs-log');
1990
+ if (!pane) return;
1991
+ pane.appendChild(buildLogRow(event));
1992
+ if (shouldFollow && logsPinnedToBottom) {
1993
+ scrollLogPaneToBottom(pane);
1994
+ } else {
1995
+ syncJumpToLatestButton();
1996
+ }
1997
+ }
1998
+
1999
+ function renderLogPane(events) {
2000
+ const pane = document.getElementById('runs-log');
2001
+ if (!pane) return;
2002
+ pane.innerHTML = '';
2003
+ for (const event of events || []) {
2004
+ appendLogLine(event, false);
2005
+ }
2006
+ applyLogPaneScroll(pane);
2007
+ }
2008
+
2009
+ function renderReview(jobs) {
2010
+ const pool = jobs.filter((j) => REVIEW_STATUSES.has(j.status));
2011
+ const hasPool = pool.length > 0;
2012
+
2013
+ els.reviewToolbar.classList.toggle('hidden', !hasPool);
2014
+ els.reviewEmpty.classList.toggle('hidden', hasPool);
2015
+
2016
+ if (els.reviewSearch && document.activeElement !== els.reviewSearch) {
2017
+ els.reviewSearch.value = reviewSearch;
2018
+ }
2019
+ syncReviewFilterChips(pool);
2020
+
2021
+ if (!hasPool) {
2022
+ els.reviewNoMatches.classList.add('hidden');
2023
+ els.reviewLayout.classList.add('hidden');
2024
+ return;
2025
+ }
2026
+
2027
+ const list = filterReviewJobs(pool);
2028
+ const noMatches = list.length === 0;
2029
+ els.reviewNoMatches.classList.toggle('hidden', !noMatches);
2030
+ els.reviewLayout.classList.toggle('hidden', noMatches);
2031
+
2032
+ if (selectedReviewId && !list.some((j) => j.id === selectedReviewId)) {
2033
+ selectedReviewId = null;
2034
+ }
2035
+
2036
+ if (noMatches) return;
2037
+
2038
+ if (!selectedReviewId && list.length) {
2039
+ selectedReviewId = list[0].id;
2040
+ }
2041
+
2042
+ els.reviewList.innerHTML = '';
2043
+ for (const job of list) {
2044
+ const btn = document.createElement('button');
2045
+ btn.type = 'button';
2046
+ btn.className = 'review-pick' + (job.id === selectedReviewId ? ' selected' : '');
2047
+ btn.innerHTML = `
2048
+ <div class="review-pick-title"></div>
2049
+ <div class="review-pick-sub"></div>
2050
+ `;
2051
+ btn.querySelector('.review-pick-title').textContent = jobTitle(job);
2052
+ btn.querySelector('.review-pick-sub').textContent =
2053
+ `${jobSubLine(job).replace(/ · .*$/, '')} · ${statusLabel(job.status)}`;
2054
+ btn.addEventListener('click', () => selectReview(job.id));
2055
+ els.reviewList.appendChild(btn);
2056
+ }
2057
+
2058
+ const job = selectedReviewId ? jobsById[selectedReviewId] : null;
2059
+ if (!job || !REVIEW_STATUSES.has(job.status)) {
2060
+ els.reviewEmptyDetail.classList.remove('hidden');
2061
+ els.reviewDetailContent.classList.add('hidden');
2062
+ return;
2063
+ }
2064
+
2065
+ els.reviewEmptyDetail.classList.add('hidden');
2066
+ els.reviewDetailContent.classList.remove('hidden');
2067
+
2068
+ const editable = job.status === 'awaiting_review';
2069
+ const isOpened = job.status === 'pr_opened';
2070
+ const isTerminal = job.status === 'discarded' || job.status === 'failed';
2071
+
2072
+ els.reviewActions.classList.toggle('hidden', !editable);
2073
+ if (els.reviewFeedbackSection) {
2074
+ els.reviewFeedbackSection.classList.toggle('hidden', !editable);
2075
+ }
2076
+ if (els.reviewFilesSection) {
2077
+ els.reviewFilesSection.classList.toggle('hidden', !editable);
2078
+ }
2079
+ els.prTitle.disabled = !editable;
2080
+ els.prBody.disabled = !editable;
2081
+ if (els.reviewGeneralComment) {
2082
+ els.reviewGeneralComment.disabled = !editable;
2083
+ }
2084
+ els.reviewPrSection.classList.toggle('hidden', !isOpened);
2085
+ els.reviewTerminalSection.classList.toggle('hidden', !isTerminal);
2086
+
2087
+ if (document.activeElement !== els.prTitle && document.activeElement !== els.prBody) {
2088
+ els.prTitle.value = job.prTitle || '';
2089
+ els.prBody.value = job.prBody || '';
2090
+ }
2091
+
2092
+ if (editable) {
2093
+ const draft = loadReviewDraft(job.id);
2094
+ if (
2095
+ els.reviewGeneralComment &&
2096
+ document.activeElement !== els.reviewGeneralComment
2097
+ ) {
2098
+ els.reviewGeneralComment.value = draft.generalComment;
2099
+ }
2100
+ renderPendingCommentList(job.id);
2101
+ syncSubmitReviewButton(job.id);
2102
+ renderReviewFilesPanel(job);
2103
+ }
2104
+
2105
+ const displayDiff = editable
2106
+ ? filterDiffForReviewJob(job)
2107
+ : job.diff || '';
2108
+ const stats = diffStats(displayDiff);
2109
+ const fileHint = stats.files ? `${stats.files} files` : 'diff';
2110
+ els.diffHeader.textContent = `Diff · ${fileHint} (+${stats.additions} / -${stats.deletions})`;
2111
+
2112
+ if (isOpened && job.prUrl) {
2113
+ els.reviewPrLink.href = job.prUrl;
2114
+ els.reviewPrLink.textContent = job.prUrl;
2115
+ }
2116
+
2117
+ if (isTerminal) {
2118
+ if (job.status === 'failed') {
2119
+ els.reviewTerminalMessage.textContent = job.error
2120
+ ? `Failed: ${job.error}`
2121
+ : 'This run failed.';
2122
+ } else {
2123
+ els.reviewTerminalMessage.textContent = 'Rejected — discarded without opening a PR.';
2124
+ }
2125
+ els.reviewRetryBtn.classList.remove('hidden');
2126
+ }
2127
+
2128
+ const focusInDiff = els.diffViewer?.contains(document.activeElement);
2129
+ if (!(activeInlineCommentKey && focusInDiff)) {
2130
+ renderDiff(displayDiff, { interactive: editable, jobId: job.id });
2131
+ }
2132
+
2133
+ if (editable) {
2134
+ ensureReviewFiles(job.id);
2135
+ }
2136
+ }
2137
+
2138
+ /**
2139
+ * @param {object} job
2140
+ * @returns {string}
2141
+ */
2142
+ function filterDiffForReviewJob(job) {
2143
+ const files = getReviewFilePaths(job.id);
2144
+ if (files.length === 0) return job.diff || '';
2145
+ const excluded = loadExcludedPaths(job.id);
2146
+ const included = new Set(files.filter((p) => !excluded.has(p)));
2147
+ if (included.size === files.length) return job.diff || '';
2148
+ return filterDiffByIncludedPaths(job.diff || '', included);
2149
+ }
2150
+
2151
+ /**
2152
+ * @param {object} job
2153
+ */
2154
+ function renderReviewFilesPanel(job) {
2155
+ if (!els.reviewFilesList) return;
2156
+
2157
+ const paths = getReviewFilePaths(job.id).length
2158
+ ? getReviewFilePaths(job.id)
2159
+ : parseChangedFilesFromDiff(job.diff || '');
2160
+ const excluded = loadExcludedPaths(job.id);
2161
+ // Drop stale exclusions for paths no longer in the change.
2162
+ let pruned = false;
2163
+ for (const p of [...excluded]) {
2164
+ if (!paths.includes(p)) {
2165
+ excluded.delete(p);
2166
+ pruned = true;
2167
+ }
2168
+ }
2169
+ if (pruned) saveExcludedPaths(job.id);
2170
+
2171
+ const includedCount = paths.filter((p) => !excluded.has(p)).length;
2172
+ if (els.reviewFilesCount) {
2173
+ els.reviewFilesCount.textContent =
2174
+ paths.length === 0
2175
+ ? 'No files'
2176
+ : `${includedCount} of ${paths.length} file${paths.length === 1 ? '' : 's'} included`;
2177
+ }
2178
+
2179
+ els.reviewFilesList.innerHTML = '';
2180
+ if (paths.length === 0) {
2181
+ const empty = document.createElement('div');
2182
+ empty.className = 'review-files-empty';
2183
+ empty.textContent = 'No changed files detected yet.';
2184
+ els.reviewFilesList.appendChild(empty);
2185
+ syncApproveButtonsForFileSelection(job.id);
2186
+ return;
2187
+ }
2188
+
2189
+ for (const filePath of paths) {
2190
+ const label = document.createElement('label');
2191
+ label.className = 'review-file-check';
2192
+ const input = document.createElement('input');
2193
+ input.type = 'checkbox';
2194
+ input.checked = !excluded.has(filePath);
2195
+ input.dataset.path = filePath;
2196
+ input.addEventListener('change', () => {
2197
+ const set = loadExcludedPaths(job.id);
2198
+ if (input.checked) set.delete(filePath);
2199
+ else set.add(filePath);
2200
+ saveExcludedPaths(job.id);
2201
+ renderReview(sortedJobs());
2202
+ });
2203
+ const span = document.createElement('span');
2204
+ span.textContent = filePath;
2205
+ label.appendChild(input);
2206
+ label.appendChild(span);
2207
+ els.reviewFilesList.appendChild(label);
2208
+ }
2209
+
2210
+ syncApproveButtonsForFileSelection(job.id);
2211
+ }
2212
+
2213
+ /** @param {string} jobId */
2214
+ function syncApproveButtonsForFileSelection(jobId) {
2215
+ const files = getReviewFilePaths(jobId);
2216
+ const excluded = loadExcludedPaths(jobId);
2217
+ const includedCount = files.filter((p) => !excluded.has(p)).length;
2218
+ const block = files.length > 0 && includedCount === 0;
2219
+ for (const btn of [els.approveDraftBtn, els.approveReadyBtn].filter(Boolean)) {
2220
+ if (block) {
2221
+ btn.disabled = true;
2222
+ btn.title = 'Keep at least one file included';
2223
+ } else {
2224
+ btn.title = '';
2225
+ // Don't re-enable if submit/approve flow already disabled them mid-request;
2226
+ // renderReview / approve finally handles that. Here only clear exclusion lock.
2227
+ if (!btn.dataset.busy) btn.disabled = false;
2228
+ }
2229
+ }
2230
+ }
2231
+
2232
+ /**
2233
+ * Fetch changed-file list for a review job (best-effort).
2234
+ * @param {string} jobId
2235
+ */
2236
+ async function ensureReviewFiles(jobId) {
2237
+ const job = jobsById[jobId];
2238
+ if (!job || job.status !== 'awaiting_review') return;
2239
+ if (Object.prototype.hasOwnProperty.call(reviewFileLists, jobId)) return;
2240
+ try {
2241
+ const res = await fetch(`/api/jobs/${jobId}/files`);
2242
+ const data = await res.json().catch(() => ({}));
2243
+ if (res.ok && Array.isArray(data.files)) {
2244
+ setReviewFilePaths(jobId, data.files.map(String));
2245
+ if (selectedReviewId === jobId) renderReview(sortedJobs());
2246
+ return;
2247
+ }
2248
+ } catch {
2249
+ /* fall through */
2250
+ }
2251
+ setReviewFilePaths(jobId, parseChangedFilesFromDiff(job.diff || ''));
2252
+ if (selectedReviewId === jobId) renderReview(sortedJobs());
2253
+ }
2254
+
2255
+ /** @param {object[]} pool */
2256
+ function syncReviewFilterChips(pool) {
2257
+ const q = reviewSearch.trim().toLowerCase();
2258
+ const searched = q ? pool.filter((j) => jobMatchesReviewSearch(j, q)) : pool;
2259
+
2260
+ for (const btn of els.reviewFilters?.querySelectorAll('[data-review-filter]') || []) {
2261
+ const id = /** @type {ReviewFilterId} */ (btn.getAttribute('data-review-filter'));
2262
+ const selected = id === reviewFilter;
2263
+ btn.setAttribute('aria-selected', selected ? 'true' : 'false');
2264
+ const countEl = btn.querySelector('[data-count-for]');
2265
+ if (countEl) {
2266
+ const def = REVIEW_FILTERS[id];
2267
+ const n = def?.statuses
2268
+ ? searched.filter((j) => def.statuses.has(j.status)).length
2269
+ : searched.length;
2270
+ countEl.textContent = String(n);
2271
+ }
2272
+ }
2273
+ }
2274
+
2275
+ /** @param {object[]} pool */
2276
+ function filterReviewJobs(pool) {
2277
+ const def = REVIEW_FILTERS[reviewFilter] || REVIEW_FILTERS.pending;
2278
+ let list = def.statuses
2279
+ ? pool.filter((j) => def.statuses.has(j.status))
2280
+ : pool.slice();
2281
+ const q = reviewSearch.trim().toLowerCase();
2282
+ if (q) list = list.filter((j) => jobMatchesReviewSearch(j, q));
2283
+ return list;
2284
+ }
2285
+
2286
+ /**
2287
+ * Case-insensitive substring match on issue number, title, URL, branch, PR title.
2288
+ * @param {object} job
2289
+ * @param {string} q lowercased query
2290
+ */
2291
+ function jobMatchesReviewSearch(job, q) {
2292
+ const ref = parseIssueRef(job.issueUrl);
2293
+ const jiraKey = parseJiraKeyFromJob(job);
2294
+ const hay = [
2295
+ job.issueTitle,
2296
+ job.issueUrl,
2297
+ job.branchName,
2298
+ job.prTitle,
2299
+ job.prUrl,
2300
+ ref?.number,
2301
+ job.issueNumber,
2302
+ ref?.full,
2303
+ jiraKey,
2304
+ job.ticketSource,
2305
+ ]
2306
+ .filter(Boolean)
2307
+ .map((s) => String(s).toLowerCase());
2308
+ return hay.some((s) => s.includes(q));
2309
+ }
2310
+
2311
+ function renderActivityLog(jobs) {
2312
+ const prev = els.logJobFilter.value;
2313
+ els.logJobFilter.innerHTML = '<option value="">All jobs</option>';
2314
+ for (const job of jobs) {
2315
+ const opt = document.createElement('option');
2316
+ opt.value = job.id;
2317
+ opt.textContent = jobTicketLabel(job);
2318
+ els.logJobFilter.appendChild(opt);
2319
+ }
2320
+ if ([...els.logJobFilter.options].some((o) => o.value === (logFilterJobId || prev))) {
2321
+ els.logJobFilter.value = logFilterJobId || prev;
2322
+ logFilterJobId = els.logJobFilter.value;
2323
+ }
2324
+
2325
+ /** @type {Array<{ts: string, job: object, event: object}>} */
2326
+ const entries = [];
2327
+ for (const job of jobs) {
2328
+ if (logFilterJobId && job.id !== logFilterJobId) continue;
2329
+ for (const event of job.logs || []) {
2330
+ entries.push({ ts: event.ts, job, event });
2331
+ }
2332
+ }
2333
+ entries.sort((a, b) => new Date(b.ts) - new Date(a.ts));
2334
+
2335
+ els.activityLog.innerHTML = '';
2336
+ els.logEmpty.classList.toggle('hidden', entries.length > 0);
2337
+
2338
+ for (const entry of entries.slice(0, 300)) {
2339
+ const f = formatLogEvent(entry.event);
2340
+ const tr = document.createElement('tr');
2341
+ tr.innerHTML = `
2342
+ <td class="time"></td>
2343
+ <td class="job"></td>
2344
+ <td class="event"></td>
2345
+ <td class="detail"></td>
2346
+ `;
2347
+ tr.querySelector('.time').textContent = formatTime(entry.event.ts);
2348
+ tr.querySelector('.job').textContent = jobTicketLabel(entry.job);
2349
+ tr.querySelector('.event').textContent = f.label;
2350
+ const detail = tr.querySelector('.detail');
2351
+ detail.textContent = f.text;
2352
+ if (f.raw && f.raw.trim()) {
2353
+ const details = document.createElement('details');
2354
+ details.className = 'log-raw';
2355
+ const summary = document.createElement('summary');
2356
+ summary.textContent = 'Raw';
2357
+ const pre = document.createElement('pre');
2358
+ pre.className = 'log-raw-pre';
2359
+ pre.textContent = f.raw;
2360
+ details.appendChild(summary);
2361
+ details.appendChild(pre);
2362
+ detail.appendChild(details);
2363
+ }
2364
+ els.activityLog.appendChild(tr);
2365
+ }
2366
+ }
2367
+
2368
+ function jobCardShort(job) {
2369
+ return jobTicketLabel(job);
2370
+ }
2371
+
2372
+ function renderAlerts(jobs) {
2373
+ const alerts = collectAlerts(jobs);
2374
+ els.alertsList.innerHTML = '';
2375
+ els.alertsEmpty.classList.toggle('hidden', alerts.length > 0);
2376
+
2377
+ for (const alert of alerts) {
2378
+ const card = document.createElement('div');
2379
+ card.className = 'alert-card';
2380
+ card.innerHTML = `
2381
+ <span class="alert-icon" aria-hidden="true">⚠</span>
2382
+ <div class="alert-body">
2383
+ <div class="alert-title"></div>
2384
+ <div class="alert-meta"></div>
2385
+ <div class="alert-message"></div>
2386
+ </div>
2387
+ <div class="alert-actions"></div>
2388
+ `;
2389
+ card.querySelector('.alert-title').textContent = alert.title;
2390
+ card.querySelector('.alert-meta').textContent =
2391
+ `${alert.number} · ${alert.time}`;
2392
+ card.querySelector('.alert-message').textContent = alert.text;
2393
+ const actions = card.querySelector('.alert-actions');
2394
+
2395
+ if (alert.actions.includes('retry')) {
2396
+ const retry = document.createElement('button');
2397
+ retry.type = 'button';
2398
+ retry.className = 'btn btn-primary btn-sm';
2399
+ retry.textContent = 'Retry';
2400
+ retry.addEventListener('click', async () => {
2401
+ await fetch(`/api/jobs/${alert.jobId}/retry`, { method: 'POST' });
2402
+ await fetchJobs();
2403
+ setView('runs');
2404
+ selectRun(alert.jobId);
2405
+ });
2406
+ actions.appendChild(retry);
2407
+ }
2408
+
2409
+ if (alert.actions.includes('clear')) {
2410
+ const clear = document.createElement('button');
2411
+ clear.type = 'button';
2412
+ clear.className = 'btn btn-secondary btn-sm';
2413
+ clear.textContent = 'Clear';
2414
+ clear.addEventListener('click', () => clearJob(alert.jobId));
2415
+ actions.appendChild(clear);
2416
+ }
2417
+
2418
+ const dismiss = document.createElement('button');
2419
+ dismiss.type = 'button';
2420
+ dismiss.className = 'btn btn-secondary btn-sm';
2421
+ dismiss.textContent = 'Dismiss';
2422
+ dismiss.addEventListener('click', () => {
2423
+ dismissedAlertIds.push(alert.id);
2424
+ saveDismissed();
2425
+ renderAll();
2426
+ });
2427
+ actions.appendChild(dismiss);
2428
+
2429
+ els.alertsList.appendChild(card);
2430
+ }
2431
+ }
2432
+
2433
+ function renderAll() {
2434
+ const jobs = sortedJobs();
2435
+ updateNavBadges(jobs);
2436
+ renderStats(jobs);
2437
+ renderOverview(jobs);
2438
+ renderRuns(jobs);
2439
+ renderReview(jobs);
2440
+ renderActivityLog(jobs);
2441
+ renderAlerts(jobs);
2442
+ }
2443
+
2444
+ function connectSSE(jobId) {
2445
+ if (eventSource) {
2446
+ eventSource.close();
2447
+ eventSource = null;
2448
+ }
2449
+
2450
+ const job = jobsById[jobId];
2451
+ // Defer until DOM from renderRuns exists
2452
+ requestAnimationFrame(() => {
2453
+ renderLogPane(job?.logs || []);
2454
+ });
2455
+
2456
+ eventSource = new EventSource(`/api/jobs/${jobId}/events`);
2457
+ const seen = new Set(
2458
+ (job?.logs || []).map((e) => `${e.ts}:${e.type}:${JSON.stringify(e.payload)}`)
2459
+ );
2460
+
2461
+ eventSource.onmessage = (e) => {
2462
+ const event = JSON.parse(e.data);
2463
+ const key = `${event.ts}:${event.type}:${JSON.stringify(event.payload)}`;
2464
+ if (seen.has(key)) return;
2465
+ seen.add(key);
2466
+ if (currentView === 'runs' && selectedRunId === jobId) {
2467
+ appendLogLine(event);
2468
+ }
2469
+ if (event.type === 'status' || event.type === 'error') {
2470
+ fetchJobs();
2471
+ }
2472
+ };
2473
+
2474
+ eventSource.onerror = () => {
2475
+ // EventSource auto-reconnects; polling keeps job state fresh
2476
+ };
2477
+ }
2478
+
2479
+ function selectRun(jobId) {
2480
+ const changed = selectedRunId !== jobId;
2481
+ selectedRunId = jobId;
2482
+ if (changed) {
2483
+ logsPinnedToBottom = true;
2484
+ logsScrollTop = 0;
2485
+ }
2486
+ renderRuns(sortedJobs());
2487
+ if (changed || !eventSource) {
2488
+ connectSSE(jobId);
2489
+ } else {
2490
+ requestAnimationFrame(() => renderLogPane(jobsById[jobId]?.logs || []));
2491
+ }
2492
+ }
2493
+
2494
+ function selectReview(jobId) {
2495
+ selectedReviewId = jobId;
2496
+ const job = jobsById[jobId];
2497
+ if (job && REVIEW_STATUSES.has(job.status)) {
2498
+ const def = REVIEW_FILTERS[reviewFilter];
2499
+ if (def?.statuses && !def.statuses.has(job.status)) {
2500
+ if (job.status === 'awaiting_review') reviewFilter = 'pending';
2501
+ else if (job.status === 'pr_opened') reviewFilter = 'approved';
2502
+ else if (job.status === 'discarded') reviewFilter = 'rejected';
2503
+ else if (job.status === 'failed') reviewFilter = 'failed';
2504
+ else reviewFilter = 'all';
2505
+ saveReviewFilter();
2506
+ }
2507
+ const q = reviewSearch.trim().toLowerCase();
2508
+ if (q && !jobMatchesReviewSearch(job, q)) {
2509
+ reviewSearch = '';
2510
+ if (els.reviewSearch) els.reviewSearch.value = '';
2511
+ saveReviewSearch();
2512
+ }
2513
+ }
2514
+ renderReview(sortedJobs());
2515
+ if (job?.status === 'awaiting_review') {
2516
+ ensureReviewFiles(jobId);
2517
+ }
2518
+ }
2519
+
2520
+ async function clearJob(jobId) {
2521
+ if (
2522
+ !confirm(
2523
+ 'Clear this job from history? Worktree/branch will be removed if present. You can re-queue the issue afterward.'
2524
+ )
2525
+ ) {
2526
+ return;
2527
+ }
2528
+
2529
+ try {
2530
+ const res = await fetch(`/api/jobs/${jobId}`, { method: 'DELETE' });
2531
+ const data = await res.json().catch(() => ({}));
2532
+ if (!res.ok) {
2533
+ alert(data.error || `Clear failed (HTTP ${res.status})`);
2534
+ return;
2535
+ }
2536
+
2537
+ delete jobsById[jobId];
2538
+ if (selectedRunId === jobId) {
2539
+ selectedRunId = null;
2540
+ if (eventSource) {
2541
+ eventSource.close();
2542
+ eventSource = null;
2543
+ }
2544
+ }
2545
+ if (selectedReviewId === jobId) {
2546
+ selectedReviewId = null;
2547
+ }
2548
+ clearExcludedPaths(jobId);
2549
+ delete reviewFileLists[jobId];
2550
+ clearReviewDraft(jobId);
2551
+ await fetchJobs();
2552
+ } catch (err) {
2553
+ alert(`Clear failed: ${err.message}`);
2554
+ }
2555
+ }
2556
+
2557
+ async function fetchJobs() {
2558
+ try {
2559
+ const res = await fetch('/api/jobs');
2560
+ const jobs = await res.json();
2561
+ jobsById = Object.fromEntries(jobs.map((j) => [j.id, j]));
2562
+ renderAll();
2563
+
2564
+ if (currentView === 'runs' && selectedRunId && jobsById[selectedRunId]) {
2565
+ // Keep SSE; refresh card chrome via renderRuns already called
2566
+ }
2567
+ } catch (err) {
2568
+ console.error('Failed to fetch jobs:', err);
2569
+ }
2570
+ }
2571
+
2572
+ async function readJson(res) {
2573
+ const ct = res.headers.get('content-type') || '';
2574
+ const text = await res.text();
2575
+ if (!ct.includes('application/json')) {
2576
+ const snippet = text.trim().slice(0, 80).replace(/\s+/g, ' ');
2577
+ throw new Error(
2578
+ `Expected JSON from ${res.url || 'API'} (HTTP ${res.status}), got ${ct || 'unknown type'}: ${snippet}`
2579
+ );
2580
+ }
2581
+ try {
2582
+ return text ? JSON.parse(text) : {};
2583
+ } catch {
2584
+ throw new Error(`Invalid JSON (HTTP ${res.status})`);
2585
+ }
2586
+ }
2587
+
2588
+ function updateModelButtons() {
2589
+ els.modelToggle.querySelectorAll('.model-btn').forEach((btn) => {
2590
+ btn.classList.toggle('active', btn.dataset.model === currentModel);
2591
+ });
2592
+ if (els.settingsModel && els.settingsModel.value !== currentModel) {
2593
+ const opt = [...els.settingsModel.options].find((o) => o.value === currentModel);
2594
+ if (opt) els.settingsModel.value = currentModel;
2595
+ }
2596
+ }
2597
+
2598
+ function msToMinutes(ms) {
2599
+ const n = Number(ms);
2600
+ if (!Number.isFinite(n) || n <= 0) return 15;
2601
+ return Math.max(1, Math.round(n / 60_000));
2602
+ }
2603
+
2604
+ function setSettingsFeedback(message, kind = 'ok') {
2605
+ if (!els.settingsFeedback) return;
2606
+ if (!message) {
2607
+ els.settingsFeedback.textContent = '';
2608
+ els.settingsFeedback.className = 'feedback hidden';
2609
+ return;
2610
+ }
2611
+ els.settingsFeedback.textContent = message;
2612
+ els.settingsFeedback.className = `feedback ${kind}`;
2613
+ els.settingsFeedback.classList.remove('hidden');
2614
+ }
2615
+
2616
+ function knownToolsList(cfg = appConfig) {
2617
+ return Array.isArray(cfg.knownTools) && cfg.knownTools.length
2618
+ ? cfg.knownTools
2619
+ : DEFAULT_KNOWN_TOOLS;
2620
+ }
2621
+
2622
+ function fillSettingsForm(cfg) {
2623
+ if (!els.settingsForm || !cfg) return;
2624
+
2625
+ updateTicketSourceUI(cfg.ticketSource === 'jira' ? 'jira' : 'github');
2626
+
2627
+ if (els.settingsJiraBaseUrl) {
2628
+ els.settingsJiraBaseUrl.value = cfg.jiraBaseUrl || '';
2629
+ }
2630
+ if (els.settingsJiraEmail) {
2631
+ els.settingsJiraEmail.value = cfg.jiraEmail || '';
2632
+ }
2633
+ if (els.settingsJiraToken) {
2634
+ els.settingsJiraToken.value = '';
2635
+ els.settingsJiraToken.placeholder = cfg.jiraApiTokenSet
2636
+ ? `Saved (${cfg.jiraApiTokenMasked || '••••'}) — leave blank to keep`
2637
+ : 'Paste API token';
2638
+ }
2639
+ if (els.settingsJiraTokenHint) {
2640
+ els.settingsJiraTokenHint.innerHTML = cfg.jiraConfigured
2641
+ ? 'Connected — token stored in <code>.acdev/.env</code> (not committed).'
2642
+ : 'Create a token at id.atlassian.com → Security → API tokens. Stored in <code>.acdev/.env</code> (not committed).';
2643
+ }
2644
+ if (els.settingsJiraPrPhrase) {
2645
+ els.settingsJiraPrPhrase.value = cfg.jiraPrLinkPhrase || 'Relates to';
2646
+ }
2647
+
2648
+ const jiraRule = cfg.jiraRules?.afterPrOpened || {};
2649
+ if (els.settingsJiraRuleEnabled) {
2650
+ els.settingsJiraRuleEnabled.checked = Boolean(jiraRule.enabled);
2651
+ }
2652
+ if (els.settingsJiraRuleStatus) {
2653
+ els.settingsJiraRuleStatus.value = jiraRule.targetStatus || 'In Review';
2654
+ }
2655
+
2656
+ const ghRule = cfg.githubRules?.afterPrOpened || {};
2657
+ if (els.settingsGithubRuleEnabled) {
2658
+ els.settingsGithubRuleEnabled.checked = Boolean(ghRule.enabled);
2659
+ }
2660
+ if (els.settingsGithubRuleAction) {
2661
+ const action = ['none', 'add_label', 'close_issue'].includes(ghRule.action)
2662
+ ? ghRule.action
2663
+ : 'none';
2664
+ els.settingsGithubRuleAction.value = action;
2665
+ }
2666
+ if (els.settingsGithubRuleLabel) {
2667
+ els.settingsGithubRuleLabel.value = ghRule.label || '';
2668
+ }
2669
+ updateGithubRuleLabelVisibility();
2670
+
2671
+ if (els.jiraStatus) {
2672
+ els.jiraStatus.textContent = cfg.jiraConfigured
2673
+ ? 'Credentials on file'
2674
+ : ticketSource === 'jira'
2675
+ ? 'Connect Jira to enqueue issues'
2676
+ : '';
2677
+ els.jiraStatus.className = cfg.jiraConfigured ? 'jira-status ok' : 'jira-status';
2678
+ }
2679
+
2680
+ if (els.settingsBaseBranch) {
2681
+ els.settingsBaseBranch.value = cfg.baseBranch || '';
2682
+ }
2683
+
2684
+ if (els.settingsModel) {
2685
+ const models = Array.isArray(cfg.models) ? cfg.models : [];
2686
+ const currentIds = [...els.settingsModel.options].map((o) => o.value);
2687
+ const nextIds = models.map((m) => m.id);
2688
+ if (currentIds.join(',') !== nextIds.join(',')) {
2689
+ els.settingsModel.innerHTML = '';
2690
+ for (const m of models) {
2691
+ const opt = document.createElement('option');
2692
+ opt.value = m.id;
2693
+ opt.textContent = m.label || m.id;
2694
+ els.settingsModel.appendChild(opt);
2695
+ }
2696
+ }
2697
+ if (cfg.model) els.settingsModel.value = cfg.model;
2698
+ }
2699
+
2700
+ if (els.settingsMaxTurns) {
2701
+ els.settingsMaxTurns.value = String(cfg.maxAgentTurns ?? 30);
2702
+ }
2703
+
2704
+ if (els.settingsTimeout) {
2705
+ const minutes = msToMinutes(cfg.agentTimeoutMs ?? 900_000);
2706
+ els.settingsTimeout.value = String(minutes);
2707
+ updateTimeoutMsHint(minutes);
2708
+ }
2709
+
2710
+ if (els.settingsTestCommand) {
2711
+ els.settingsTestCommand.value =
2712
+ cfg.testCommand == null ? '' : String(cfg.testCommand);
2713
+ }
2714
+
2715
+ if (els.settingsTools) {
2716
+ const tools = knownToolsList(cfg);
2717
+ const selected = new Set(
2718
+ Array.isArray(cfg.allowedTools) ? cfg.allowedTools : tools
2719
+ );
2720
+ els.settingsTools.innerHTML = '';
2721
+ for (const tool of tools) {
2722
+ const label = document.createElement('label');
2723
+ label.className = 'tool-check';
2724
+ const input = document.createElement('input');
2725
+ input.type = 'checkbox';
2726
+ input.name = 'allowedTools';
2727
+ input.value = tool;
2728
+ input.checked = selected.has(tool);
2729
+ const span = document.createElement('span');
2730
+ span.textContent = tool;
2731
+ label.appendChild(input);
2732
+ label.appendChild(span);
2733
+ els.settingsTools.appendChild(label);
2734
+ }
2735
+ }
2736
+ }
2737
+
2738
+ /**
2739
+ * Show GitHub label field only when action is add_label.
2740
+ */
2741
+ function updateGithubRuleLabelVisibility() {
2742
+ if (!els.settingsGithubRuleLabelField || !els.settingsGithubRuleAction) return;
2743
+ const show = els.settingsGithubRuleAction.value === 'add_label';
2744
+ els.settingsGithubRuleLabelField.classList.toggle('hidden', !show);
2745
+ }
2746
+
2747
+ /**
2748
+ * @param {'github' | 'jira'} source
2749
+ */
2750
+ function updateTicketSourceUI(source) {
2751
+ ticketSource = source === 'jira' ? 'jira' : 'github';
2752
+
2753
+ if (els.settingsTicketSource) {
2754
+ els.settingsTicketSource.querySelectorAll('.source-btn').forEach((btn) => {
2755
+ const active = btn.dataset.source === ticketSource;
2756
+ btn.classList.toggle('active', active);
2757
+ });
2758
+ }
2759
+
2760
+ if (els.jiraConnectPanel) {
2761
+ els.jiraConnectPanel.classList.toggle('hidden', ticketSource !== 'jira');
2762
+ }
2763
+
2764
+ if (els.jiraRulesPanel) {
2765
+ els.jiraRulesPanel.classList.toggle('hidden', ticketSource !== 'jira');
2766
+ }
2767
+ if (els.githubRulesPanel) {
2768
+ els.githubRulesPanel.classList.toggle('hidden', ticketSource !== 'github');
2769
+ }
2770
+
2771
+ if (els.ticketSourceMeta) {
2772
+ els.ticketSourceMeta.textContent =
2773
+ ticketSource === 'jira' ? 'Tickets: Jira' : 'Tickets: GitHub';
2774
+ }
2775
+
2776
+ if (els.issueUrls) {
2777
+ if (ticketSource === 'jira') {
2778
+ els.issueUrls.placeholder =
2779
+ 'Paste Jira keys or browse URLs — one per line, or comma-separated\ne.g. PROJ-123 or https://your-domain.atlassian.net/browse/PROJ-123';
2780
+ } else {
2781
+ els.issueUrls.placeholder =
2782
+ 'Paste GitHub issue links — one per line, or comma-separated\ne.g. https://github.com/acme/storefront/issues/482';
2783
+ }
2784
+ }
2785
+
2786
+ if (els.issueUrlsLabel) {
2787
+ els.issueUrlsLabel.textContent =
2788
+ ticketSource === 'jira' ? 'Jira issue keys or URLs' : 'GitHub issue URLs';
2789
+ }
2790
+
2791
+ if (els.enqueueHint && !els.enqueueHint.classList.contains('hidden')) {
2792
+ els.enqueueHint.textContent =
2793
+ ticketSource === 'jira'
2794
+ ? 'Jira issues run one at a time. PRs still open on GitHub via gh.'
2795
+ : 'Issues run one at a time, each in its own workspace and branch.';
2796
+ }
2797
+
2798
+ if (els.overviewQueueEmpty) {
2799
+ els.overviewQueueEmpty.textContent =
2800
+ ticketSource === 'jira'
2801
+ ? 'Nothing queued. Paste Jira keys or browse URLs above to get started.'
2802
+ : 'Nothing queued. Paste issue links above to get started.';
2803
+ }
2804
+ }
2805
+
2806
+ function updateTimeoutMsHint(minutes) {
2807
+ if (!els.settingsTimeoutMs) return;
2808
+ const m = Number(minutes);
2809
+ if (!Number.isFinite(m) || m <= 0) {
2810
+ els.settingsTimeoutMs.textContent = '—';
2811
+ return;
2812
+ }
2813
+ els.settingsTimeoutMs.textContent = String(Math.round(m * 60_000));
2814
+ }
2815
+
2816
+ function applyConfigSnapshot(data) {
2817
+ appConfig = data || {};
2818
+ if (data?.model) currentModel = data.model;
2819
+ updateModelButtons();
2820
+ updateTicketSourceUI(data?.ticketSource === 'jira' ? 'jira' : 'github');
2821
+ if (els.repoName) {
2822
+ els.repoName.textContent = data?.repoName || 'local repo';
2823
+ }
2824
+ if (els.repoBranch) {
2825
+ els.repoBranch.textContent = data?.baseBranch || '—';
2826
+ }
2827
+ if (currentView === 'settings') {
2828
+ fillSettingsForm(appConfig);
2829
+ }
2830
+ }
2831
+
2832
+ async function fetchConfig() {
2833
+ try {
2834
+ const res = await fetch('/api/config');
2835
+ if (!res.ok) return;
2836
+ const data = await readJson(res);
2837
+ applyConfigSnapshot(data);
2838
+ } catch (err) {
2839
+ console.error('Failed to fetch config:', err);
2840
+ }
2841
+ }
2842
+
2843
+ async function setModel(model) {
2844
+ if (modelSaving || model === currentModel) return;
2845
+ modelSaving = true;
2846
+ const prev = currentModel;
2847
+ currentModel = model;
2848
+ updateModelButtons();
2849
+ try {
2850
+ const res = await fetch('/api/config', {
2851
+ method: 'PATCH',
2852
+ headers: { 'Content-Type': 'application/json' },
2853
+ body: JSON.stringify({ model }),
2854
+ });
2855
+ const data = await readJson(res);
2856
+ if (!res.ok) {
2857
+ currentModel = prev;
2858
+ updateModelButtons();
2859
+ alert(`Model update failed: ${data.error || `HTTP ${res.status}`}`);
2860
+ return;
2861
+ }
2862
+ applyConfigSnapshot({ ...appConfig, ...data });
2863
+ } catch (err) {
2864
+ currentModel = prev;
2865
+ updateModelButtons();
2866
+ alert(`Model update failed: ${err.message}`);
2867
+ } finally {
2868
+ modelSaving = false;
2869
+ }
2870
+ }
2871
+
2872
+ function readSettingsForm() {
2873
+ const baseBranch = els.settingsBaseBranch?.value?.trim() || '';
2874
+ const model = els.settingsModel?.value || currentModel;
2875
+ const maxAgentTurns = Number(els.settingsMaxTurns?.value);
2876
+ const timeoutMinutes = Number(els.settingsTimeout?.value);
2877
+ const testRaw = els.settingsTestCommand?.value ?? '';
2878
+ const testTrimmed = testRaw.trim();
2879
+ const allowedTools = [
2880
+ ...(els.settingsTools?.querySelectorAll('input[name="allowedTools"]:checked') ||
2881
+ []),
2882
+ ].map((el) => el.value);
2883
+
2884
+ /** @type {Record<string, unknown>} */
2885
+ const patch = {
2886
+ baseBranch,
2887
+ model,
2888
+ maxAgentTurns,
2889
+ agentTimeoutMs: Math.round(timeoutMinutes * 60_000),
2890
+ testCommand: testTrimmed === '' ? null : testTrimmed,
2891
+ allowedTools,
2892
+ ticketSource,
2893
+ };
2894
+
2895
+ if (ticketSource === 'jira' || els.settingsJiraBaseUrl?.value) {
2896
+ patch.jiraBaseUrl = els.settingsJiraBaseUrl?.value?.trim() || '';
2897
+ patch.jiraPrLinkPhrase =
2898
+ els.settingsJiraPrPhrase?.value?.trim() || 'Relates to';
2899
+ const email = els.settingsJiraEmail?.value?.trim() || '';
2900
+ if (email) patch.jiraEmail = email;
2901
+ const token = els.settingsJiraToken?.value?.trim() || '';
2902
+ if (token) patch.jiraApiToken = token;
2903
+ }
2904
+
2905
+ // Only PATCH the visible source's rules so saving on one source
2906
+ // never resets the other source's saved config (updateConfig merges).
2907
+ if (ticketSource === 'jira') {
2908
+ patch.jiraRules = {
2909
+ afterPrOpened: {
2910
+ enabled: Boolean(els.settingsJiraRuleEnabled?.checked),
2911
+ targetStatus:
2912
+ els.settingsJiraRuleStatus?.value?.trim() || 'In Review',
2913
+ },
2914
+ };
2915
+ } else {
2916
+ const ghAction = els.settingsGithubRuleAction?.value || 'none';
2917
+ patch.githubRules = {
2918
+ afterPrOpened: {
2919
+ enabled: Boolean(els.settingsGithubRuleEnabled?.checked),
2920
+ action: ['none', 'add_label', 'close_issue'].includes(ghAction)
2921
+ ? ghAction
2922
+ : 'none',
2923
+ label: els.settingsGithubRuleLabel?.value?.trim() || '',
2924
+ },
2925
+ };
2926
+ }
2927
+
2928
+ return patch;
2929
+ }
2930
+
2931
+ async function saveSettings(event) {
2932
+ event?.preventDefault?.();
2933
+ if (settingsSaving) return;
2934
+ settingsSaving = true;
2935
+ if (els.settingsSaveBtn) els.settingsSaveBtn.disabled = true;
2936
+ setSettingsFeedback(null);
2937
+
2938
+ try {
2939
+ const patch = readSettingsForm();
2940
+ if (!patch.baseBranch) {
2941
+ setSettingsFeedback('Base branch is required.', 'error');
2942
+ return;
2943
+ }
2944
+ if (!Number.isInteger(patch.maxAgentTurns) || patch.maxAgentTurns <= 0) {
2945
+ setSettingsFeedback('Max agent turns must be a positive integer.', 'error');
2946
+ return;
2947
+ }
2948
+ if (!Number.isInteger(patch.agentTimeoutMs) || patch.agentTimeoutMs <= 0) {
2949
+ setSettingsFeedback('Agent timeout must be at least 1 minute.', 'error');
2950
+ return;
2951
+ }
2952
+
2953
+ const res = await fetch('/api/config', {
2954
+ method: 'PATCH',
2955
+ headers: { 'Content-Type': 'application/json' },
2956
+ body: JSON.stringify(patch),
2957
+ });
2958
+ const data = await readJson(res);
2959
+ if (!res.ok) {
2960
+ setSettingsFeedback(data.error || `Save failed (HTTP ${res.status})`, 'error');
2961
+ return;
2962
+ }
2963
+ applyConfigSnapshot(data);
2964
+ fillSettingsForm(data);
2965
+ setSettingsFeedback('Settings saved. Next jobs will use these values.', 'ok');
2966
+ } catch (err) {
2967
+ setSettingsFeedback(err.message || 'Save failed', 'error');
2968
+ } finally {
2969
+ settingsSaving = false;
2970
+ if (els.settingsSaveBtn) els.settingsSaveBtn.disabled = false;
2971
+ }
2972
+ }
2973
+
2974
+ // —— Events ——
2975
+ document.querySelectorAll('.nav-item').forEach((btn) => {
2976
+ btn.addEventListener('click', () => setView(/** @type {ViewId} */ (btn.dataset.view)));
2977
+ });
2978
+
2979
+ els.themeToggle.addEventListener('click', () => {
2980
+ const next = els.app.dataset.theme === 'dark' ? 'light' : 'dark';
2981
+ applyTheme(next);
2982
+ });
2983
+
2984
+ els.modelToggle.querySelectorAll('.model-btn').forEach((btn) => {
2985
+ btn.addEventListener('click', () => setModel(btn.dataset.model));
2986
+ });
2987
+
2988
+ els.settingsForm?.addEventListener('submit', saveSettings);
2989
+ els.settingsTimeout?.addEventListener('input', () => {
2990
+ updateTimeoutMsHint(els.settingsTimeout.value);
2991
+ });
2992
+ els.settingsGithubRuleAction?.addEventListener('change', updateGithubRuleLabelVisibility);
2993
+
2994
+ els.addBtn.addEventListener('click', async () => {
2995
+ els.enqueueFeedback.classList.add('hidden');
2996
+ if (els.enqueueHint) els.enqueueHint.classList.remove('hidden');
2997
+ const text = els.issueUrls.value.trim();
2998
+ if (!text) return;
2999
+
3000
+ const urls = splitIssueUrls(text);
3001
+
3002
+ try {
3003
+ const res = await fetch('/api/issues', {
3004
+ method: 'POST',
3005
+ headers: { 'Content-Type': 'application/json' },
3006
+ body: JSON.stringify({ urls, ticketSource }),
3007
+ });
3008
+ const data = await res.json();
3009
+ if (!res.ok) {
3010
+ if (els.enqueueHint) els.enqueueHint.classList.add('hidden');
3011
+ els.enqueueFeedback.textContent =
3012
+ data.error + (data.invalidUrls ? `: ${data.invalidUrls.join(', ')}` : '');
3013
+ els.enqueueFeedback.className = 'feedback error';
3014
+ els.enqueueFeedback.classList.remove('hidden');
3015
+ return;
3016
+ }
3017
+ els.issueUrls.value = '';
3018
+ const parts = [];
3019
+ if (data.jobs?.length) parts.push(`Added ${data.jobs.length}`);
3020
+ if (data.skipped?.length) {
3021
+ parts.push(`Skipped duplicates: ${data.skipped.join(', ')}`);
3022
+ }
3023
+ if (parts.length) {
3024
+ if (els.enqueueHint) els.enqueueHint.classList.add('hidden');
3025
+ els.enqueueFeedback.textContent = parts.join(' · ');
3026
+ els.enqueueFeedback.className =
3027
+ 'feedback ' + (data.skipped?.length ? 'warn' : 'ok');
3028
+ els.enqueueFeedback.classList.remove('hidden');
3029
+ }
3030
+ await fetchJobs();
3031
+ if (data.jobs?.length) {
3032
+ setView('runs');
3033
+ selectRun(data.jobs[0].id);
3034
+ }
3035
+ } catch (err) {
3036
+ if (els.enqueueHint) els.enqueueHint.classList.add('hidden');
3037
+ els.enqueueFeedback.textContent = err.message;
3038
+ els.enqueueFeedback.className = 'feedback error';
3039
+ els.enqueueFeedback.classList.remove('hidden');
3040
+ }
3041
+ });
3042
+
3043
+ els.settingsTicketSource?.addEventListener('click', async (e) => {
3044
+ const btn = e.target.closest('.source-btn');
3045
+ if (!btn?.dataset.source) return;
3046
+ const next = btn.dataset.source === 'jira' ? 'jira' : 'github';
3047
+ updateTicketSourceUI(next);
3048
+ try {
3049
+ const res = await fetch('/api/config', {
3050
+ method: 'PATCH',
3051
+ headers: { 'Content-Type': 'application/json' },
3052
+ body: JSON.stringify({ ticketSource: next }),
3053
+ });
3054
+ const data = await readJson(res);
3055
+ if (res.ok) {
3056
+ applyConfigSnapshot({ ...appConfig, ...data });
3057
+ }
3058
+ } catch {
3059
+ // local UI already updated; save via Settings if needed
3060
+ }
3061
+ });
3062
+
3063
+ els.jiraTestBtn?.addEventListener('click', async () => {
3064
+ if (!els.jiraTestBtn || !els.jiraStatus) return;
3065
+ els.jiraTestBtn.disabled = true;
3066
+ els.jiraStatus.textContent = 'Testing…';
3067
+ els.jiraStatus.className = 'jira-status';
3068
+ try {
3069
+ const body = {
3070
+ baseUrl: els.settingsJiraBaseUrl?.value?.trim() || undefined,
3071
+ email: els.settingsJiraEmail?.value?.trim() || undefined,
3072
+ apiToken: els.settingsJiraToken?.value?.trim() || undefined,
3073
+ };
3074
+ const res = await fetch('/api/jira/test', {
3075
+ method: 'POST',
3076
+ headers: { 'Content-Type': 'application/json' },
3077
+ body: JSON.stringify(body),
3078
+ });
3079
+ const data = await readJson(res);
3080
+ if (!res.ok || !data.ok) {
3081
+ els.jiraStatus.textContent = data.error || `Failed (HTTP ${res.status})`;
3082
+ els.jiraStatus.className = 'jira-status err';
3083
+ return;
3084
+ }
3085
+ els.jiraStatus.textContent = `Connected as ${data.displayName || 'OK'}`;
3086
+ els.jiraStatus.className = 'jira-status ok';
3087
+ } catch (err) {
3088
+ els.jiraStatus.textContent = err.message || 'Test failed';
3089
+ els.jiraStatus.className = 'jira-status err';
3090
+ } finally {
3091
+ els.jiraTestBtn.disabled = false;
3092
+ }
3093
+ });
3094
+
3095
+ async function approve(draft) {
3096
+ if (!selectedReviewId) return;
3097
+ const jobId = selectedReviewId;
3098
+ const files = getReviewFilePaths(jobId);
3099
+ const excludedPaths = getExcludedPathsForApprove(jobId);
3100
+ if (files.length > 0 && excludedPaths.length >= files.length) {
3101
+ alert('Keep at least one file included');
3102
+ return;
3103
+ }
3104
+
3105
+ const btns = [
3106
+ els.approveDraftBtn,
3107
+ els.approveReadyBtn,
3108
+ els.rejectBtn,
3109
+ els.submitReviewBtn,
3110
+ ].filter(Boolean);
3111
+ btns.forEach((b) => {
3112
+ b.disabled = true;
3113
+ b.dataset.busy = '1';
3114
+ });
3115
+ try {
3116
+ const res = await fetch(`/api/jobs/${jobId}/approve`, {
3117
+ method: 'POST',
3118
+ headers: { 'Content-Type': 'application/json' },
3119
+ body: JSON.stringify({
3120
+ prTitle: els.prTitle.value,
3121
+ prBody: els.prBody.value,
3122
+ draft,
3123
+ excludedPaths,
3124
+ }),
3125
+ });
3126
+ if (res.ok) {
3127
+ clearExcludedPaths(jobId);
3128
+ delete reviewFileLists[jobId];
3129
+ }
3130
+ await fetchJobs();
3131
+ if (!res.ok) {
3132
+ const data = await res.json();
3133
+ alert(`Approve failed: ${data.error}`);
3134
+ }
3135
+ } finally {
3136
+ btns.forEach((b) => {
3137
+ delete b.dataset.busy;
3138
+ b.disabled = false;
3139
+ });
3140
+ if (selectedReviewId) {
3141
+ syncSubmitReviewButton(selectedReviewId);
3142
+ syncApproveButtonsForFileSelection(selectedReviewId);
3143
+ }
3144
+ }
3145
+ }
3146
+
3147
+ async function submitReview() {
3148
+ if (!selectedReviewId) return;
3149
+ const jobId = selectedReviewId;
3150
+ const draft = loadReviewDraft(jobId);
3151
+ const generalComment = (els.reviewGeneralComment?.value || draft.generalComment || '').trim();
3152
+ draft.generalComment = generalComment;
3153
+ saveReviewDraft(jobId);
3154
+
3155
+ const lineComments = draft.lineComments
3156
+ .map((c) => ({
3157
+ path: c.path,
3158
+ line: c.line,
3159
+ side: c.side,
3160
+ body: c.body.trim(),
3161
+ }))
3162
+ .filter((c) => c.body);
3163
+
3164
+ if (!generalComment && lineComments.length === 0) {
3165
+ syncSubmitReviewButton(jobId);
3166
+ return;
3167
+ }
3168
+
3169
+ const btns = [
3170
+ els.submitReviewBtn,
3171
+ els.approveDraftBtn,
3172
+ els.approveReadyBtn,
3173
+ els.rejectBtn,
3174
+ ].filter(Boolean);
3175
+ btns.forEach((b) => {
3176
+ b.disabled = true;
3177
+ });
3178
+
3179
+ try {
3180
+ const res = await fetch(`/api/jobs/${jobId}/review`, {
3181
+ method: 'POST',
3182
+ headers: { 'Content-Type': 'application/json' },
3183
+ body: JSON.stringify({ generalComment, lineComments }),
3184
+ });
3185
+ const data = await res.json().catch(() => ({}));
3186
+ if (!res.ok) {
3187
+ alert(data.error || `Submit review failed (HTTP ${res.status})`);
3188
+ return;
3189
+ }
3190
+
3191
+ clearReviewDraft(jobId);
3192
+ if (els.reviewGeneralComment) els.reviewGeneralComment.value = '';
3193
+ selectedRunId = jobId;
3194
+ selectedReviewId = null;
3195
+ await fetchJobs();
3196
+ setView('runs');
3197
+ selectRun(jobId);
3198
+ } catch (err) {
3199
+ alert(`Submit review failed: ${err.message}`);
3200
+ } finally {
3201
+ btns.forEach((b) => {
3202
+ b.disabled = false;
3203
+ });
3204
+ if (selectedReviewId) syncSubmitReviewButton(selectedReviewId);
3205
+ }
3206
+ }
3207
+
3208
+ els.approveDraftBtn.addEventListener('click', () => approve(true));
3209
+ els.approveReadyBtn.addEventListener('click', () => approve(false));
3210
+ els.submitReviewBtn?.addEventListener('click', () => submitReview());
3211
+
3212
+ els.reviewFilesSelectAll?.addEventListener('click', () => {
3213
+ if (!selectedReviewId) return;
3214
+ const set = loadExcludedPaths(selectedReviewId);
3215
+ set.clear();
3216
+ saveExcludedPaths(selectedReviewId);
3217
+ renderReview(sortedJobs());
3218
+ });
3219
+
3220
+ els.reviewFilesDeselectAll?.addEventListener('click', () => {
3221
+ if (!selectedReviewId) return;
3222
+ const files = getReviewFilePaths(selectedReviewId);
3223
+ const set = loadExcludedPaths(selectedReviewId);
3224
+ for (const p of files) set.add(p);
3225
+ saveExcludedPaths(selectedReviewId);
3226
+ renderReview(sortedJobs());
3227
+ });
3228
+
3229
+ els.reviewGeneralComment?.addEventListener('input', () => {
3230
+ if (!selectedReviewId) return;
3231
+ const draft = loadReviewDraft(selectedReviewId);
3232
+ draft.generalComment = els.reviewGeneralComment.value;
3233
+ saveReviewDraft(selectedReviewId);
3234
+ syncSubmitReviewButton(selectedReviewId);
3235
+ });
3236
+
3237
+ els.rejectBtn.addEventListener('click', async () => {
3238
+ if (!selectedReviewId) return;
3239
+ await fetch(`/api/jobs/${selectedReviewId}/reject`, { method: 'POST' });
3240
+ selectedReviewId = null;
3241
+ await fetchJobs();
3242
+ });
3243
+
3244
+ els.reviewClearBtn.addEventListener('click', () => {
3245
+ if (!selectedReviewId) return;
3246
+ clearJob(selectedReviewId);
3247
+ });
3248
+
3249
+ els.reviewPrClearBtn.addEventListener('click', () => {
3250
+ if (!selectedReviewId) return;
3251
+ clearJob(selectedReviewId);
3252
+ });
3253
+
3254
+ els.reviewTerminalClearBtn?.addEventListener('click', () => {
3255
+ if (!selectedReviewId) return;
3256
+ clearJob(selectedReviewId);
3257
+ });
3258
+
3259
+ els.reviewRetryBtn?.addEventListener('click', async () => {
3260
+ if (!selectedReviewId) return;
3261
+ await fetch(`/api/jobs/${selectedReviewId}/retry`, { method: 'POST' });
3262
+ selectedReviewId = null;
3263
+ await fetchJobs();
3264
+ setView('runs');
3265
+ });
3266
+
3267
+ els.reviewFilters?.addEventListener('click', (e) => {
3268
+ const btn = e.target.closest('[data-review-filter]');
3269
+ if (!btn || !els.reviewFilters.contains(btn)) return;
3270
+ const id = btn.getAttribute('data-review-filter');
3271
+ if (!id || !(id in REVIEW_FILTERS) || id === reviewFilter) return;
3272
+ reviewFilter = /** @type {ReviewFilterId} */ (id);
3273
+ saveReviewFilter();
3274
+ renderReview(sortedJobs());
3275
+ });
3276
+
3277
+ els.reviewSearch?.addEventListener('input', () => {
3278
+ reviewSearch = els.reviewSearch.value;
3279
+ saveReviewSearch();
3280
+ renderReview(sortedJobs());
3281
+ });
3282
+
3283
+ els.logJobFilter.addEventListener('change', () => {
3284
+ logFilterJobId = els.logJobFilter.value;
3285
+ renderActivityLog(sortedJobs());
3286
+ });
3287
+
3288
+ applyTheme(loadTheme());
3289
+ fetchConfig();
3290
+ fetchJobs();
3291
+ setInterval(fetchJobs, 3000);