acdev 1.0.0 → 1.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acdev",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Local CLI + web UI for running AI agents on GitHub issues via git worktrees",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,6 +22,7 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@anthropic-ai/claude-agent-sdk": "0.1.77",
25
+ "acdev": "^1.0.0",
25
26
  "dotenv": "^17.4.2",
26
27
  "express": "^4.21.2",
27
28
  "open": "^10.1.0",
@@ -51,6 +52,5 @@
51
52
  "main": "index.js",
52
53
  "directories": {
53
54
  "test": "test"
54
- },
55
- "devDependencies": {}
55
+ }
56
56
  }
package/public/app.js CHANGED
@@ -106,9 +106,18 @@ let reviewFilter = loadReviewFilter();
106
106
  let reviewSearch = loadReviewSearch();
107
107
  /** @type {string | null} */
108
108
  let activeInlineCommentKey = null;
109
+ /** @type {string | null} id of line comment currently being edited */
110
+ let editingCommentId = null;
111
+ /** @type {'inline' | 'pending' | null} where the edit form should render */
112
+ let editingCommentSurface = null;
113
+ /** Original body when edit started — restored on Cancel after live sync. */
114
+ let editingCommentOriginalBody = null;
109
115
  /** @type {Record<string, {
110
116
  * generalComment: string,
111
- * lineComments: Array<{ id: string, path: string, line: number, side: 'LEFT' | 'RIGHT', body: string }>
117
+ * lineComments: Array<{ id: string, path: string, line: number, side: 'LEFT' | 'RIGHT', body: string }>,
118
+ * prTitle: string | null,
119
+ * prBody: string | null,
120
+ * prMetaDirty: boolean,
112
121
  * }>} */
113
122
  let reviewDrafts = {};
114
123
  /** @type {Record<string, string[]>} changed file paths per job (from API or parsed diff) */
@@ -199,6 +208,8 @@ const els = {
199
208
  reviewFilesDeselectAll: document.getElementById('review-files-deselect-all'),
200
209
  diffHeader: document.getElementById('diff-header'),
201
210
  diffViewer: document.getElementById('diff-viewer'),
211
+ diffSection: document.getElementById('diff-section'),
212
+ diffFullscreenBtn: document.getElementById('diff-fullscreen-btn'),
202
213
  approveDraftBtn: document.getElementById('approve-draft-btn'),
203
214
  approveReadyBtn: document.getElementById('approve-ready-btn'),
204
215
  rejectBtn: document.getElementById('reject-btn'),
@@ -294,11 +305,20 @@ function reviewDraftStorageKey(jobId) {
294
305
  * @param {string} jobId
295
306
  * @returns {{
296
307
  * generalComment: string,
297
- * lineComments: Array<{ id: string, path: string, line: number, side: 'LEFT' | 'RIGHT', body: string }>
308
+ * lineComments: Array<{ id: string, path: string, line: number, side: 'LEFT' | 'RIGHT', body: string }>,
309
+ * prTitle: string | null,
310
+ * prBody: string | null,
311
+ * prMetaDirty: boolean,
298
312
  * }}
299
313
  */
300
314
  function emptyReviewDraft() {
301
- return { generalComment: '', lineComments: [] };
315
+ return {
316
+ generalComment: '',
317
+ lineComments: [],
318
+ prTitle: null,
319
+ prBody: null,
320
+ prMetaDirty: false,
321
+ };
302
322
  }
303
323
 
304
324
  /** @param {string} jobId */
@@ -329,6 +349,9 @@ function loadReviewDraft(jobId) {
329
349
  body: c.body,
330
350
  }))
331
351
  : [],
352
+ prTitle: typeof parsed?.prTitle === 'string' ? parsed.prTitle : null,
353
+ prBody: typeof parsed?.prBody === 'string' ? parsed.prBody : null,
354
+ prMetaDirty: Boolean(parsed?.prMetaDirty),
332
355
  };
333
356
  reviewDrafts[jobId] = draft;
334
357
  return draft;
@@ -352,10 +375,84 @@ function saveReviewDraft(jobId) {
352
375
  }
353
376
  }
354
377
 
