@in-the-loop-labs/pair-review 3.0.3 → 3.0.5

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": "@in-the-loop-labs/pair-review",
3
- "version": "3.0.3",
3
+ "version": "3.0.5",
4
4
  "description": "Your AI-powered code review partner - Close the feedback loop with AI coding agents",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -77,6 +77,7 @@
77
77
  "@playwright/test": "^1.57.0",
78
78
  "@vitest/coverage-v8": "^4.0.16",
79
79
  "highlight.js": "^11.11.1",
80
+ "jsdom": "^29.0.1",
80
81
  "supertest": "^7.1.4",
81
82
  "vitest": "^4.0.16"
82
83
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pair-review",
3
- "version": "3.0.3",
3
+ "version": "3.0.5",
4
4
  "description": "pair-review app integration — Open PRs and local changes in the pair-review web UI, run server-side AI analysis, and address review feedback. Requires the pair-review MCP server.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-critic",
3
- "version": "3.0.3",
3
+ "version": "3.0.5",
4
4
  "description": "AI-powered code review analysis — Run three-level AI analysis and implement-review-fix loops directly in your coding agent. Works standalone, no server required.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
package/public/css/pr.css CHANGED
@@ -6000,12 +6000,8 @@ body::before {
6000
6000
  color: var(--color-accent, #2f81f7);
6001
6001
  }
6002
6002
 
6003
- /* PR description popover */
6003
+ /* PR description popover - appended to document.body with fixed positioning */
6004
6004
  .pr-description-popover {
6005
- position: absolute;
6006
- top: calc(100% + 8px);
6007
- left: 50%;
6008
- transform: translateX(-50%);
6009
6005
  z-index: 1000;
6010
6006
  width: min(500px, 90vw);
6011
6007
  background: var(--color-bg-primary, #0d1117);
@@ -10881,6 +10877,15 @@ body.resizing * {
10881
10877
  /* File-level AI suggestions now use the same classes as line-level (.ai-suggestion-*)
10882
10878
  No custom button styles needed - they inherit from .ai-action classes defined earlier */
10883
10879
 
10880
+ /* File-level AI suggestions: override line-level viewport-based max-width.
10881
+ Line-level suggestions use calc(100vw - sidebar - panels) because they sit in table cells.
10882
+ File-level suggestions sit inside .file-comments-container which is already correctly sized,
10883
+ so they just need to fill their container. */
10884
+ .file-comment-card.ai-suggestion {
10885
+ max-width: 100%;
10886
+ margin: 0; /* Container handles spacing via gap; don't center with auto margins */
10887
+ }
10888
+
10884
10889
  /* File-level AI suggestion collapsed state - uses same classes as line-level */
10885
10890
  .file-comment-card.ai-suggestion.collapsed {
10886
10891
  background: var(--color-bg-secondary, #f6f8fa);
@@ -371,7 +371,7 @@ class ReviewModal {
371
371
  */
372
372
  updateCommentCount() {
373
373
  // Count both line-level comments (.user-comment-row) and file-level comments (.file-comment-card.user-comment)
374
- const lineComments = document.querySelectorAll('.user-comment-row').length;
374
+ const lineComments = document.querySelectorAll('.user-comment-row:not(.suggestion-edit-pending)').length;
375
375
  const fileComments = document.querySelectorAll('.file-comment-card.user-comment').length;
376
376
  const userComments = lineComments + fileComments;
377
377
  const countElement = this.modal.querySelector('.review-comment-count');
@@ -470,7 +470,7 @@ class ReviewModal {
470
470
  const reviewEvent = selectedOption ? selectedOption.value : 'COMMENT';
471
471
  // Count BOTH line-level (.user-comment-row) and file-level (.file-comment-card.user-comment) comments
472
472
  // This must match the counting logic in updateCommentCount() for consistency
473
- const lineComments = document.querySelectorAll('.user-comment-row').length;
473
+ const lineComments = document.querySelectorAll('.user-comment-row:not(.suggestion-edit-pending)').length;
474
474
  const fileComments = document.querySelectorAll('.file-comment-card.user-comment').length;
475
475
  const commentCount = lineComments + fileComments;
476
476
 
@@ -580,12 +580,8 @@ class CommentManager {
580
580
 
581
581
  // Choose icon based on comment origin (AI-adopted vs user-originated)
582
582
  const commentIcon = comment.parent_id
583
- ? `<svg class="octicon octicon-comment-ai" viewBox="0 0 16 16" width="16" height="16">
584
- <path d="M7.75 1a.75.75 0 0 1 0 1.5h-5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2c.199 0 .39.079.53.22.141.14.22.331.22.53v2.19l2.72-2.72a.747.747 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-2a.75.75 0 0 1 1.5 0v2c0 .464-.184.909-.513 1.237A1.746 1.746 0 0 1 13.25 12H9.06l-2.573 2.573A1.457 1.457 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25v-7.5C1 1.784 1.784 1 2.75 1h5Zm4.519-.837a.248.248 0 0 1 .466 0l.238.648a3.726 3.726 0 0 0 2.218 2.219l.649.238a.249.249 0 0 1 0 .467l-.649.238a3.725 3.725 0 0 0-2.218 2.218l-.238.649a.248.248 0 0 1-.466 0l-.239-.649a3.725 3.725 0 0 0-2.218-2.218l-.649-.238a.249.249 0 0 1 0-.467l.649-.238A3.726 3.726 0 0 0 12.03.811l.239-.648Z"/>
585
- </svg>`
586
- : `<svg class="octicon octicon-person" viewBox="0 0 16 16" width="16" height="16">
587
- <path d="M10.561 8.073a6.005 6.005 0 0 1 3.432 5.142.75.75 0 1 1-1.498.07 4.5 4.5 0 0 0-8.99 0 .75.75 0 0 1-1.498-.07 6.004 6.004 0 0 1 3.431-5.142 3.999 3.999 0 1 1 5.123 0ZM10.5 5a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/>
588
- </svg>`;
583
+ ? CommentManager.AI_ICON_SVG
584
+ : CommentManager.PERSON_ICON_SVG;
589
585
 
590
586
  // Build class list for comment styling
591
587
  const baseClasses = ['user-comment'];
@@ -634,56 +630,51 @@ class CommentManager {
634
630
  targetRow.parentNode.insertBefore(commentRow, targetRow.nextSibling);
635
631
  }
636
632
 
633
+ /** SVG icons for comment origin display */
634
+ static AI_ICON_SVG = `<svg class="octicon octicon-comment-ai" viewBox="0 0 16 16" width="16" height="16">
635
+ <path d="M7.75 1a.75.75 0 0 1 0 1.5h-5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2c.199 0 .39.079.53.22.141.14.22.331.22.53v2.19l2.72-2.72a.747.747 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-2a.75.75 0 0 1 1.5 0v2c0 .464-.184.909-.513 1.237A1.746 1.746 0 0 1 13.25 12H9.06l-2.573 2.573A1.457 1.457 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25v-7.5C1 1.784 1.784 1 2.75 1h5Zm4.519-.837a.248.248 0 0 1 .466 0l.238.648a3.726 3.726 0 0 0 2.218 2.219l.649.238a.249.249 0 0 1 0 .467l-.649.238a3.725 3.725 0 0 0-2.218 2.218l-.238.649a.248.248 0 0 1-.466 0l-.239-.649a3.725 3.725 0 0 0-2.218-2.218l-.649-.238a.249.249 0 0 1 0-.467l.649-.238A3.726 3.726 0 0 0 12.03.811l.239-.648Z"/>
636
+ </svg>`;
637
+
638
+ static PERSON_ICON_SVG = `<svg class="octicon octicon-person" viewBox="0 0 16 16" width="16" height="16">
639
+ <path d="M10.561 8.073a6.005 6.005 0 0 1 3.432 5.142.75.75 0 1 1-1.498.07 4.5 4.5 0 0 0-8.99 0 .75.75 0 0 1-1.498-.07 6.004 6.004 0 0 1 3.431-5.142 3.999 3.999 0 1 1 5.123 0ZM10.5 5a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/>
640
+ </svg>`;
641
+
637
642
  /**
638
- * Display a user comment in edit mode (for adopted suggestions)
639
- * @param {Object} comment - Comment data
640
- * @param {HTMLElement} targetRow - Row to insert after
643
+ * Shared builder for comment/suggestion edit form rows.
644
+ * Builds the DOM, inserts after targetRow, wires common event handling
645
+ * (autoResize, emoji, suggestion button), and returns { formRow, textarea, saveBtn, cancelBtn }.
646
+ * Callers wire their own save/cancel logic on the returned elements.
647
+ * NOTE: similar edit form in pr.js editUserComment — keep in sync
648
+ * @private
641
649
  */
642
- displayUserCommentInEditMode(comment, targetRow) {
643
- const commentRow = document.createElement('tr');
644
- commentRow.className = 'user-comment-row';
645
- commentRow.dataset.commentId = comment.id;
646
- // Store file/line/side data for editing
647
- commentRow.dataset.file = comment.file;
648
- commentRow.dataset.lineStart = comment.line_start;
649
- commentRow.dataset.lineEnd = comment.line_end || comment.line_start;
650
- if (comment.side) {
651
- commentRow.dataset.side = comment.side;
652
- }
650
+ _buildEditFormRow(targetRow, {
651
+ rowClassName, rowDataset, iconHtml, originClass, lineInfo,
652
+ type, title, body, bodyHtml, textareaId, placeholder,
653
+ dataAttrs, saveLabel
654
+ }) {
655
+ const formRow = document.createElement('tr');
656
+ formRow.className = rowClassName;
657
+ Object.assign(formRow.dataset, rowDataset);
653
658
 
654
659
  const td = document.createElement('td');
655
660
  td.colSpan = 4;
656
661
  td.className = 'user-comment-cell';
657
662
 
658
- const lineInfo = comment.line_end && comment.line_end !== comment.line_start
659
- ? `Lines ${comment.line_start}-${comment.line_end}`
660
- : `Line ${comment.line_start}`;
661
-
662
663
  const escapeHtml = this.prManager?.escapeHtml?.bind(this.prManager) || ((s) => s);
663
664
 
664
- // Choose icon based on comment origin (AI-adopted vs user-originated)
665
- const commentIcon = comment.parent_id
666
- ? `<svg class="octicon octicon-comment-ai" viewBox="0 0 16 16" width="16" height="16">
667
- <path d="M7.75 1a.75.75 0 0 1 0 1.5h-5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2c.199 0 .39.079.53.22.141.14.22.331.22.53v2.19l2.72-2.72a.747.747 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-2a.75.75 0 0 1 1.5 0v2c0 .464-.184.909-.513 1.237A1.746 1.746 0 0 1 13.25 12H9.06l-2.573 2.573A1.457 1.457 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25v-7.5C1 1.784 1.784 1 2.75 1h5Zm4.519-.837a.248.248 0 0 1 .466 0l.238.648a3.726 3.726 0 0 0 2.218 2.219l.649.238a.249.249 0 0 1 0 .467l-.649.238a3.725 3.725 0 0 0-2.218 2.218l-.238.649a.248.248 0 0 1-.466 0l-.239-.649a3.725 3.725 0 0 0-2.218-2.218l-.649-.238a.249.249 0 0 1 0-.467l.649-.238A3.726 3.726 0 0 0 12.03.811l.239-.648Z"/>
668
- </svg>`
669
- : `<svg class="octicon octicon-person" viewBox="0 0 16 16" width="16" height="16">
670
- <path d="M10.561 8.073a6.005 6.005 0 0 1 3.432 5.142.75.75 0 1 1-1.498.07 4.5 4.5 0 0 0-8.99 0 .75.75 0 0 1-1.498-.07 6.004 6.004 0 0 1 3.431-5.142 3.999 3.999 0 1 1 5.123 0ZM10.5 5a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/>
671
- </svg>`;
672
-
673
- const commentHTML = `
674
- <div class="user-comment editing-mode ${comment.parent_id ? 'adopted-comment comment-ai-origin' : 'comment-user-origin'}">
665
+ const html = `
666
+ <div class="user-comment editing-mode ${originClass}">
675
667
  <div class="user-comment-header">
676
668
  <div class="user-comment-header-left">
677
669
  <span class="comment-origin-icon">
678
- ${commentIcon}
670
+ ${iconHtml}
679
671
  </span>
680
672
  <span class="user-comment-line-info">${lineInfo}</span>
681
- ${comment.type === 'praise' ? `<span class="adopted-praise-badge" title="Nice Work"><svg viewBox="0 0 16 16" width="12" height="12"><path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z"/></svg>Nice Work</span>` : ''}
682
- ${comment.title ? `<span class="adopted-title">${escapeHtml(comment.title)}</span>` : ''}
673
+ ${type === 'praise' ? `<span class="adopted-praise-badge" title="Nice Work"><svg viewBox="0 0 16 16" width="12" height="12"><path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z"/></svg>Nice Work</span>` : ''}
674
+ ${title ? `<span class="adopted-title">${escapeHtml(title)}</span>` : ''}
683
675
  </div>
684
676
  </div>
685
- <!-- Hidden body div for saving - pre-populate with markdown rendered content and store original -->
686
- <div class="user-comment-body" style="display: none;" data-original-markdown="${window.escapeHtmlAttribute(comment.body)}">${window.renderMarkdown ? window.renderMarkdown(comment.body) : escapeHtml(comment.body)}</div>
677
+ ${bodyHtml || ''}
687
678
  <div class="user-comment-edit-form">
688
679
  <div class="comment-form-toolbar">
689
680
  <button type="button" class="btn btn-sm suggestion-btn" title="Insert a suggestion (Ctrl+G)">
@@ -691,71 +682,55 @@ class CommentManager {
691
682
  </button>
692
683
  </div>
693
684
  <textarea
694
- id="edit-comment-${comment.id}"
685
+ ${textareaId ? `id="${textareaId}"` : ''}
695
686
  class="comment-edit-textarea"
696
- placeholder="Enter your comment..."
697
- data-file="${comment.file}"
698
- data-line="${comment.line_start}"
699
- data-line-end="${comment.line_end || comment.line_start}"
700
- data-side="${comment.side || 'RIGHT'}"
701
- >${escapeHtml(comment.body)}</textarea>
687
+ placeholder="${placeholder}"
688
+ data-file="${window.escapeHtmlAttribute ? window.escapeHtmlAttribute(dataAttrs.file) : dataAttrs.file}"
689
+ data-line="${dataAttrs.line}"
690
+ data-line-end="${dataAttrs.lineEnd}"
691
+ data-side="${dataAttrs.side}"
692
+ >${escapeHtml(body)}</textarea>
702
693
  <div class="comment-edit-actions">
703
- <button class="btn btn-sm btn-primary save-edit-btn">
704
- Save
705
- </button>
706
- <button class="btn btn-sm btn-secondary cancel-edit-btn">
707
- Cancel
708
- </button>
694
+ <button class="btn btn-sm btn-primary save-edit-btn">${saveLabel}</button>
695
+ <button class="btn btn-sm btn-secondary cancel-edit-btn">Cancel</button>
709
696
  </div>
710
697
  </div>
711
698
  </div>
712
699
  `;
713
700
 
714
- td.innerHTML = commentHTML;
715
- commentRow.appendChild(td);
701
+ td.innerHTML = html;
702
+ formRow.appendChild(td);
716
703
 
717
- // Insert comment immediately after the target row (suggestion row)
718
704
  if (targetRow.nextSibling) {
719
- targetRow.parentNode.insertBefore(commentRow, targetRow.nextSibling);
705
+ targetRow.parentNode.insertBefore(formRow, targetRow.nextSibling);
720
706
  } else {
721
- targetRow.parentNode.appendChild(commentRow);
707
+ targetRow.parentNode.appendChild(formRow);
722
708
  }
723
709
 
724
- // Get references
725
- const editForm = td.querySelector('.user-comment-edit-form');
726
- const textarea = document.getElementById(`edit-comment-${comment.id}`);
727
- const suggestionBtn = editForm.querySelector('.suggestion-btn');
728
- const saveBtn = editForm.querySelector('.save-edit-btn');
729
- const cancelBtn = editForm.querySelector('.cancel-edit-btn');
710
+ const textarea = textareaId
711
+ ? document.getElementById(textareaId)
712
+ : formRow.querySelector('.comment-edit-textarea');
713
+ const suggestionBtn = formRow.querySelector('.suggestion-btn');
714
+ const saveBtn = formRow.querySelector('.save-edit-btn');
715
+ const cancelBtn = formRow.querySelector('.cancel-edit-btn');
730
716
 
731
717
  if (textarea) {
732
- // Auto-resize to fit content
733
718
  this.autoResizeTextarea(textarea);
734
-
735
719
  textarea.focus();
736
- // Position cursor at end of text instead of selecting all
737
720
  textarea.setSelectionRange(textarea.value.length, textarea.value.length);
738
721
 
739
- // Attach emoji picker for autocomplete
740
722
  if (window.emojiPicker) {
741
723
  window.emojiPicker.attach(textarea);
742
724
  }
743
725
 
744
- // Update suggestion button state based on content
745
726
  this.updateSuggestionButtonState(textarea, suggestionBtn);
746
727
 
747
- // Suggestion button handler
748
728
  suggestionBtn.addEventListener('click', () => {
749
729
  if (!suggestionBtn.disabled) {
750
730
  this.insertSuggestionBlock(textarea, suggestionBtn);
751
731
  }
752
732
  });
753
733
 
754
- // Save/cancel handlers - use prManager methods for consistency
755
- saveBtn.addEventListener('click', () => this.prManager?.saveEditedUserComment(comment.id));
756
- cancelBtn.addEventListener('click', () => this.prManager?.cancelEditUserComment(comment.id));
757
-
758
- // Auto-resize on input and update suggestion button state
759
734
  textarea.addEventListener('input', () => {
760
735
  this.autoResizeTextarea(textarea);
761
736
  this.updateSuggestionButtonState(textarea, suggestionBtn);
@@ -764,6 +739,105 @@ class CommentManager {
764
739
  // Keyboard shortcuts (Escape, Cmd/Ctrl+Enter) are handled by delegated
765
740
  // event listener in setupCommentFormDelegation() to avoid memory leaks
766
741
  }
742
+
743
+ return { formRow, textarea, saveBtn, cancelBtn };
744
+ }
745
+
746
+ /**
747
+ * Display a user comment in edit mode (for adopted suggestions)
748
+ * @param {Object} comment - Comment data
749
+ * @param {HTMLElement} targetRow - Row to insert after
750
+ */
751
+ displayUserCommentInEditMode(comment, targetRow) {
752
+ const lineInfo = comment.line_end && comment.line_end !== comment.line_start
753
+ ? `Lines ${comment.line_start}-${comment.line_end}`
754
+ : `Line ${comment.line_start}`;
755
+
756
+ const escapeHtml = this.prManager?.escapeHtml?.bind(this.prManager) || ((s) => s);
757
+ const bodyHtml = `<div class="user-comment-body" style="display: none;" data-original-markdown="${window.escapeHtmlAttribute(comment.body)}">${window.renderMarkdown ? window.renderMarkdown(comment.body) : escapeHtml(comment.body)}</div>`;
758
+
759
+ const rowDataset = { commentId: comment.id, file: comment.file, lineStart: comment.line_start, lineEnd: comment.line_end || comment.line_start };
760
+ if (comment.side) rowDataset.side = comment.side;
761
+
762
+ const { saveBtn, cancelBtn } = this._buildEditFormRow(targetRow, {
763
+ rowClassName: 'user-comment-row',
764
+ rowDataset,
765
+ iconHtml: comment.parent_id ? CommentManager.AI_ICON_SVG : CommentManager.PERSON_ICON_SVG,
766
+ originClass: comment.parent_id ? 'adopted-comment comment-ai-origin' : 'comment-user-origin',
767
+ lineInfo,
768
+ type: comment.type,
769
+ title: comment.title,
770
+ body: comment.body,
771
+ bodyHtml,
772
+ textareaId: `edit-comment-${comment.id}`,
773
+ placeholder: 'Enter your comment...',
774
+ dataAttrs: {
775
+ file: comment.file,
776
+ line: comment.line_start,
777
+ lineEnd: comment.line_end || comment.line_start,
778
+ side: comment.side || 'RIGHT'
779
+ },
780
+ saveLabel: 'Save'
781
+ });
782
+
783
+ saveBtn.addEventListener('click', () => this.prManager?.saveEditedUserComment(comment.id));
784
+ cancelBtn.addEventListener('click', () => this.prManager?.cancelEditUserComment(comment.id));
785
+ }
786
+
787
+ /**
788
+ * Display an edit form for an AI suggestion that has NOT yet been adopted.
789
+ * Nothing is saved until the user clicks Save/Adopt.
790
+ * @param {Object} suggestion - { id, body, type, title, file, lineNumber, diffPosition, side }
791
+ * @param {HTMLElement} targetRow - The suggestion row to insert the form after
792
+ * @param {Function} onSave - Called with (editedText) when user clicks Save
793
+ * @param {Function} onCancel - Called when user clicks Cancel
794
+ */
795
+ displaySuggestionEditForm(suggestion, targetRow, onSave, onCancel) {
796
+ // Remove any existing pending edit form (prevents stale multi-form state)
797
+ const existing = document.querySelector('.suggestion-edit-pending');
798
+ if (existing) existing.remove();
799
+
800
+ const { formRow, saveBtn, cancelBtn } = this._buildEditFormRow(targetRow, {
801
+ rowClassName: 'user-comment-row suggestion-edit-pending',
802
+ rowDataset: { suggestionId: suggestion.id },
803
+ iconHtml: CommentManager.AI_ICON_SVG,
804
+ originClass: 'adopted-comment comment-ai-origin',
805
+ lineInfo: suggestion.lineEnd && suggestion.lineEnd !== suggestion.lineNumber
806
+ ? `Lines ${suggestion.lineNumber}-${suggestion.lineEnd}`
807
+ : `Line ${suggestion.lineNumber}`,
808
+ type: suggestion.type,
809
+ title: suggestion.title,
810
+ body: suggestion.body,
811
+ placeholder: 'Edit the suggestion...',
812
+ dataAttrs: {
813
+ file: suggestion.file,
814
+ line: suggestion.lineNumber,
815
+ lineEnd: suggestion.lineEnd || suggestion.lineNumber,
816
+ side: suggestion.side || 'RIGHT'
817
+ },
818
+ saveLabel: 'Save'
819
+ });
820
+
821
+ saveBtn.addEventListener('click', async () => {
822
+ const textarea = formRow.querySelector('.comment-edit-textarea');
823
+ const text = textarea?.value.trim();
824
+ if (text) {
825
+ saveBtn.disabled = true;
826
+ try {
827
+ await onSave(text);
828
+ formRow.remove();
829
+ } catch (err) {
830
+ saveBtn.disabled = false;
831
+ }
832
+ }
833
+ });
834
+
835
+ cancelBtn.addEventListener('click', () => {
836
+ formRow.remove();
837
+ onCancel();
838
+ });
839
+
840
+ return formRow;
767
841
  }
768
842
  }
769
843
 
@@ -17,7 +17,7 @@ class CommentMinimizer {
17
17
  /** Sparkles icon SVG (matches AI suggestion badge) */
18
18
  static SPARKLES_ICON = `<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M9.6 2.279a.426.426 0 0 1 .8 0l.407 1.112a6.386 6.386 0 0 0 3.802 3.802l1.112.407a.426.426 0 0 1 0 .8l-1.112.407a6.386 6.386 0 0 0-3.802 3.802l-.407 1.112a.426.426 0 0 1-.8 0l-.407-1.112a6.386 6.386 0 0 0-3.802-3.802L4.279 8.4a.426.426 0 0 1 0-.8l1.112-.407a6.386 6.386 0 0 0 3.802-3.802L9.6 2.279Zm-4.267 8.837a.178.178 0 0 1 .334 0l.169.464a2.662 2.662 0 0 0 1.584 1.584l.464.169a.178.178 0 0 1 0 .334l-.464.169a2.662 2.662 0 0 0-1.584 1.584l-.169.464a.178.178 0 0 1-.334 0l-.169-.464a2.662 2.662 0 0 0-1.584-1.584l-.464-.169a.178.178 0 0 1 0-.334l.464-.169a2.662 2.662 0 0 0 1.584-1.584l.169-.464ZM2.8.14a.213.213 0 0 1 .4 0l.203.556a3.2 3.2 0 0 0 1.901 1.901l.556.203a.213.213 0 0 1 0 .4l-.556.203a3.2 3.2 0 0 0-1.901 1.901L3.2 5.86a.213.213 0 0 1-.4 0l-.203-.556A3.2 3.2 0 0 0 .696 3.403L.14 3.2a.213.213 0 0 1 0-.4l.556-.203A3.2 3.2 0 0 0 2.597.696L2.8.14Z"/></svg>`;
19
19
 
20
- /** AI comment icon SVG — speech bubble with sparkles (for adopted AI suggestions) */
20
+ /** AI comment icon SVG — speech bubble with sparkles (matches CommentManager.AI_ICON_SVG, different size) */
21
21
  static AI_COMMENT_ICON = `<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M7.75 1a.75.75 0 0 1 0 1.5h-5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2c.199 0 .39.079.53.22.141.14.22.331.22.53v2.19l2.72-2.72a.747.747 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-2a.75.75 0 0 1 1.5 0v2c0 .464-.184.909-.513 1.237A1.746 1.746 0 0 1 13.25 12H9.06l-2.573 2.573A1.457 1.457 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25v-7.5C1 1.784 1.784 1 2.75 1h5Zm4.519-.837a.248.248 0 0 1 .466 0l.238.648a3.726 3.726 0 0 0 2.218 2.219l.649.238a.249.249 0 0 1 0 .467l-.649.238a3.725 3.725 0 0 0-2.218 2.218l-.238.649a.248.248 0 0 1-.466 0l-.239-.649a3.725 3.725 0 0 0-2.218-2.218l-.649-.238a.249.249 0 0 1 0-.467l.649-.238A3.726 3.726 0 0 0 12.03.811l.239-.648Z"/></svg>`;
22
22
 
23
23
  constructor() {
@@ -357,12 +357,8 @@ class FileCommentManager {
357
357
 
358
358
  // Choose icon based on comment origin (AI-adopted vs user-originated) - matches line-level
359
359
  const commentIcon = isAIOrigin
360
- ? `<svg class="octicon octicon-comment-ai" viewBox="0 0 16 16" width="16" height="16">
361
- <path d="M7.75 1a.75.75 0 0 1 0 1.5h-5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2c.199 0 .39.079.53.22.141.14.22.331.22.53v2.19l2.72-2.72a.747.747 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-2a.75.75 0 0 1 1.5 0v2c0 .464-.184.909-.513 1.237A1.746 1.746 0 0 1 13.25 12H9.06l-2.573 2.573A1.457 1.457 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25v-7.5C1 1.784 1.784 1 2.75 1h5Zm4.519-.837a.248.248 0 0 1 .466 0l.238.648a3.726 3.726 0 0 0 2.218 2.219l.649.238a.249.249 0 0 1 0 .467l-.649.238a3.725 3.725 0 0 0-2.218 2.218l-.238.649a.248.248 0 0 1-.466 0l-.239-.649a3.725 3.725 0 0 0-2.218-2.218l-.649-.238a.249.249 0 0 1 0-.467l.649-.238A3.726 3.726 0 0 0 12.03.811l.239-.648Z"/>
362
- </svg>`
363
- : `<svg class="octicon octicon-person" viewBox="0 0 16 16" width="16" height="16">
364
- <path d="M10.561 8.073a6.005 6.005 0 0 1 3.432 5.142.75.75 0 1 1-1.498.07 4.5 4.5 0 0 0-8.99 0 .75.75 0 0 1-1.498-.07 6.004 6.004 0 0 1 3.431-5.142 3.999 3.999 0 1 1 5.123 0ZM10.5 5a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/>
365
- </svg>`;
360
+ ? window.CommentManager.AI_ICON_SVG
361
+ : window.CommentManager.PERSON_ICON_SVG;
366
362
 
367
363
  // Praise badge for "Nice Work" comments - matches line-level
368
364
  const praiseBadge = comment.type === 'praise'
@@ -746,7 +742,7 @@ class FileCommentManager {
746
742
  data-file="${window.escapeHtmlAttribute(suggestion.file)}"
747
743
  >${this.escapeHtml(suggestion.formattedBody || suggestion.body)}</textarea>
748
744
  <div class="file-comment-form-footer">
749
- <button class="file-comment-form-btn submit submit-btn">Adopt</button>
745
+ <button class="file-comment-form-btn submit submit-btn">Save</button>
750
746
  <button class="file-comment-form-btn cancel cancel-btn">Cancel</button>
751
747
  </div>
752
748
  `;
@@ -433,6 +433,7 @@ class SuggestionManager {
433
433
  // Store original markdown body for adopt functionality
434
434
  // Use JSON.stringify to preserve newlines and special characters
435
435
  suggestionDiv.dataset.originalBody = JSON.stringify(suggestion.body || '');
436
+ suggestionDiv.dataset.formattedBody = JSON.stringify(suggestion.formattedBody || '');
436
437
 
437
438
  // Store target info on the suggestion div for reliable retrieval in getFileAndLineInfo
438
439
  // This avoids fragile DOM traversal that fails when gap rows are between suggestion and target
@@ -445,6 +446,9 @@ class SuggestionManager {
445
446
  suggestionDiv.dataset.isFileLevel = targetInfo.isFileLevel ? 'true' : 'false';
446
447
  }
447
448
 
449
+ // Store line_end from the suggestion itself (may differ from targetInfo.lineNumber for multi-line suggestions)
450
+ suggestionDiv.dataset.lineEnd = suggestion.line_end !== undefined ? String(suggestion.line_end) : (targetInfo?.lineNumber !== undefined ? String(targetInfo.lineNumber) : '');
451
+
448
452
  // Convert suggestion.id to number for comparison since parent_id might be a number
449
453
  const suggestionIdNum = parseInt(suggestion.id);
450
454
 
@@ -526,7 +530,7 @@ class SuggestionManager {
526
530
  <svg viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"></path></svg>
527
531
  Adopt
528
532
  </button>
529
- <button class="ai-action ai-action-edit" onclick="prManager.adoptAndEditSuggestion(${suggestion.id})">
533
+ <button class="ai-action ai-action-edit" onclick="prManager.editAndAdoptSuggestion(${suggestion.id})">
530
534
  <svg viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M11.013 1.427a1.75 1.75 0 012.474 0l1.086 1.086a1.75 1.75 0 010 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 01-.927-.928l.929-3.25a1.75 1.75 0 01.445-.758l8.61-8.61zm1.414 1.06a.25.25 0 00-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 000-.354l-1.086-1.086zM11.189 6.25L9.75 4.81l-6.286 6.287a.25.25 0 00-.064.108l-.558 1.953 1.953-.558a.249.249 0 00.108-.064l6.286-6.286z"></path></svg>
531
535
  Edit
532
536
  </button>
@@ -556,6 +560,8 @@ class SuggestionManager {
556
560
  extractSuggestionData(suggestionDiv) {
557
561
  const suggestionText = suggestionDiv.dataset?.originalBody ?
558
562
  JSON.parse(suggestionDiv.dataset.originalBody) : '';
563
+ const formattedBody = suggestionDiv.dataset?.formattedBody ?
564
+ JSON.parse(suggestionDiv.dataset.formattedBody) : '';
559
565
 
560
566
  // Get type from ai-suggestion-badge data-type attribute or praise-badge
561
567
  const badgeElement = suggestionDiv.querySelector('.ai-suggestion-badge, .praise-badge');
@@ -563,7 +569,7 @@ class SuggestionManager {
563
569
  const suggestionType = badgeElement?.dataset?.type || (badgeElement?.classList?.contains('praise-badge') ? 'praise' : '');
564
570
  const suggestionTitle = titleElement?.textContent?.trim() || '';
565
571
 
566
- return { suggestionText, suggestionType, suggestionTitle };
572
+ return { suggestionText, formattedBody, suggestionType, suggestionTitle };
567
573
  }
568
574
 
569
575
  /**
@@ -609,6 +615,7 @@ class SuggestionManager {
609
615
  // This is the reliable method that works even with gap rows between suggestion and target
610
616
  const storedFileName = suggestionDiv.dataset.fileName;
611
617
  const storedLineNumber = suggestionDiv.dataset.lineNumber;
618
+ const storedLineEnd = suggestionDiv.dataset.lineEnd;
612
619
  const storedSide = suggestionDiv.dataset.side;
613
620
  const storedDiffPosition = suggestionDiv.dataset.diffPosition;
614
621
  const storedIsFileLevel = suggestionDiv.dataset.isFileLevel;
@@ -642,6 +649,7 @@ class SuggestionManager {
642
649
  targetRow,
643
650
  suggestionRow,
644
651
  lineNumber: parseInt(storedLineNumber, 10),
652
+ lineEnd: storedLineEnd ? parseInt(storedLineEnd, 10) : null,
645
653
  fileName: storedFileName,
646
654
  diffPosition: storedDiffPosition || null,
647
655
  side: storedSide || 'RIGHT',
package/public/js/pr.js CHANGED
@@ -970,11 +970,8 @@ class PRManager {
970
970
  const toggle = document.getElementById('pr-description-toggle');
971
971
  if (!toggle) return;
972
972
 
973
- const wrapper = toggle.closest('.pr-title-wrapper');
974
- if (!wrapper) return;
975
-
976
973
  const closePopover = () => {
977
- const existing = wrapper.querySelector('.pr-description-popover');
974
+ const existing = document.querySelector('.pr-description-popover');
978
975
  if (existing) existing.remove();
979
976
  toggle.classList.remove('active');
980
977
  toggle.setAttribute('aria-expanded', 'false');
@@ -982,7 +979,7 @@ class PRManager {
982
979
 
983
980
  toggle.addEventListener('click', (e) => {
984
981
  e.stopPropagation();
985
- const existing = wrapper.querySelector('.pr-description-popover');
982
+ const existing = document.querySelector('.pr-description-popover');
986
983
  if (existing) {
987
984
  closePopover();
988
985
  return;
@@ -1017,7 +1014,16 @@ class PRManager {
1017
1014
 
1018
1015
  popover.append(arrow, header, content);
1019
1016
 
1020
- wrapper.appendChild(popover);
1017
+ // Position relative to the toggle button
1018
+ const rect = toggle.getBoundingClientRect();
1019
+ popover.style.position = 'fixed';
1020
+ popover.style.top = `${rect.bottom + 8}px`;
1021
+ popover.style.left = `${rect.left + rect.width / 2}px`;
1022
+ popover.style.transform = 'translateX(-50%)';
1023
+
1024
+ // Append to document.body to escape overflow:hidden on .header-center
1025
+ document.body.appendChild(popover);
1026
+
1021
1027
  toggle.classList.add('active');
1022
1028
  toggle.setAttribute('aria-expanded', 'true');
1023
1029
 
@@ -2241,6 +2247,7 @@ class PRManager {
2241
2247
 
2242
2248
  /**
2243
2249
  * Edit user comment
2250
+ * NOTE: similar edit form in comment-manager.js _buildEditFormRow — keep in sync
2244
2251
  */
2245
2252
  async editUserComment(commentId) {
2246
2253
  try {
@@ -2536,7 +2543,7 @@ class PRManager {
2536
2543
  */
2537
2544
  async clearAllUserComments() {
2538
2545
  // Count both line-level and file-level user comments
2539
- const lineCommentRows = document.querySelectorAll('.user-comment-row');
2546
+ const lineCommentRows = document.querySelectorAll('.user-comment-row:not(.suggestion-edit-pending)');
2540
2547
  const fileCommentCards = document.querySelectorAll('.file-comment-card.user-comment');
2541
2548
  const totalComments = lineCommentRows.length + fileCommentCards.length;
2542
2549
 
@@ -2654,7 +2661,7 @@ class PRManager {
2654
2661
  });
2655
2662
 
2656
2663
  // Clear existing comment rows before re-rendering
2657
- document.querySelectorAll('.user-comment-row').forEach(row => row.remove());
2664
+ document.querySelectorAll('.user-comment-row:not(.suggestion-edit-pending)').forEach(row => row.remove());
2658
2665
 
2659
2666
  // Before rendering, ensure all comment target lines are visible
2660
2667
  // (expand hidden hunks so the line rows exist in the DOM)
@@ -2836,7 +2843,27 @@ class PRManager {
2836
2843
  }
2837
2844
 
2838
2845
  /**
2839
- * Shared helper for adoptAndEditSuggestion and adoptSuggestion.
2846
+ * Build a user comment object from adoption/edit response data.
2847
+ * Single source of truth for comment shape — used by both adopt-as-is
2848
+ * and edit-then-adopt flows.
2849
+ */
2850
+ _buildCommentObject({ userCommentId, formattedBody, fileName, lineNumber, suggestionType, suggestionTitle, suggestionId, diffPosition, side }) {
2851
+ return {
2852
+ id: userCommentId,
2853
+ file: fileName,
2854
+ line_start: parseInt(lineNumber),
2855
+ body: formattedBody,
2856
+ type: suggestionType,
2857
+ title: suggestionTitle,
2858
+ parent_id: suggestionId,
2859
+ diff_position: diffPosition ? parseInt(diffPosition) : null,
2860
+ side: side || 'RIGHT',
2861
+ created_at: new Date().toISOString()
2862
+ };
2863
+ }
2864
+
2865
+ /**
2866
+ * Helper for adoptSuggestion (adopt-as-is flow).
2840
2867
  * Performs the /adopt fetch, collapses the suggestion, formats the comment,
2841
2868
  * and builds the newComment object. Returns { newComment, suggestionRow }
2842
2869
  * or null on failure. Throws on errors so the caller can handle them.
@@ -2865,20 +2892,12 @@ class PRManager {
2865
2892
  // Collapse the suggestion in the UI
2866
2893
  this.collapseSuggestionForAdoption(suggestionRow, suggestionId);
2867
2894
 
2868
- // Use the server-formatted body — server is the single source of truth
2869
- const formattedText = adoptResult.formattedBody;
2870
- const newComment = {
2871
- id: adoptResult.userCommentId,
2872
- file: fileName,
2873
- line_start: parseInt(lineNumber),
2874
- body: formattedText,
2875
- type: suggestionType,
2876
- title: suggestionTitle,
2877
- parent_id: suggestionId,
2878
- diff_position: diffPosition ? parseInt(diffPosition) : null,
2879
- side: side || 'RIGHT',
2880
- created_at: new Date().toISOString()
2881
- };
2895
+ const newComment = this._buildCommentObject({
2896
+ userCommentId: adoptResult.userCommentId,
2897
+ formattedBody: adoptResult.formattedBody,
2898
+ fileName, lineNumber, suggestionType, suggestionTitle,
2899
+ suggestionId, diffPosition, side
2900
+ });
2882
2901
 
2883
2902
  return { isFileLevel: false, newComment, suggestionRow };
2884
2903
  }
@@ -2911,37 +2930,88 @@ class PRManager {
2911
2930
  }
2912
2931
 
2913
2932
  /**
2914
- * Adopt an AI suggestion and open it in edit mode
2933
+ * Open an AI suggestion in edit mode without adopting it yet.
2934
+ * The suggestion is only adopted when the user clicks Save/Adopt.
2915
2935
  */
2916
- async adoptAndEditSuggestion(suggestionId) {
2936
+ async editAndAdoptSuggestion(suggestionId) {
2917
2937
  try {
2918
2938
  const suggestionDiv = document.querySelector(`[data-suggestion-id="${suggestionId}"]`);
2919
2939
  if (!suggestionDiv) throw new Error('Suggestion element not found');
2920
2940
 
2921
- const result = await this._adoptAndBuildComment(suggestionId, suggestionDiv);
2941
+ const { suggestionText, formattedBody, suggestionType, suggestionTitle } = this.extractSuggestionData(suggestionDiv);
2942
+ const { suggestionRow, lineNumber, lineEnd, fileName, diffPosition, side, isFileLevel } = this.getFileAndLineInfo(suggestionDiv);
2922
2943
 
2923
- if (result.isFileLevel) {
2944
+ if (isFileLevel) {
2924
2945
  if (!this.fileCommentManager) throw new Error('FileCommentManager not initialized');
2925
- const zone = this.fileCommentManager.findZoneForFile(result.fileName);
2926
- if (!zone) throw new Error(`Could not find file comments zone for ${result.fileName}`);
2946
+ const zone = this.fileCommentManager.findZoneForFile(fileName);
2947
+ if (!zone) throw new Error(`Could not find file comments zone for ${fileName}`);
2927
2948
 
2928
2949
  const suggestion = {
2929
2950
  id: suggestionId,
2930
- file: result.fileName,
2931
- body: result.suggestionText,
2932
- type: result.suggestionType,
2933
- title: result.suggestionTitle
2951
+ file: fileName,
2952
+ body: suggestionText,
2953
+ formattedBody,
2954
+ type: suggestionType,
2955
+ title: suggestionTitle
2934
2956
  };
2935
2957
 
2936
2958
  this.fileCommentManager.editAndAdoptAISuggestion(zone, suggestion);
2937
2959
  return;
2938
2960
  }
2939
2961
 
2940
- this.displayUserCommentInEditMode(result.newComment, result.suggestionRow);
2941
- this._notifyAdoption(suggestionId, result.newComment);
2962
+ // Line-level: show edit form WITHOUT adopting yet
2963
+ const suggestion = {
2964
+ id: suggestionId,
2965
+ file: fileName,
2966
+ body: formattedBody || suggestionText,
2967
+ type: suggestionType,
2968
+ title: suggestionTitle,
2969
+ lineNumber,
2970
+ lineEnd,
2971
+ diffPosition,
2972
+ side
2973
+ };
2974
+
2975
+ this.commentManager.displaySuggestionEditForm(
2976
+ suggestion,
2977
+ suggestionRow,
2978
+ async (editedText) => {
2979
+ // User clicked Save — now adopt via /edit endpoint
2980
+ try {
2981
+ const reviewId = this.currentPR?.id;
2982
+ const editResponse = await fetch(`/api/reviews/${reviewId}/suggestions/${suggestionId}/edit`, {
2983
+ method: 'POST',
2984
+ headers: { 'Content-Type': 'application/json' },
2985
+ body: JSON.stringify({ action: 'adopt_edited', editedText })
2986
+ });
2987
+
2988
+ if (!editResponse.ok) throw new Error('Failed to adopt suggestion with edits');
2989
+ const editResult = await editResponse.json();
2990
+
2991
+ // Collapse the suggestion card
2992
+ this.collapseSuggestionForAdoption(suggestionRow, suggestionId);
2993
+
2994
+ const newComment = this._buildCommentObject({
2995
+ userCommentId: editResult.userCommentId,
2996
+ formattedBody: editResult.formattedBody,
2997
+ fileName, lineNumber, suggestionType, suggestionTitle,
2998
+ suggestionId, diffPosition, side
2999
+ });
3000
+ this.displayUserComment(newComment, suggestionRow);
3001
+ this._notifyAdoption(suggestionId, newComment);
3002
+ } catch (error) {
3003
+ console.error('Error saving edited suggestion:', error);
3004
+ alert(`Failed to save suggestion: ${error.message}`);
3005
+ throw error; // Re-throw so displaySuggestionEditForm can re-enable the save button
3006
+ }
3007
+ },
3008
+ () => {
3009
+ // User clicked Cancel — nothing to revert
3010
+ }
3011
+ );
2942
3012
  } catch (error) {
2943
- console.error('Error adopting and editing suggestion:', error);
2944
- alert(`Failed to adopt suggestion: ${error.message}`);
3013
+ console.error('Error editing suggestion:', error);
3014
+ alert(`Failed to edit suggestion: ${error.message}`);
2945
3015
  }
2946
3016
  }
2947
3017
 
@@ -3262,7 +3332,7 @@ class PRManager {
3262
3332
  */
3263
3333
  updateCommentCount() {
3264
3334
  // Count both line-level comments (.user-comment-row) and file-level comments (.file-comment-card.user-comment)
3265
- const lineComments = document.querySelectorAll('.user-comment-row').length;
3335
+ const lineComments = document.querySelectorAll('.user-comment-row:not(.suggestion-edit-pending)').length;
3266
3336
  const fileComments = document.querySelectorAll('.file-comment-card.user-comment').length;
3267
3337
  const userComments = lineComments + fileComments;
3268
3338
 
@@ -3299,7 +3369,7 @@ class PRManager {
3299
3369
  const submitBtn = document.getElementById('submit-review-btn');
3300
3370
 
3301
3371
  // Count BOTH line-level and file-level comments for validation
3302
- const lineComments = document.querySelectorAll('.user-comment-row').length;
3372
+ const lineComments = document.querySelectorAll('.user-comment-row:not(.suggestion-edit-pending)').length;
3303
3373
  const fileComments = document.querySelectorAll('.file-comment-card.user-comment').length;
3304
3374
  const totalComments = lineComments + fileComments;
3305
3375
  if (reviewEvent === 'REQUEST_CHANGES' && !reviewBody && totalComments === 0) {
package/src/database.js CHANGED
@@ -2755,6 +2755,11 @@ class ReviewRepository {
2755
2755
  params.push(updates.name);
2756
2756
  }
2757
2757
 
2758
+ if (updates.local_base_branch !== undefined) {
2759
+ setClauses.push('local_base_branch = ?');
2760
+ params.push(updates.local_base_branch);
2761
+ }
2762
+
2758
2763
  if (updates.local_head_branch !== undefined) {
2759
2764
  setClauses.push('local_head_branch = ?');
2760
2765
  params.push(updates.local_head_branch);
@@ -170,4 +170,55 @@ function tryDefaultBranch(repoPath, currentBranch, deps) {
170
170
  return null;
171
171
  }
172
172
 
173
- module.exports = { detectBaseBranch };
173
+ /**
174
+ * Synchronously detect the default branch for a repository.
175
+ *
176
+ * Uses the same logic as tryDefaultBranch but returns just the branch name
177
+ * (or null). Suitable for call sites that need a quick, synchronous answer
178
+ * without the full detectBaseBranch priority chain.
179
+ *
180
+ * @param {string} repoPath - Absolute path to the repository
181
+ * @param {Object} [_deps] - Dependency overrides for testing
182
+ * @returns {string|null} Default branch name, or null if it cannot be determined
183
+ */
184
+ function getDefaultBranch(repoPath, _deps) {
185
+ const deps = { ...defaults, ..._deps };
186
+
187
+ // Try `git remote show origin`
188
+ try {
189
+ const output = deps.execSync('git remote show origin', {
190
+ cwd: repoPath,
191
+ encoding: 'utf8',
192
+ stdio: ['pipe', 'pipe', 'pipe'],
193
+ timeout: 5000
194
+ });
195
+
196
+ const match = output.match(/HEAD branch:\s*(.+)/);
197
+ if (match) {
198
+ const branch = match[1].trim();
199
+ if (branch && branch !== '(unknown)') {
200
+ return branch;
201
+ }
202
+ }
203
+ } catch {
204
+ // No remote or network issue — try local refs
205
+ }
206
+
207
+ // Fallback: check if main or master exists locally
208
+ for (const candidate of ['main', 'master']) {
209
+ try {
210
+ deps.execSync(`git rev-parse --verify ${candidate}`, {
211
+ cwd: repoPath,
212
+ encoding: 'utf8',
213
+ stdio: ['pipe', 'pipe', 'pipe']
214
+ });
215
+ return candidate;
216
+ } catch {
217
+ // Branch doesn't exist
218
+ }
219
+ }
220
+
221
+ return null;
222
+ }
223
+
224
+ module.exports = { detectBaseBranch, getDefaultBranch };
@@ -68,30 +68,24 @@ function deleteLocalReviewDiff(reviewId) {
68
68
 
69
69
  /**
70
70
  * Check whether branch scope should be selectable in the scope range selector.
71
- * Returns true when the branch has commits ahead of the base branch.
72
- * Non-fatal: returns false on any error.
71
+ * Returns true when the current branch is a non-default, non-detached branch,
72
+ * or when the scope already includes branch.
73
+ *
74
+ * @param {string} branchName - Current branch name
75
+ * @param {string} scopeStart - Current scope start stop
76
+ * @param {string} localPath - Absolute path to the repository (used to detect the actual default branch)
73
77
  */
74
- async function checkBranchAvailable(localPath, branchName, scopeStart, config, repositoryName) {
78
+ function isBranchAvailable(branchName, scopeStart, localPath) {
75
79
  if (includesBranch(scopeStart)) return true;
76
- if (!branchName || branchName === 'HEAD' || branchName === 'unknown' || !localPath) return false;
77
- try {
78
- const baseBranch = require('../git/base-branch');
79
- const depsOverride = getGitHubToken(config) ? { getGitHubToken: () => getGitHubToken(config) } : undefined;
80
- const detection = await baseBranch.detectBaseBranch(localPath, branchName, {
81
- repository: repositoryName,
82
- enableGraphite: config.enable_graphite === true,
83
- _deps: depsOverride
84
- });
85
- if (detection) {
86
- // Lazy require to ensure testability via vi.spyOn on the module exports
87
- const localReview = require('../local-review');
88
- const commitCount = await localReview.getBranchCommitCount(localPath, detection.baseBranch);
89
- return commitCount > 0;
90
- }
91
- } catch {
92
- // Non-fatal — branch stop stays disabled
80
+ if (!branchName || branchName === 'HEAD' || branchName === 'unknown') return false;
81
+
82
+ const { getDefaultBranch } = require('../git/base-branch');
83
+ const defaultBranch = localPath ? getDefaultBranch(localPath) : null;
84
+ // If detection fails, fall back to checking main/master
85
+ if (defaultBranch) {
86
+ return branchName !== defaultBranch;
93
87
  }
94
- return false;
88
+ return branchName !== 'main' && branchName !== 'master';
95
89
  }
96
90
 
97
91
  /**
@@ -506,6 +500,7 @@ router.post('/api/local/start', async (req, res) => {
506
500
  * Get local review metadata
507
501
  */
508
502
  router.get('/api/local/:reviewId', async (req, res) => {
503
+ const tEndpoint = Date.now();
509
504
  try {
510
505
  const reviewId = parseInt(req.params.reviewId);
511
506
 
@@ -602,13 +597,15 @@ router.get('/api/local/:reviewId', async (req, res) => {
602
597
 
603
598
  // Determine if Branch stop should be selectable in the scope range selector.
604
599
  // This is independent of branchInfo (which guards on no uncommitted changes).
605
- // Branch is available when: not detached HEAD, not on default branch, and has commits ahead.
606
- const branchAvailable = Boolean(branchInfo) || await checkBranchAvailable(
607
- review.local_path, branchName, scopeStart, req.app.get('config') || {}, repositoryName
608
- );
600
+ // Branch is available when: not detached HEAD, not on default branch.
601
+ const branchAvailable = Boolean(branchInfo) || isBranchAvailable(branchName, scopeStart, review.local_path);
609
602
 
610
603
  // Compute SHA abbreviation length from the repo's git config
611
604
  const shaAbbrevLength = getShaAbbrevLength(review.local_path);
605
+ const metadataElapsed = Date.now() - tEndpoint;
606
+ if (metadataElapsed > 200) {
607
+ logger.debug(`[perf] metadata#${reviewId} took ${metadataElapsed}ms (threshold: 200ms)`);
608
+ }
612
609
 
613
610
  res.json({
614
611
  id: review.id,
@@ -630,6 +627,29 @@ router.get('/api/local/:reviewId', async (req, res) => {
630
627
  updatedAt: review.updated_at
631
628
  });
632
629
 
630
+ // Background: pre-cache base branch detection so set-scope is fast later
631
+ if (!includesBranch(scopeStart) && !review.local_base_branch
632
+ && branchName && branchName !== 'HEAD' && branchName !== 'unknown'
633
+ && repositoryName && repositoryName.includes('/')) {
634
+ const bgConfig = req.app.get('config') || {};
635
+ const bgToken = getGitHubToken(bgConfig);
636
+ const bgT0 = Date.now();
637
+ const { detectBaseBranch } = require('../git/base-branch');
638
+ detectBaseBranch(review.local_path, branchName, {
639
+ repository: repositoryName,
640
+ enableGraphite: bgConfig.enable_graphite === true,
641
+ _deps: bgToken ? { getGitHubToken: () => bgToken } : undefined
642
+ }).then(detection => {
643
+ if (detection && detection.baseBranch) {
644
+ return reviewRepo.updateReview(reviewId, { local_base_branch: detection.baseBranch });
645
+ }
646
+ }).then(() => {
647
+ logger.debug(`[perf] metadata#${reviewId} background-detectBaseBranch: ${Date.now() - bgT0}ms`);
648
+ }).catch(err => {
649
+ logger.warn(`Background base branch detection failed: ${err.message}`);
650
+ });
651
+ }
652
+
633
653
  // Fire review.loaded hook (session already exists to be fetched by ID)
634
654
  const hookConfig = req.app.get('config') || {};
635
655
  if (hasHooks('review.loaded', hookConfig)) {
@@ -704,6 +724,7 @@ router.patch('/api/local/:reviewId/name', async (req, res) => {
704
724
  * Get local diff
705
725
  */
706
726
  router.get('/api/local/:reviewId/diff', async (req, res) => {
727
+ const tEndpoint = Date.now();
707
728
  try {
708
729
  const reviewId = parseInt(req.params.reviewId);
709
730
 
@@ -781,6 +802,10 @@ router.get('/api/local/:reviewId/diff', async (req, res) => {
781
802
  }
782
803
  }
783
804
 
805
+ const diffElapsed = Date.now() - tEndpoint;
806
+ if (diffElapsed > 200) {
807
+ logger.debug(`[perf] diff#${reviewId} took ${diffElapsed}ms (threshold: 200ms)`);
808
+ }
784
809
  res.json({
785
810
  diff: diffContent || '',
786
811
  generated_files: generatedFiles,
@@ -805,6 +830,7 @@ router.get('/api/local/:reviewId/diff', async (req, res) => {
805
830
  * Uses a digest of the diff content for accurate change detection
806
831
  */
807
832
  router.get('/api/local/:reviewId/check-stale', async (req, res) => {
833
+ const tEndpoint = Date.now();
808
834
  try {
809
835
  const reviewId = parseInt(req.params.reviewId);
810
836
 
@@ -861,6 +887,10 @@ router.get('/api/local/:reviewId/check-stale', async (req, res) => {
861
887
 
862
888
  // When branch is in scope and HEAD changed, early return (existing behavior)
863
889
  if (includesBranch(scopeStart) && headShaChanged) {
890
+ const staleEarlyElapsed = Date.now() - tEndpoint;
891
+ if (staleEarlyElapsed > 200) {
892
+ logger.debug(`[perf] check-stale#${reviewId} took ${staleEarlyElapsed}ms (threshold: 200ms)`);
893
+ }
864
894
  return res.json({
865
895
  isStale: true,
866
896
  headShaChanged,
@@ -918,6 +948,10 @@ router.get('/api/local/:reviewId/check-stale', async (req, res) => {
918
948
 
919
949
  const isStale = storedDiffData.digest !== currentDigest;
920
950
 
951
+ const staleElapsed = Date.now() - tEndpoint;
952
+ if (staleElapsed > 200) {
953
+ logger.debug(`[perf] check-stale#${reviewId} took ${staleElapsed}ms (threshold: 200ms)`);
954
+ }
921
955
  res.json({
922
956
  isStale,
923
957
  storedDigest: storedDiffData.digest,
@@ -1377,12 +1411,10 @@ router.post('/api/local/:reviewId/refresh', async (req, res) => {
1377
1411
 
1378
1412
  // Recompute branchAvailable so the frontend can update the scope selector
1379
1413
  // (e.g. after a commit creates the first branch-ahead commit).
1380
- const config = req.app.get('config') || {};
1414
+ // Lazy require to ensure testability via vi.spyOn on the module exports.
1381
1415
  let branchName;
1382
- try { branchName = await getCurrentBranch(localPath); } catch (_) { branchName = review.local_head_branch || null; }
1383
- const branchAvailable = await checkBranchAvailable(
1384
- localPath, branchName, scopeStart, config, review.repository
1385
- );
1416
+ try { branchName = await require('../local-review').getCurrentBranch(localPath); } catch (_) { branchName = review.local_head_branch || null; }
1417
+ const branchAvailable = isBranchAvailable(branchName, scopeStart, localPath);
1386
1418
 
1387
1419
  // Non-branch HEAD change: skip diff computation entirely — the old diff is
1388
1420
  // preserved until the user decides (via resolve-head-change) what to do.
@@ -1491,8 +1523,8 @@ router.post('/api/local/:reviewId/resolve-head-change', async (req, res) => {
1491
1523
 
1492
1524
  // Persist SHA and branch together in a single write so SQLite only
1493
1525
  // ever sees the final identity tuple — no transient intermediate state.
1494
- await reviewRepo.updateReview(reviewId, { local_head_sha: newHeadSha, local_head_branch: headBranch });
1495
- logger.log('API', `Updated HEAD SHA and branch on session ${reviewId}`, 'cyan');
1526
+ await reviewRepo.updateReview(reviewId, { local_head_sha: newHeadSha, local_head_branch: headBranch, local_base_branch: null });
1527
+ logger.log('API', `Updated HEAD SHA and branch on session ${reviewId} (cleared cached base branch)`, 'cyan');
1496
1528
 
1497
1529
  // Recompute and persist diff
1498
1530
  const scopedResult = await generateScopedDiff(localPath, scopeStart, scopeEnd, review.local_base_branch);
@@ -1506,10 +1538,7 @@ router.post('/api/local/:reviewId/resolve-head-change', async (req, res) => {
1506
1538
 
1507
1539
  // Recompute branchAvailable — the commit may have created the first
1508
1540
  // branch-ahead commit, making the Branch scope stop selectable.
1509
- const config = req.app.get('config') || {};
1510
- const branchAvailable = await checkBranchAvailable(
1511
- localPath, headBranch, scopeStart, config, review.repository
1512
- );
1541
+ const branchAvailable = isBranchAvailable(headBranch, scopeStart, localPath);
1513
1542
 
1514
1543
  return res.json({ success: true, action: 'updated', branchAvailable });
1515
1544
  }
@@ -1593,20 +1622,26 @@ router.post('/api/local/:reviewId/set-scope', async (req, res) => {
1593
1622
  let baseBranch = requestBaseBranch || null;
1594
1623
  let currentBranch = null;
1595
1624
  if (includesBranch(scopeStart)) {
1596
- currentBranch = await getCurrentBranch(localPath);
1625
+ currentBranch = await require('../local-review').getCurrentBranch(localPath);
1597
1626
  if (!baseBranch) {
1598
- const { detectBaseBranch } = require('../git/base-branch');
1599
- const config = req.app.get('config') || {};
1600
- const token = getGitHubToken(config);
1601
- const detection = await detectBaseBranch(localPath, currentBranch, {
1602
- repository: review.repository,
1603
- enableGraphite: config.enable_graphite === true,
1604
- _deps: token ? { getGitHubToken: () => token } : undefined
1605
- });
1606
- if (!detection) {
1607
- return res.status(400).json({ error: 'Could not detect base branch' });
1627
+ // Use cached base branch from background detection if available
1628
+ if (review.local_base_branch && review.local_head_branch === currentBranch) {
1629
+ baseBranch = review.local_base_branch;
1630
+ logger.debug(`[perf] set-scope#${reviewId} using cached base branch: ${baseBranch}`);
1631
+ } else {
1632
+ const { detectBaseBranch } = require('../git/base-branch');
1633
+ const config = req.app.get('config') || {};
1634
+ const token = getGitHubToken(config);
1635
+ const detection = await detectBaseBranch(localPath, currentBranch, {
1636
+ repository: review.repository,
1637
+ enableGraphite: config.enable_graphite === true,
1638
+ _deps: token ? { getGitHubToken: () => token } : undefined
1639
+ });
1640
+ if (!detection) {
1641
+ return res.status(400).json({ error: 'Could not detect base branch' });
1642
+ }
1643
+ baseBranch = detection.baseBranch;
1608
1644
  }
1609
- baseBranch = detection.baseBranch;
1610
1645
  }
1611
1646
 
1612
1647
  // Validate branch name to prevent shell injection