378
+ /**
379
+ * Resolve editable PR title/body for a job (local draft wins when dirty).
380
+ * @param {{ id: string, prTitle?: string, prBody?: string }} job
381
+ */
382
+ function resolvePrMeta(job) {
383
+ const draft = loadReviewDraft(job.id);
384
+ if (!draft.prMetaDirty) {
385
+ return {
386
+ prTitle: job.prTitle || '',
387
+ prBody: job.prBody || '',
388
+ };
389
+ }
390
+ return {
391
+ prTitle: draft.prTitle != null ? draft.prTitle : job.prTitle || '',
392
+ prBody: draft.prBody != null ? draft.prBody : job.prBody || '',
393
+ };
394
+ }
395
+
396
+ /**
397
+ * Persist PR title/body edits from the form into the review draft.
398
+ * @param {string} jobId
399
+ */
400
+ function persistPrMetaFromForm(jobId) {
401
+ if (!els.prTitle || !els.prBody) return;
402
+ const draft = loadReviewDraft(jobId);
403
+ draft.prTitle = els.prTitle.value;
404
+ draft.prBody = els.prBody.value;
405
+ draft.prMetaDirty = true;
406
+ saveReviewDraft(jobId);
407
+ }
408
+
409
+ /**
410
+ * Keep PR title/body across comment-only draft clears (submit review).
411
+ * @param {string} jobId
412
+ */
413
+ function clearReviewCommentDraft(jobId) {
414
+ const prev = loadReviewDraft(jobId);
415
+ const kept = {
416
+ prTitle: prev.prTitle,
417
+ prBody: prev.prBody,
418
+ prMetaDirty: prev.prMetaDirty,
419
+ };
420
+ clearReviewDraft(jobId);
421
+ if (kept.prMetaDirty) {
422
+ const draft = loadReviewDraft(jobId);
423
+ draft.prTitle = kept.prTitle;
424
+ draft.prBody = kept.prBody;
425
+ draft.prMetaDirty = true;
426
+ saveReviewDraft(jobId);
427
+ }
428
+ }
429
+
430
+ function isDiffFullscreen() {
431
+ return Boolean(els.diffSection?.classList.contains('diff-fullscreen'));
432
+ }
433
+
434
+ /** @param {boolean} on */
435
+ function setDiffFullscreen(on) {
436
+ if (!els.diffSection) return;
437
+ els.diffSection.classList.toggle('diff-fullscreen', on);
438
+ document.body.classList.toggle('diff-fs-open', on);
439
+ if (els.diffFullscreenBtn) {
440
+ els.diffFullscreenBtn.textContent = on ? 'Exit fullscreen' : 'Fullscreen';
441
+ els.diffFullscreenBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
442
+ }
443
+ }
444
+
445
+ function toggleDiffFullscreen() {
446
+ setDiffFullscreen(!isDiffFullscreen());
447
+ }
448
+
355
449
  /** @param {string} jobId */
356
450
  function clearReviewDraft(jobId) {
357
451
  delete reviewDrafts[jobId];
358
452
  activeInlineCommentKey = null;
453
+ editingCommentId = null;
454
+ editingCommentSurface = null;
455
+ editingCommentOriginalBody = null;
359
456
  try {
360
457
  sessionStorage.removeItem(reviewDraftStorageKey(jobId));
361
458
  } catch {
@@ -363,6 +460,155 @@ function clearReviewDraft(jobId) {
363
460
  }
364
461
  }
365
462
 
463
+ /** Clear in-progress line-comment edit state. */
464
+ function clearCommentEditState() {
465
+ editingCommentId = null;
466
+ editingCommentSurface = null;
467
+ editingCommentOriginalBody = null;
468
+ }
469
+
470
+ /**
471
+ * @param {string} jobId
472
+ * @param {string} commentId
473
+ * @param {string} body
474
+ */
475
+ function updateLineCommentBody(jobId, commentId, body) {
476
+ const d = loadReviewDraft(jobId);
477
+ const target = d.lineComments.find((x) => x.id === commentId);
478
+ if (!target) return;
479
+ target.body = body;
480
+ saveReviewDraft(jobId);
481
+ }
482
+
483
+ /**
484
+ * Refresh the non-active edit surface so both views stay in sync.
485
+ * @param {string} jobId
486
+ */
487
+ function mirrorCommentEditToOtherSurface(jobId) {
488
+ if (editingCommentSurface === 'inline') {
489
+ renderPendingCommentList(jobId);
490
+ syncSubmitReviewButton(jobId);
491
+ return;
492
+ }
493
+ if (editingCommentSurface === 'pending') {
494
+ const job = jobsById[jobId];
495
+ if (job) {
496
+ const diff =
497
+ job.status === 'awaiting_review'
498
+ ? filterDiffForReviewJob(job)
499
+ : job.diff || '';
500
+ renderDiff(diff, {
501
+ interactive: job.status === 'awaiting_review',
502
+ jobId,
503
+ });
504
+ }
505
+ syncSubmitReviewButton(jobId);
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Commit or discard an in-progress comment edit.
511
+ * @param {string} jobId
512
+ * @param {string} commentId
513
+ * @param {string | null} nextBody null = cancel / restore original
514
+ */
515
+ function finishCommentEdit(jobId, commentId, nextBody) {
516
+ if (nextBody == null) {
517
+ if (editingCommentOriginalBody != null) {
518
+ updateLineCommentBody(jobId, commentId, editingCommentOriginalBody);
519
+ }
520
+ } else {
521
+ updateLineCommentBody(jobId, commentId, nextBody);
522
+ }
523
+ clearCommentEditState();
524
+ refreshReviewCommentUi(jobId);
525
+ }
526
+
527
+ /**
528
+ * Start editing a pending line comment on a specific surface.
529
+ * @param {string} commentId
530
+ * @param {'inline' | 'pending'} surface
531
+ * @param {string} jobId
532
+ */
533
+ function startCommentEdit(commentId, surface, jobId) {
534
+ const draft = loadReviewDraft(jobId);
535
+ const target = draft.lineComments.find((x) => x.id === commentId);
536
+ editingCommentId = commentId;
537
+ editingCommentSurface = surface;
538
+ editingCommentOriginalBody = target ? target.body : '';
539
+ activeInlineCommentKey = null;
540
+ refreshReviewCommentUi(jobId);
541
+ }
542
+
543
+ /**
544
+ * Re-render diff + pending list after draft mutation.
545
+ * @param {string} jobId
546
+ */
547
+ function refreshReviewCommentUi(jobId) {
548
+ const job = jobsById[jobId];
549
+ if (job) {
550
+ const diff =
551
+ job.status === 'awaiting_review'
552
+ ? filterDiffForReviewJob(job)
553
+ : job.diff || '';
554
+ renderDiff(diff, {
555
+ interactive: job.status === 'awaiting_review',
556
+ jobId,
557
+ });
558
+ }
559
+ renderPendingCommentList(jobId);
560
+ syncSubmitReviewButton(jobId);
561
+ }
562
+
563
+ /**
564
+ * @param {{
565
+ * initialBody: string,
566
+ * saveLabel?: string,
567
+ * onSave: (body: string) => void,
568
+ * onCancel: () => void,
569
+ * onInput?: (body: string) => void,
570
+ * }} opts
571
+ */
572
+ function buildCommentEditForm(opts) {
573
+ const form = document.createElement('div');
574
+ form.className = 'diff-inline-form';
575
+ const ta = document.createElement('textarea');
576
+ ta.className = 'input textarea';
577
+ ta.rows = 3;
578
+ ta.value = opts.initialBody;
579
+ ta.placeholder = 'Edit comment…';
580
+ if (opts.onInput) {
581
+ ta.addEventListener('input', () => opts.onInput(ta.value));
582
+ }
583
+ const actions = document.createElement('div');
584
+ actions.className = 'diff-inline-actions';
585
+ const save = document.createElement('button');
586
+ save.type = 'button';
587
+ save.className = 'btn btn-primary btn-sm';
588
+ save.textContent = opts.saveLabel || 'Save';
589
+ const cancel = document.createElement('button');
590
+ cancel.type = 'button';
591
+ cancel.className = 'btn btn-secondary btn-sm';
592
+ cancel.textContent = 'Cancel';
593
+ cancel.addEventListener('click', () => opts.onCancel());
594
+ save.addEventListener('click', () => {
595
+ const body = ta.value.trim();
596
+ if (!body) return;
597
+ opts.onSave(body);
598
+ });
599
+ actions.appendChild(save);
600
+ actions.appendChild(cancel);
601
+ form.appendChild(ta);
602
+ form.appendChild(actions);
603
+ requestAnimationFrame(() => {
604
+ ta.focus();
605
+ const len = ta.value.length;
606
+ ta.setSelectionRange(len, len);
607
+ ta.scrollIntoView({ block: 'nearest', inline: 'nearest' });
608
+ });
609
+ return form;
610
+ }
611
+
366
612
  /** @param {string} jobId */
367
613
  function reviewFilesStorageKey(jobId) {
368
614
  return `${REVIEW_FILES_PREFIX}${jobId}`;
@@ -1162,6 +1408,7 @@ function renderDiff(diff, opts = {}) {
1162
1408
  plus.addEventListener('click', (e) => {
1163
1409
  e.stopPropagation();
1164
1410
  activeInlineCommentKey = lineCommentKey(path, line, side);
1411
+ clearCommentEditState();
1165
1412
  renderDiff(diff, opts);
1166
1413
  renderPendingCommentList(jobId);
1167
1414
  syncSubmitReviewButton(jobId);
@@ -1171,6 +1418,7 @@ function renderDiff(diff, opts = {}) {
1171
1418
  cell.addEventListener('click', (e) => {
1172
1419
  if (e.target.closest('button, textarea, a')) return;
1173
1420
  activeInlineCommentKey = lineCommentKey(path, line, side);
1421
+ clearCommentEditState();
1174
1422
  renderDiff(diff, opts);
1175
1423
  renderPendingCommentList(jobId);
1176
1424
  syncSubmitReviewButton(jobId);
@@ -1225,6 +1473,21 @@ function renderDiff(diff, opts = {}) {
1225
1473
  thread.appendChild(meta);
1226
1474
 
1227
1475
  for (const c of existing) {
1476
+ if (editingCommentId === c.id && editingCommentSurface === 'inline') {
1477
+ thread.appendChild(
1478
+ buildCommentEditForm({
1479
+ initialBody: c.body,
1480
+ onInput: (body) => {
1481
+ updateLineCommentBody(jobId, c.id, body);
1482
+ mirrorCommentEditToOtherSurface(jobId);
1483
+ },
1484
+ onSave: (body) => finishCommentEdit(jobId, c.id, body),
1485
+ onCancel: () => finishCommentEdit(jobId, c.id, null),
1486
+ })
1487
+ );
1488
+ continue;
1489
+ }
1490
+
1228
1491
  const chip = document.createElement('div');
1229
1492
  chip.className = 'diff-pending-chip';
1230
1493
  const body = document.createElement('div');
@@ -1232,6 +1495,13 @@ function renderDiff(diff, opts = {}) {
1232
1495
  body.textContent = c.body;
1233
1496
  const actions = document.createElement('div');
1234
1497
  actions.className = 'diff-pending-chip-actions';
1498
+ const edit = document.createElement('button');
1499
+ edit.type = 'button';
1500
+ edit.className = 'btn btn-muted-text btn-sm';
1501
+ edit.textContent = 'Edit';
1502
+ edit.addEventListener('click', () => {
1503
+ startCommentEdit(c.id, 'inline', jobId);
1504
+ });
1235
1505
  const remove = document.createElement('button');
1236
1506
  remove.type = 'button';
1237
1507
  remove.className = 'btn btn-muted-text btn-sm';
@@ -1240,10 +1510,10 @@ function renderDiff(diff, opts = {}) {
1240
1510
  const d = loadReviewDraft(jobId);
1241
1511
  d.lineComments = d.lineComments.filter((x) => x.id !== c.id);
1242
1512
  saveReviewDraft(jobId);
1243
- renderDiff(diff, opts);
1244
- renderPendingCommentList(jobId);
1245
- syncSubmitReviewButton(jobId);
1513
+ if (editingCommentId === c.id) clearCommentEditState();
1514
+ refreshReviewCommentUi(jobId);
1246
1515
  });
1516
+ actions.appendChild(edit);
1247
1517
  actions.appendChild(remove);
1248
1518
  chip.appendChild(body);
1249
1519
  chip.appendChild(actions);
@@ -1495,11 +1765,38 @@ function renderPendingCommentList(jobId) {
1495
1765
  const loc = document.createElement('div');
1496
1766
  loc.className = 'review-pending-item-loc';
1497
1767
  loc.textContent = `${c.path}:${c.line} (${c.side})`;
1768
+ main.appendChild(loc);
1769
+
1770
+ if (editingCommentId === c.id && editingCommentSurface === 'pending') {
1771
+ main.appendChild(
1772
+ buildCommentEditForm({
1773
+ initialBody: c.body,
1774
+ onInput: (body) => {
1775
+ updateLineCommentBody(jobId, c.id, body);
1776
+ mirrorCommentEditToOtherSurface(jobId);
1777
+ },
1778
+ onSave: (body) => finishCommentEdit(jobId, c.id, body),
1779
+ onCancel: () => finishCommentEdit(jobId, c.id, null),
1780
+ })
1781
+ );
1782
+ item.appendChild(main);
1783
+ els.reviewPendingList.appendChild(item);
1784
+ continue;
1785
+ }
1786
+
1498
1787
  const body = document.createElement('div');
1499
1788
  body.className = 'review-pending-item-body';
1500
1789
  body.textContent = c.body;
1501
- main.appendChild(loc);
1502
1790
  main.appendChild(body);
1791
+ const actions = document.createElement('div');
1792
+ actions.className = 'review-pending-item-actions';
1793
+ const edit = document.createElement('button');
1794
+ edit.type = 'button';
1795
+ edit.className = 'btn btn-muted-text btn-sm';
1796
+ edit.textContent = 'Edit';
1797
+ edit.addEventListener('click', () => {
1798
+ startCommentEdit(c.id, 'pending', jobId);
1799
+ });
1503
1800
  const remove = document.createElement('button');
1504
1801
  remove.type = 'button';
1505
1802
  remove.className = 'btn btn-muted-text btn-sm';
@@ -1508,19 +1805,13 @@ function renderPendingCommentList(jobId) {
1508
1805
  const d = loadReviewDraft(jobId);
1509
1806
  d.lineComments = d.lineComments.filter((x) => x.id !== c.id);
1510
1807
  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);
1808
+ if (editingCommentId === c.id) clearCommentEditState();
1809
+ refreshReviewCommentUi(jobId);
1521
1810
  });
1811
+ actions.appendChild(edit);
1812
+ actions.appendChild(remove);
1522
1813
  item.appendChild(main);
1523
- item.appendChild(remove);
1814
+ item.appendChild(actions);
1524
1815
  els.reviewPendingList.appendChild(item);
1525
1816
  }
1526
1817
  }
@@ -1533,6 +1824,9 @@ function syncSubmitReviewButton(jobId) {
1533
1824
  }
1534
1825
 
1535
1826
  function setView(view) {
1827
+ if (view !== 'review' && isDiffFullscreen()) {
1828
+ setDiffFullscreen(false);
1829
+ }
1536
1830
  currentView = view;
1537
1831
  els.viewTitle.textContent = VIEW_TITLES[view] || view;
1538
1832
 
@@ -2021,6 +2315,7 @@ function renderReview(jobs) {
2021
2315
  if (!hasPool) {
2022
2316
  els.reviewNoMatches.classList.add('hidden');
2023
2317
  els.reviewLayout.classList.add('hidden');
2318
+ if (isDiffFullscreen()) setDiffFullscreen(false);
2024
2319
  return;
2025
2320
  }
2026
2321
 
@@ -2084,9 +2379,10 @@ function renderReview(jobs) {
2084
2379
  els.reviewPrSection.classList.toggle('hidden', !isOpened);
2085
2380
  els.reviewTerminalSection.classList.toggle('hidden', !isTerminal);
2086
2381
 
2382
+ const prMeta = resolvePrMeta(job);
2087
2383
  if (document.activeElement !== els.prTitle && document.activeElement !== els.prBody) {
2088
- els.prTitle.value = job.prTitle || '';
2089
- els.prBody.value = job.prBody || '';
2384
+ els.prTitle.value = prMeta.prTitle;
2385
+ els.prBody.value = prMeta.prBody;
2090
2386
  }
2091
2387
 
2092
2388
  if (editable) {
@@ -2097,7 +2393,12 @@ function renderReview(jobs) {
2097
2393
  ) {
2098
2394
  els.reviewGeneralComment.value = draft.generalComment;
2099
2395
  }
2100
- renderPendingCommentList(job.id);
2396
+ const focusInPending = Boolean(
2397
+ els.reviewPendingList?.contains(document.activeElement)
2398
+ );
2399
+ if (!(editingCommentId && editingCommentSurface === 'pending' && focusInPending)) {
2400
+ renderPendingCommentList(job.id);
2401
+ }
2101
2402
  syncSubmitReviewButton(job.id);
2102
2403
  renderReviewFilesPanel(job);
2103
2404
  }
@@ -2126,7 +2427,11 @@ function renderReview(jobs) {
2126
2427
  }
2127
2428
 
2128
2429
  const focusInDiff = els.diffViewer?.contains(document.activeElement);
2129
- if (!(activeInlineCommentKey && focusInDiff)) {
2430
+ const preservingDiffCompose =
2431
+ (Boolean(activeInlineCommentKey) ||
2432
+ (editingCommentId && editingCommentSurface === 'inline')) &&
2433
+ focusInDiff;
2434
+ if (!preservingDiffCompose) {
2130
2435
  renderDiff(displayDiff, { interactive: editable, jobId: job.id });
2131
2436
  }
2132
2437
 
@@ -3113,12 +3418,14 @@ async function approve(draft) {
3113
3418
  b.dataset.busy = '1';
3114
3419
  });
3115
3420
  try {
3421
+ persistPrMetaFromForm(jobId);
3422
+ const prMeta = resolvePrMeta(jobsById[jobId] || { id: jobId });
3116
3423
  const res = await fetch(`/api/jobs/${jobId}/approve`, {
3117
3424
  method: 'POST',
3118
3425
  headers: { 'Content-Type': 'application/json' },
3119
3426
  body: JSON.stringify({
3120
- prTitle: els.prTitle.value,
3121
- prBody: els.prBody.value,
3427
+ prTitle: prMeta.prTitle,
3428
+ prBody: prMeta.prBody,
3122
3429
  draft,
3123
3430
  excludedPaths,
3124
3431
  }),
@@ -3126,6 +3433,7 @@ async function approve(draft) {
3126
3433
  if (res.ok) {
3127
3434
  clearExcludedPaths(jobId);
3128
3435
  delete reviewFileLists[jobId];
3436
+ clearReviewDraft(jobId);
3129
3437
  }
3130
3438
  await fetchJobs();
3131
3439
  if (!res.ok) {
@@ -3188,7 +3496,7 @@ async function submitReview() {
3188
3496
  return;
3189
3497
  }
3190
3498
 
3191
- clearReviewDraft(jobId);
3499
+ clearReviewCommentDraft(jobId);
3192
3500
  if (els.reviewGeneralComment) els.reviewGeneralComment.value = '';
3193
3501
  selectedRunId = jobId;
3194
3502
  selectedReviewId = null;
@@ -3234,9 +3542,29 @@ els.reviewGeneralComment?.addEventListener('input', () => {
3234
3542
  syncSubmitReviewButton(selectedReviewId);
3235
3543
  });
3236
3544
 
3545
+ function onPrMetaInput() {
3546
+ if (!selectedReviewId) return;
3547
+ persistPrMetaFromForm(selectedReviewId);
3548
+ }
3549
+
3550
+ els.prTitle?.addEventListener('input', onPrMetaInput);
3551
+ els.prBody?.addEventListener('input', onPrMetaInput);
3552
+
3553
+ els.diffFullscreenBtn?.addEventListener('click', () => toggleDiffFullscreen());
3554
+
3555
+ document.addEventListener('keydown', (e) => {
3556
+ if (e.key === 'Escape' && isDiffFullscreen()) {
3557
+ e.preventDefault();
3558
+ setDiffFullscreen(false);
3559
+ }
3560
+ });
3561
+
3237
3562
  els.rejectBtn.addEventListener('click', async () => {
3238
3563
  if (!selectedReviewId) return;
3239
- await fetch(`/api/jobs/${selectedReviewId}/reject`, { method: 'POST' });
3564
+ const jobId = selectedReviewId;
3565
+ await fetch(`/api/jobs/${jobId}/reject`, { method: 'POST' });
3566
+ clearReviewDraft(jobId);
3567
+ if (isDiffFullscreen()) setDiffFullscreen(false);
3240
3568
  selectedReviewId = null;
3241
3569
  await fetchJobs();
3242
3570
  });
package/public/index.html CHANGED
@@ -185,11 +185,23 @@
185
185
  <div class="review-detail" id="review-detail">
186
186
  <div class="empty-dashed" id="review-empty-detail">Select a job to review</div>
187
187
  <div class="stack hidden" id="review-detail-content">
188
- <div class="card">
188
+ <div class="card" id="review-pr-meta-card">
189
189
  <div class="field-label">PR title</div>
190
- <input id="pr-title" class="input input-title" type="text">
190
+ <input
191
+ id="pr-title"
192
+ class="input input-title"
193
+ type="text"
194
+ placeholder="Pull request title"
195
+ autocomplete="off"
196
+ >
191
197
  <div class="field-label field-label-spaced">Description</div>
192
- <textarea id="pr-body" class="input textarea" rows="4"></textarea>
198
+ <textarea
199
+ id="pr-body"
200
+ class="input textarea"
201
+ rows="4"
202
+ placeholder="Pull request description…"
203
+ ></textarea>
204
+ <p class="field-hint" id="pr-meta-hint">Editable before approve. Edits stick across refreshes until you approve or reject.</p>
193
205
  </div>
194
206
 
195
207
  <div class="card" id="review-files-section">
@@ -204,8 +216,13 @@
204
216
  <div id="review-files-list" class="review-files-list"></div>
205
217
  </div>
206
218
 
207
- <div>
208
- <div class="section-label" id="diff-header">Diff</div>
219
+ <div id="diff-section" class="diff-section">
220
+ <div class="diff-section-bar">
221
+ <div class="section-label" id="diff-header">Diff</div>
222
+ <button type="button" class="btn btn-muted-text btn-sm" id="diff-fullscreen-btn" aria-pressed="false">
223
+ Fullscreen
224
+ </button>
225
+ </div>
209
226
  <div id="diff-viewer" class="diff-viewer amcp-scroll"></div>
210
227
  </div>
211
228
 
package/public/styles.css CHANGED
@@ -1233,6 +1233,24 @@ a { color: var(--primary); text-underline-offset: 3px; }
1233
1233
  }
1234
1234
 
1235
1235
  /* —— Diff (side-by-side, GitHub-style column panes) —— */
1236
+ .diff-section {
1237
+ display: flex;
1238
+ flex-direction: column;
1239
+ gap: 8px;
1240
+ min-width: 0;
1241
+ }
1242
+
1243
+ .diff-section-bar {
1244
+ display: flex;
1245
+ align-items: center;
1246
+ justify-content: space-between;
1247
+ gap: 12px;
1248
+ }
1249
+
1250
+ .diff-section-bar .section-label {
1251
+ margin: 0;
1252
+ }
1253
+
1236
1254
  .diff-viewer {
1237
1255
  border: 1px solid var(--border);
1238
1256
  border-radius: 10px;
@@ -1244,6 +1262,28 @@ a { color: var(--primary); text-underline-offset: 3px; }
1244
1262
  background: var(--surface);
1245
1263
  }
1246
1264
 
1265
+ .diff-section.diff-fullscreen {
1266
+ position: fixed;
1267
+ inset: 0;
1268
+ z-index: 1200;
1269
+ margin: 0;
1270
+ padding: 14px 16px 16px;
1271
+ background: var(--bg);
1272
+ gap: 10px;
1273
+ box-sizing: border-box;
1274
+ }
1275
+
1276
+ .diff-section.diff-fullscreen .diff-viewer {
1277
+ max-height: none;
1278
+ flex: 1 1 auto;
1279
+ min-height: 0;
1280
+ border-radius: 12px;
1281
+ }
1282
+
1283
+ body.diff-fs-open {
1284
+ overflow: hidden;
1285
+ }
1286
+
1247
1287
  .diff-file + .diff-file {
1248
1288
  border-top: 1px solid var(--border);
1249
1289
  }
@@ -1439,6 +1479,9 @@ a { color: var(--primary); text-underline-offset: 3px; }
1439
1479
 
1440
1480
  .diff-pending-chip-actions {
1441
1481
  margin-top: 6px;
1482
+ display: flex;
1483
+ gap: 4px;
1484
+ flex-wrap: wrap;
1442
1485
  }
1443
1486
 
1444
1487
  .review-pending-list {
@@ -1469,6 +1512,13 @@ a { color: var(--primary); text-underline-offset: 3px; }
1469
1512
  flex: 1;
1470
1513
  }
1471
1514
 
1515
+ .review-pending-item-actions {
1516
+ display: flex;
1517
+ flex-direction: column;
1518
+ gap: 2px;
1519
+ flex-shrink: 0;
1520
+ }
1521
+
1472
1522
  .review-pending-item-loc {
1473
1523
  font-size: 11px;
1474
1524
  font-weight: 600;