@in-the-loop-labs/pair-review 2.6.2 → 2.7.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.
Files changed (50) hide show
  1. package/bin/git-diff-lines +1 -1
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +1 -1
  6. package/public/css/pr.css +201 -0
  7. package/public/index.html +168 -3
  8. package/public/js/components/AIPanel.js +16 -2
  9. package/public/js/components/ChatPanel.js +41 -6
  10. package/public/js/components/ConfirmDialog.js +21 -2
  11. package/public/js/components/CouncilProgressModal.js +13 -0
  12. package/public/js/components/DiffOptionsDropdown.js +410 -23
  13. package/public/js/components/SuggestionNavigator.js +12 -5
  14. package/public/js/components/TabTitle.js +96 -0
  15. package/public/js/components/Toast.js +6 -0
  16. package/public/js/index.js +648 -43
  17. package/public/js/local.js +569 -76
  18. package/public/js/modules/analysis-history.js +3 -2
  19. package/public/js/modules/comment-manager.js +5 -0
  20. package/public/js/modules/comment-minimizer.js +304 -0
  21. package/public/js/pr.js +82 -6
  22. package/public/local.html +14 -0
  23. package/public/pr.html +3 -0
  24. package/src/ai/analyzer.js +22 -16
  25. package/src/ai/cursor-agent-provider.js +21 -12
  26. package/src/chat/prompt-builder.js +3 -3
  27. package/src/config.js +2 -0
  28. package/src/database.js +590 -39
  29. package/src/git/base-branch.js +173 -0
  30. package/src/git/sha-abbrev.js +35 -0
  31. package/src/git/worktree.js +3 -2
  32. package/src/github/client.js +32 -1
  33. package/src/hooks/hook-runner.js +100 -0
  34. package/src/hooks/payloads.js +212 -0
  35. package/src/local-review.js +468 -129
  36. package/src/local-scope.js +58 -0
  37. package/src/main.js +57 -6
  38. package/src/routes/analyses.js +73 -10
  39. package/src/routes/chat.js +33 -0
  40. package/src/routes/config.js +1 -0
  41. package/src/routes/github-collections.js +2 -2
  42. package/src/routes/local.js +734 -68
  43. package/src/routes/mcp.js +20 -10
  44. package/src/routes/pr.js +92 -14
  45. package/src/routes/setup.js +1 -0
  46. package/src/routes/worktrees.js +212 -148
  47. package/src/server.js +30 -0
  48. package/src/setup/local-setup.js +46 -5
  49. package/src/setup/pr-setup.js +28 -5
  50. package/src/utils/diff-file-list.js +1 -1
@@ -66,6 +66,12 @@
66
66
  // Close on Escape key
67
67
  document.addEventListener('keydown', function (e) {
68
68
  if (e.key === 'Escape') {
69
+ // Exit selection mode first (higher priority)
70
+ if (activeSelection && activeSelection.active) {
71
+ activeSelection.exit();
72
+ return;
73
+ }
74
+ // Then try closing help modal
69
75
  const overlay = document.getElementById('help-modal-overlay');
70
76
  if (overlay.classList.contains('visible')) {
71
77
  closeHelpModal();
@@ -229,8 +235,9 @@
229
235
  const link = '/local/' + session.id;
230
236
  const relativeTime = formatRelativeTime(session.updated_at);
231
237
  const pathDisplay = session.local_path || '';
238
+ const abbrevLen = session.sha_abbrev_length || 7;
232
239
  const sha = session.local_head_sha
233
- ? session.local_head_sha.substring(0, 7)
240
+ ? session.local_head_sha.substring(0, abbrevLen)
234
241
  : '';
235
242
  const hasName = !!session.name;
236
243
  const nameDisplay = hasName ? escapeHtml(session.name) : '<em>Untitled</em>';
@@ -328,7 +335,7 @@
328
335
  : '<td class="col-author">' + authorDisplay + '</td>';
329
336
 
330
337
  return '' +
331
- '<tr class="collection-pr-row" data-pr-url="' + escapeHtml(prUrl) + '">' +
338
+ '<tr class="collection-pr-row" data-pr-url="' + escapeHtml(prUrl) + '" data-owner="' + escapeHtml(pr.owner) + '" data-repo="' + escapeHtml(pr.repo) + '" data-number="' + pr.number + '">' +
332
339
  '<td class="col-repo">' + escapeHtml(repoFull) + '</td>' +
333
340
  '<td class="col-pr"><span class="collection-pr-number">#' + pr.number + '</span></td>' +
334
341
  '<td class="col-title" title="' + escapeHtml(pr.title || '') + '">' + escapeHtml(pr.title || '') + '</td>' +
@@ -345,6 +352,9 @@
345
352
  * @param {string} collection - The collection name ('review-requests' or 'my-prs')
346
353
  */
347
354
  function renderCollectionTable(container, state, collection) {
355
+ var sel = collection === 'review-requests' ? reviewRequestsSelection : myPrsSelection;
356
+ sel.exit();
357
+
348
358
  var fetchedAtId = collection === 'review-requests' ? 'review-requests-fetched-at' : 'my-prs-fetched-at';
349
359
  var fetchedAtEl = document.getElementById(fetchedAtId);
350
360
  if (fetchedAtEl) {
@@ -374,6 +384,8 @@
374
384
 
375
385
  var authorTh = collection === 'my-prs' ? '' : '<th>Author</th>';
376
386
 
387
+ var tbodyId = collection === 'review-requests' ? 'review-requests-tbody' : 'my-prs-tbody';
388
+
377
389
  container.innerHTML =
378
390
  '<table class="recent-reviews-table">' +
379
391
  '<thead>' +
@@ -386,7 +398,7 @@
386
398
  '<th>Actions</th>' +
387
399
  '</tr>' +
388
400
  '</thead>' +
389
- '<tbody>' +
401
+ '<tbody id="' + tbodyId + '">' +
390
402
  state.prs.map(function (pr) { return renderCollectionPrRow(pr, collection); }).join('') +
391
403
  '</tbody>' +
392
404
  '</table>';
@@ -486,6 +498,8 @@
486
498
  * Fetch and display local review sessions (initial load).
487
499
  */
488
500
  async function loadLocalReviews() {
501
+ localSelection.exit();
502
+
489
503
  const container = document.getElementById('local-reviews-container');
490
504
  if (!container) return;
491
505
 
@@ -577,6 +591,7 @@
577
591
  }
578
592
 
579
593
  tbody.insertAdjacentHTML('beforeend', data.sessions.map(renderLocalReviewRow).join(''));
594
+ if (localSelection.active) localSelection.onRowsAdded();
580
595
 
581
596
  localReviewsPagination.lastTimestamp = data.sessions[data.sessions.length - 1].updated_at;
582
597
  localReviewsPagination.hasMore = !!data.hasMore;
@@ -734,34 +749,34 @@
734
749
 
735
750
  /**
736
751
  * Render a single recent review table row
737
- * @param {Object} worktree - Worktree data
752
+ * @param {Object} review - Review data
738
753
  * @returns {string} HTML string for the table row
739
754
  */
740
- function renderRecentReviewRow(worktree) {
741
- const parts = worktree.repository.split('/');
755
+ function renderRecentReviewRow(review) {
756
+ const parts = review.repository.split('/');
742
757
  const owner = parts[0];
743
758
  const repo = parts[1];
744
- const link = '/pr/' + owner + '/' + repo + '/' + worktree.pr_number;
759
+ const link = '/pr/' + owner + '/' + repo + '/' + review.pr_number;
745
760
  const settingsLink = '/repo-settings.html?owner=' + encodeURIComponent(owner) + '&repo=' + encodeURIComponent(repo);
746
- const relativeTime = formatRelativeTime(worktree.last_accessed_at);
761
+ const relativeTime = formatRelativeTime(review.last_accessed_at);
747
762
 
748
- const authorDisplay = worktree.author
749
- ? '<a href="https://github.com/' + encodeURIComponent(worktree.author) + '" target="_blank" rel="noopener">' + escapeHtml(worktree.author) + '</a>'
763
+ const authorDisplay = review.author
764
+ ? '<a href="https://github.com/' + encodeURIComponent(review.author) + '" target="_blank" rel="noopener">' + escapeHtml(review.author) + '</a>'
750
765
  : '';
751
766
 
752
767
  return '' +
753
- '<tr>' +
754
- '<td class="col-repo">' + escapeHtml(worktree.repository) + '</td>' +
755
- '<td class="col-pr"><a href="' + link + '">#' + worktree.pr_number + '</a></td>' +
756
- '<td class="col-title" title="' + escapeHtml(worktree.pr_title) + '">' + escapeHtml(worktree.pr_title) + '</td>' +
768
+ '<tr data-review-id="' + review.id + '">' +
769
+ '<td class="col-repo">' + escapeHtml(review.repository) + '</td>' +
770
+ '<td class="col-pr"><a href="' + link + '">#' + review.pr_number + '</a></td>' +
771
+ '<td class="col-title" title="' + escapeHtml(review.pr_title) + '">' + escapeHtml(review.pr_title) + '</td>' +
757
772
  '<td class="col-author">' + authorDisplay + '</td>' +
758
773
  '<td class="col-time">' + relativeTime + '</td>' +
759
774
  '<td class="col-actions">' +
760
- '<a href="https://github.com/' + escapeHtml(worktree.repository) + '/pull/' + worktree.pr_number + '" target="_blank" rel="noopener" class="btn-github-link" title="Open on GitHub">' +
775
+ '<a href="https://github.com/' + escapeHtml(review.repository) + '/pull/' + review.pr_number + '" target="_blank" rel="noopener" class="btn-github-link" title="Open on GitHub">' +
761
776
  '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"/></svg>' +
762
777
  '</a>' +
763
- (window.__pairReview?.enableGraphite && worktree.html_url
764
- ? '<a href="' + escapeHtml(window.__pairReview.toGraphiteUrl(worktree.html_url)) + '" target="_blank" rel="noopener" class="btn-github-link" title="Open on Graphite">' +
778
+ (window.__pairReview?.enableGraphite && review.html_url
779
+ ? '<a href="' + escapeHtml(window.__pairReview.toGraphiteUrl(review.html_url)) + '" target="_blank" rel="noopener" class="btn-github-link" title="Open on Graphite">' +
765
780
  '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M9.7932,1.3079L3.101,3.101l-1.7932,6.6921,4.899,4.899,6.6921-1.7931,1.7932-6.6921L9.7932,1.3079Zm1.0936,11.6921H5.1133l-2.8867-5L5.1133,3h5.7735l2.8867,5-2.8867,5Z"/><polygon points="11.3504 4.6496 6.7737 3.4232 3.4232 6.7737 4.6496 11.3504 9.2263 12.5768 12.5768 9.2263 11.3504 4.6496"/></svg>' +
766
781
  '</a>'
767
782
  : '') +
@@ -771,11 +786,11 @@
771
786
  '</svg>' +
772
787
  '</a>' +
773
788
  '<button' +
774
- ' class="btn-delete-worktree"' +
775
- ' data-worktree-id="' + worktree.id + '"' +
776
- ' data-repository="' + escapeHtml(worktree.repository) + '"' +
777
- ' data-pr-number="' + worktree.pr_number + '"' +
778
- ' title="Delete worktree"' +
789
+ ' class="btn-delete-review"' +
790
+ ' data-review-id="' + review.id + '"' +
791
+ ' data-repository="' + escapeHtml(review.repository) + '"' +
792
+ ' data-pr-number="' + review.pr_number + '"' +
793
+ ' title="Delete review"' +
779
794
  '>' +
780
795
  '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">' +
781
796
  '<path fill-rule="evenodd" d="M6.5 1.75a.25.25 0 01.25-.25h2.5a.25.25 0 01.25.25V3h-3V1.75zm4.5 0V3h2.25a.75.75 0 010 1.5H2.75a.75.75 0 010-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM4.496 6.675a.75.75 0 10-1.492.15l.66 6.6A1.75 1.75 0 005.405 15h5.19a1.75 1.75 0 001.741-1.575l.66-6.6a.75.75 0 00-1.492-.15l-.66 6.6a.25.25 0 01-.249.225h-5.19a.25.25 0 01-.249-.225l-.66-6.6z"></path>' +
@@ -786,11 +801,11 @@
786
801
  }
787
802
 
788
803
  /**
789
- * Show inline delete confirmation for a PR worktree row
804
+ * Show inline delete confirmation for a PR review row
790
805
  * @param {HTMLElement} button - The delete button element
791
806
  */
792
- function showDeleteWorktreeConfirm(button) {
793
- const worktreeId = button.dataset.worktreeId;
807
+ function showDeleteReviewConfirm(button) {
808
+ const reviewId = button.dataset.reviewId;
794
809
  const repository = button.dataset.repository;
795
810
  const prNumber = button.dataset.prNumber;
796
811
  const row = button.closest('tr');
@@ -808,8 +823,8 @@
808
823
  row.innerHTML =
809
824
  '<td colspan="' + colCount + '">' +
810
825
  '<div class="delete-confirm-inner">' +
811
- '<span>Delete worktree for ' + escapeHtml(repository) + ' #' + escapeHtml(String(prNumber)) + '?</span>' +
812
- '<button class="btn-confirm-yes" data-worktree-id="' + worktreeId + '">Delete</button>' +
826
+ '<span>Delete review for ' + escapeHtml(repository) + ' #' + escapeHtml(String(prNumber)) + '?</span>' +
827
+ '<button class="btn-confirm-yes" data-review-id="' + reviewId + '">Delete</button>' +
813
828
  '<button class="btn-confirm-no">Cancel</button>' +
814
829
  '</div>' +
815
830
  '</td>';
@@ -817,20 +832,20 @@
817
832
  // Wire up buttons
818
833
  row.querySelector('.btn-confirm-yes').addEventListener('click', async function () {
819
834
  try {
820
- const response = await fetch('/api/worktrees/' + worktreeId, {
835
+ const response = await fetch('/api/worktrees/' + reviewId, {
821
836
  method: 'DELETE'
822
837
  });
823
838
 
824
839
  if (!response.ok) {
825
840
  const data = await response.json().catch(function () { return {}; });
826
- throw new Error(data.error || 'Failed to delete worktree');
841
+ throw new Error(data.error || 'Failed to delete review');
827
842
  }
828
843
 
829
844
  // Reload the recent reviews list
830
845
  await loadRecentReviews();
831
846
 
832
847
  } catch (error) {
833
- console.error('Error deleting worktree:', error);
848
+ console.error('Error deleting review:', error);
834
849
  // Restore row on failure
835
850
  row.classList.remove('delete-confirm-row');
836
851
  row.innerHTML = originalHTML;
@@ -847,7 +862,7 @@
847
862
  const recentReviewsPagination = {
848
863
  /** ISO timestamp of the last loaded item (cursor for next fetch) */
849
864
  lastTimestamp: null,
850
- /** Number of worktrees to fetch per page */
865
+ /** Number of reviews to fetch per page */
851
866
  pageSize: 10,
852
867
  /** Whether the server has indicated more results exist */
853
868
  hasMore: false
@@ -858,6 +873,8 @@
858
873
  * Resets pagination state and renders the full table from scratch.
859
874
  */
860
875
  async function loadRecentReviews() {
876
+ prSelection.exit();
877
+
861
878
  const container = document.getElementById('recent-reviews-container');
862
879
  const section = document.getElementById('recent-reviews-section');
863
880
  // Reset pagination state
@@ -873,7 +890,7 @@
873
890
 
874
891
  const data = await response.json();
875
892
 
876
- if (!data.success || !data.worktrees || data.worktrees.length === 0) {
893
+ if (!data.success || !data.reviews || data.reviews.length === 0) {
877
894
  // Show friendly empty state
878
895
  container.innerHTML =
879
896
  '<div class="recent-reviews-empty">' +
@@ -886,7 +903,7 @@
886
903
  }
887
904
 
888
905
  // Update pagination state - track the cursor for the next page
889
- recentReviewsPagination.lastTimestamp = data.worktrees[data.worktrees.length - 1].last_accessed_at;
906
+ recentReviewsPagination.lastTimestamp = data.reviews[data.reviews.length - 1].last_accessed_at;
890
907
  recentReviewsPagination.hasMore = !!data.hasMore;
891
908
 
892
909
  // Render the table of recent reviews
@@ -903,7 +920,7 @@
903
920
  '</tr>' +
904
921
  '</thead>' +
905
922
  '<tbody id="recent-reviews-tbody">' +
906
- data.worktrees.map(renderRecentReviewRow).join('') +
923
+ data.reviews.map(renderRecentReviewRow).join('') +
907
924
  '</tbody>' +
908
925
  '</table>' +
909
926
  renderShowMoreButton(data.hasMore);
@@ -937,7 +954,7 @@
937
954
  }
938
955
 
939
956
  /**
940
- * Load the next page of worktrees and append them to the existing table.
957
+ * Load the next page of reviews and append them to the existing table.
941
958
  * Called when the "Show more" button is clicked.
942
959
  */
943
960
  async function loadMoreReviews() {
@@ -963,7 +980,7 @@
963
980
  // Guard against stale response if the table was refreshed (e.g. by a delete) while loading
964
981
  if (!document.contains(btn)) return;
965
982
 
966
- if (!data.success || !data.worktrees || data.worktrees.length === 0) {
983
+ if (!data.success || !data.reviews || data.reviews.length === 0) {
967
984
  // No more results - remove the button
968
985
  const showMoreContainer = document.getElementById('show-more-container');
969
986
  if (showMoreContainer) showMoreContainer.remove();
@@ -972,10 +989,11 @@
972
989
  }
973
990
 
974
991
  // Append new rows to the existing table body
975
- tbody.insertAdjacentHTML('beforeend', data.worktrees.map(renderRecentReviewRow).join(''));
992
+ tbody.insertAdjacentHTML('beforeend', data.reviews.map(renderRecentReviewRow).join(''));
993
+ if (prSelection.active) prSelection.onRowsAdded();
976
994
 
977
995
  // Update pagination state - advance the cursor
978
- recentReviewsPagination.lastTimestamp = data.worktrees[data.worktrees.length - 1].last_accessed_at;
996
+ recentReviewsPagination.lastTimestamp = data.reviews[data.reviews.length - 1].last_accessed_at;
979
997
  recentReviewsPagination.hasMore = !!data.hasMore;
980
998
 
981
999
  // Update or remove the "Show more" button
@@ -1119,6 +1137,7 @@
1119
1137
  const chatProviders = config.chat_providers || [];
1120
1138
  window.__pairReview.chatProviders = chatProviders;
1121
1139
  window.__pairReview.enableGraphite = config.enable_graphite === true;
1140
+ window.__pairReview.chatSpinner = config.chat_spinner || 'dots';
1122
1141
 
1123
1142
  // Set chat feature state based on config and provider availability
1124
1143
  let chatState = 'disabled';
@@ -1139,16 +1158,528 @@
1139
1158
  }
1140
1159
  }
1141
1160
 
1161
+ // ─── Selection Mode ──────────────────────────────────────────────────────
1162
+
1163
+ /** Currently active SelectionMode instance (only one tab at a time) */
1164
+ var activeSelection = null;
1165
+
1166
+ /**
1167
+ * SelectionMode manages checkbox-based selection for a single tab's table.
1168
+ *
1169
+ * @param {Object} config
1170
+ * @param {string} config.tabId - Tab pane element ID (e.g. 'pr-tab')
1171
+ * @param {string} config.containerId - Table container ID (e.g. 'recent-reviews-container')
1172
+ * @param {string} config.tbodyId - Table body ID (e.g. 'recent-reviews-tbody')
1173
+ * @param {string} config.rowIdAttr - data attribute name on <tr> for the row's ID (e.g. 'reviewId' reads tr.dataset.reviewId)
1174
+ * @param {Array} config.actions - [{ label: string, className: string, handler: function(selectedIds, selectionInstance) }]
1175
+ */
1176
+ function SelectionMode(config) {
1177
+ this.config = config;
1178
+ this.active = false;
1179
+ this.selectedIds = new Set();
1180
+ this._actionBar = null;
1181
+ this._toggleBtn = null;
1182
+ this._confirming = false;
1183
+ }
1184
+
1185
+ SelectionMode.prototype.enter = function () {
1186
+ if (this.active) return;
1187
+ this.active = true;
1188
+ this.selectedIds.clear();
1189
+ this._confirming = false;
1190
+
1191
+ // Deactivate any other active selection
1192
+ if (activeSelection && activeSelection !== this) {
1193
+ activeSelection.exit();
1194
+ }
1195
+ activeSelection = this;
1196
+
1197
+ var container = document.getElementById(this.config.containerId);
1198
+ if (container) container.classList.add('selection-mode');
1199
+
1200
+ // Hide the Select button, show inline action controls
1201
+ if (this._toggleBtn) {
1202
+ this._toggleBtn.style.display = 'none';
1203
+ }
1204
+ this._ensureInlineActions();
1205
+ this._showInlineActions();
1206
+
1207
+ this._injectCheckboxes();
1208
+ this._updateInlineActions();
1209
+ };
1210
+
1211
+ SelectionMode.prototype.exit = function () {
1212
+ if (!this.active) return;
1213
+ this.active = false;
1214
+ this.selectedIds.clear();
1215
+ this._confirming = false;
1216
+
1217
+ if (activeSelection === this) activeSelection = null;
1218
+
1219
+ var container = document.getElementById(this.config.containerId);
1220
+ if (container) container.classList.remove('selection-mode');
1221
+
1222
+ // Show Select button, hide inline action controls
1223
+ if (this._toggleBtn) {
1224
+ this._toggleBtn.style.display = '';
1225
+ }
1226
+ this._hideInlineActions();
1227
+
1228
+ this._removeCheckboxes();
1229
+ };
1230
+
1231
+ SelectionMode.prototype.toggle = function () {
1232
+ if (this.active) {
1233
+ this.exit();
1234
+ } else {
1235
+ this.enter();
1236
+ }
1237
+ };
1238
+
1239
+ SelectionMode.prototype._getTable = function () {
1240
+ var container = document.getElementById(this.config.containerId);
1241
+ return container ? container.querySelector('table') : null;
1242
+ };
1243
+
1244
+ SelectionMode.prototype._getTbody = function () {
1245
+ return document.getElementById(this.config.tbodyId);
1246
+ };
1247
+
1248
+ SelectionMode.prototype._injectCheckboxes = function () {
1249
+ var table = this._getTable();
1250
+ if (!table) return;
1251
+
1252
+ // Add select-all checkbox to thead
1253
+ var thead = table.querySelector('thead tr');
1254
+ if (thead) {
1255
+ var th = document.createElement('th');
1256
+ th.className = 'col-select';
1257
+ th.innerHTML = '<input type="checkbox" class="select-all-checkbox" title="Select all">';
1258
+ thead.insertBefore(th, thead.firstChild);
1259
+
1260
+ var self = this;
1261
+ th.querySelector('input').addEventListener('change', function () {
1262
+ self._handleSelectAll(this.checked);
1263
+ });
1264
+ }
1265
+
1266
+ // Add checkboxes to all existing rows
1267
+ var tbody = this._getTbody();
1268
+ if (tbody) {
1269
+ var rows = tbody.querySelectorAll('tr');
1270
+ for (var i = 0; i < rows.length; i++) {
1271
+ this._injectCheckboxIntoRow(rows[i]);
1272
+ }
1273
+ }
1274
+ };
1275
+
1276
+ SelectionMode.prototype._injectCheckboxIntoRow = function (tr) {
1277
+ // Skip rows that already have a checkbox (e.g. delete-confirm rows)
1278
+ if (tr.querySelector('.col-select')) return;
1279
+ // Skip delete confirmation rows
1280
+ if (tr.classList.contains('delete-confirm-row')) return;
1281
+
1282
+ var rowId = tr.dataset[this.config.rowIdAttr];
1283
+ var td = document.createElement('td');
1284
+ td.className = 'col-select';
1285
+ td.innerHTML = '<input type="checkbox" data-select-id="' + (rowId || '') + '">';
1286
+ tr.insertBefore(td, tr.firstChild);
1287
+
1288
+ var self = this;
1289
+ td.querySelector('input').addEventListener('change', function () {
1290
+ self._handleRowCheckbox(this.dataset.selectId, this.checked, tr);
1291
+ });
1292
+ };
1293
+
1294
+ SelectionMode.prototype._removeCheckboxes = function () {
1295
+ var table = this._getTable();
1296
+ if (!table) return;
1297
+
1298
+ // Remove all .col-select cells
1299
+ var cells = table.querySelectorAll('.col-select');
1300
+ for (var i = 0; i < cells.length; i++) {
1301
+ cells[i].remove();
1302
+ }
1303
+
1304
+ // Remove selected class from all rows
1305
+ var rows = table.querySelectorAll('tr.bulk-selected');
1306
+ for (var j = 0; j < rows.length; j++) {
1307
+ rows[j].classList.remove('bulk-selected');
1308
+ }
1309
+ };
1310
+
1311
+ SelectionMode.prototype._handleSelectAll = function (checked) {
1312
+ var tbody = this._getTbody();
1313
+ if (!tbody) return;
1314
+
1315
+ var checkboxes = tbody.querySelectorAll('.col-select input[type="checkbox"]');
1316
+ for (var i = 0; i < checkboxes.length; i++) {
1317
+ var cb = checkboxes[i];
1318
+ cb.checked = checked;
1319
+ var id = cb.dataset.selectId;
1320
+ var row = cb.closest('tr');
1321
+ if (checked) {
1322
+ if (id) this.selectedIds.add(id);
1323
+ if (row) row.classList.add('bulk-selected');
1324
+ } else {
1325
+ this.selectedIds.delete(id);
1326
+ if (row) row.classList.remove('bulk-selected');
1327
+ }
1328
+ }
1329
+ this._updateInlineActions();
1330
+ };
1331
+
1332
+ SelectionMode.prototype._handleRowCheckbox = function (id, checked, tr) {
1333
+ if (checked) {
1334
+ if (id) this.selectedIds.add(id);
1335
+ tr.classList.add('bulk-selected');
1336
+ } else {
1337
+ this.selectedIds.delete(id);
1338
+ tr.classList.remove('bulk-selected');
1339
+ }
1340
+
1341
+ // Update select-all checkbox state
1342
+ var table = this._getTable();
1343
+ if (table) {
1344
+ var selectAllCb = table.querySelector('.select-all-checkbox');
1345
+ if (selectAllCb) {
1346
+ var tbody = this._getTbody();
1347
+ var total = tbody ? tbody.querySelectorAll('.col-select input[type="checkbox"]').length : 0;
1348
+ selectAllCb.checked = total > 0 && this.selectedIds.size === total;
1349
+ selectAllCb.indeterminate = !selectAllCb.checked && this.selectedIds.size > 0;
1350
+ }
1351
+ }
1352
+
1353
+ this._updateInlineActions();
1354
+ };
1355
+
1356
+ SelectionMode.prototype.onRowsAdded = function () {
1357
+ if (!this.active) return;
1358
+ var tbody = this._getTbody();
1359
+ if (!tbody) return;
1360
+
1361
+ var rows = tbody.querySelectorAll('tr');
1362
+ for (var i = 0; i < rows.length; i++) {
1363
+ if (!rows[i].querySelector('.col-select')) {
1364
+ this._injectCheckboxIntoRow(rows[i]);
1365
+ }
1366
+ }
1367
+
1368
+ // Uncheck select-all since new rows are not selected
1369
+ var table = this._getTable();
1370
+ if (table) {
1371
+ var selectAllCb = table.querySelector('.select-all-checkbox');
1372
+ if (selectAllCb) {
1373
+ selectAllCb.checked = false;
1374
+ selectAllCb.indeterminate = this.selectedIds.size > 0;
1375
+ }
1376
+ }
1377
+ };
1378
+
1379
+ /**
1380
+ * Build the inline action controls (action buttons + count + cancel) and
1381
+ * insert them next to the Select toggle button. Created once, then
1382
+ * shown/hidden on enter/exit.
1383
+ */
1384
+ SelectionMode.prototype._ensureInlineActions = function () {
1385
+ if (this._inlineEl) return;
1386
+ if (!this._toggleBtn) return;
1387
+
1388
+ var wrapper = document.createElement('span');
1389
+ wrapper.className = 'bulk-inline-actions';
1390
+
1391
+ // Count label
1392
+ var countSpan = document.createElement('span');
1393
+ countSpan.className = 'bulk-action-count';
1394
+ wrapper.appendChild(countSpan);
1395
+
1396
+ // Action buttons (disabled by default — enabled when selection count > 0)
1397
+ var buttonsSpan = document.createElement('span');
1398
+ buttonsSpan.className = 'bulk-action-buttons';
1399
+ var self = this;
1400
+ this._actionBtns = [];
1401
+ for (var i = 0; i < this.config.actions.length; i++) {
1402
+ var action = this.config.actions[i];
1403
+ var btn = document.createElement('button');
1404
+ btn.className = action.className;
1405
+ btn.textContent = action.label;
1406
+ btn.disabled = true;
1407
+ btn.addEventListener('click', (function (act) {
1408
+ return function () {
1409
+ act.handler(new Set(self.selectedIds), self);
1410
+ };
1411
+ })(action));
1412
+ buttonsSpan.appendChild(btn);
1413
+ this._actionBtns.push(btn);
1414
+ }
1415
+ wrapper.appendChild(buttonsSpan);
1416
+
1417
+ // Confirm buttons (hidden by default, shown when confirming)
1418
+ var confirmSpan = document.createElement('span');
1419
+ confirmSpan.className = 'bulk-confirm-buttons';
1420
+ var confirmYes = document.createElement('button');
1421
+ confirmYes.className = 'btn-bulk-delete';
1422
+ confirmYes.textContent = 'Confirm';
1423
+ var confirmNo = document.createElement('button');
1424
+ confirmNo.className = 'btn-bulk-cancel';
1425
+ confirmNo.textContent = 'Cancel';
1426
+ confirmSpan.appendChild(confirmYes);
1427
+ confirmSpan.appendChild(confirmNo);
1428
+ wrapper.appendChild(confirmSpan);
1429
+
1430
+ // Cancel button (exits selection mode)
1431
+ var cancelBtn = document.createElement('button');
1432
+ cancelBtn.className = 'btn-bulk-cancel';
1433
+ cancelBtn.textContent = 'Cancel';
1434
+ cancelBtn.addEventListener('click', function () {
1435
+ self.exit();
1436
+ });
1437
+ wrapper.appendChild(cancelBtn);
1438
+
1439
+ // Insert after the toggle button
1440
+ this._toggleBtn.parentNode.insertBefore(wrapper, this._toggleBtn.nextSibling);
1441
+ this._inlineEl = wrapper;
1442
+ this._countEl = countSpan;
1443
+ this._confirmYes = confirmYes;
1444
+ this._confirmNo = confirmNo;
1445
+ };
1446
+
1447
+ SelectionMode.prototype._showInlineActions = function () {
1448
+ if (this._inlineEl) this._inlineEl.style.display = '';
1449
+ };
1450
+
1451
+ SelectionMode.prototype._hideInlineActions = function () {
1452
+ if (this._inlineEl) {
1453
+ this._inlineEl.style.display = 'none';
1454
+ this._inlineEl.classList.remove('confirming');
1455
+ this._confirming = false;
1456
+ }
1457
+ };
1458
+
1459
+ SelectionMode.prototype._updateInlineActions = function () {
1460
+ if (!this._inlineEl) return;
1461
+
1462
+ var count = this.selectedIds.size;
1463
+
1464
+ // Update count label
1465
+ // Clear count text when not confirming (only used for confirm message)
1466
+ if (this._countEl && !this._confirming) {
1467
+ this._countEl.textContent = '';
1468
+ }
1469
+
1470
+ // Enable/disable action buttons
1471
+ for (var i = 0; i < this._actionBtns.length; i++) {
1472
+ this._actionBtns[i].disabled = count === 0;
1473
+ }
1474
+
1475
+ // Exit confirming state if count drops to 0
1476
+ if (count === 0 && this._confirming) {
1477
+ this._inlineEl.classList.remove('confirming');
1478
+ this._confirming = false;
1479
+ }
1480
+ };
1481
+
1482
+ /**
1483
+ * Show confirmation state in the inline action controls.
1484
+ * @param {string} message - Confirmation message (e.g. "Delete 3 reviews?")
1485
+ * @param {Function} onConfirm - Called when user confirms
1486
+ */
1487
+ SelectionMode.prototype.showConfirm = function (message, onConfirm) {
1488
+ if (!this._inlineEl) return;
1489
+
1490
+ this._confirming = true;
1491
+ if (this._countEl) this._countEl.textContent = message;
1492
+ this._inlineEl.classList.add('confirming');
1493
+
1494
+ var self = this;
1495
+
1496
+ // Wire up confirm/cancel buttons (replace nodes to avoid stacking listeners)
1497
+ var newConfirmYes = this._confirmYes.cloneNode(true);
1498
+ this._confirmYes.parentNode.replaceChild(newConfirmYes, this._confirmYes);
1499
+ this._confirmYes = newConfirmYes;
1500
+
1501
+ var newConfirmNo = this._confirmNo.cloneNode(true);
1502
+ this._confirmNo.parentNode.replaceChild(newConfirmNo, this._confirmNo);
1503
+ this._confirmNo = newConfirmNo;
1504
+
1505
+ newConfirmYes.addEventListener('click', function () {
1506
+ self._inlineEl.classList.remove('confirming');
1507
+ self._confirming = false;
1508
+ onConfirm();
1509
+ });
1510
+
1511
+ newConfirmNo.addEventListener('click', function () {
1512
+ self._inlineEl.classList.remove('confirming');
1513
+ self._confirming = false;
1514
+ self._updateInlineActions();
1515
+ });
1516
+ };
1517
+
1518
+ // ─── Selection Mode Instances & Handlers ────────────────────────────────────
1519
+
1520
+ var prSelection = new SelectionMode({
1521
+ tabId: 'pr-tab',
1522
+ containerId: 'recent-reviews-container',
1523
+ tbodyId: 'recent-reviews-tbody',
1524
+ rowIdAttr: 'reviewId',
1525
+ actions: [
1526
+ { label: 'Delete', className: 'btn-bulk-delete', handler: handleBulkDeletePR }
1527
+ ]
1528
+ });
1529
+
1530
+ var localSelection = new SelectionMode({
1531
+ tabId: 'local-tab',
1532
+ containerId: 'local-reviews-container',
1533
+ tbodyId: 'local-reviews-tbody',
1534
+ rowIdAttr: 'sessionId',
1535
+ actions: [
1536
+ { label: 'Delete', className: 'btn-bulk-delete', handler: handleBulkDeleteLocal }
1537
+ ]
1538
+ });
1539
+
1540
+ var reviewRequestsSelection = new SelectionMode({
1541
+ tabId: 'review-requests-tab',
1542
+ containerId: 'review-requests-container',
1543
+ tbodyId: 'review-requests-tbody',
1544
+ rowIdAttr: 'prUrl',
1545
+ actions: [
1546
+ { label: 'Open', className: 'btn-bulk-open', handler: handleBulkOpen },
1547
+ { label: 'Analyze', className: 'btn-bulk-analyze', handler: handleBulkAnalyze }
1548
+ ]
1549
+ });
1550
+
1551
+ var myPrsSelection = new SelectionMode({
1552
+ tabId: 'my-prs-tab',
1553
+ containerId: 'my-prs-container',
1554
+ tbodyId: 'my-prs-tbody',
1555
+ rowIdAttr: 'prUrl',
1556
+ actions: [
1557
+ { label: 'Open', className: 'btn-bulk-open', handler: handleBulkOpen },
1558
+ { label: 'Analyze', className: 'btn-bulk-analyze', handler: handleBulkAnalyze }
1559
+ ]
1560
+ });
1561
+
1562
+ async function handleBulkDeletePR(selectedIds, selectionInstance) {
1563
+ var count = selectedIds.size;
1564
+ selectionInstance.showConfirm('Delete ' + count + ' review' + (count === 1 ? '' : 's') + '?', async function () {
1565
+ try {
1566
+ var response = await fetch('/api/worktrees/bulk-delete', {
1567
+ method: 'POST',
1568
+ headers: { 'Content-Type': 'application/json' },
1569
+ body: JSON.stringify({ ids: Array.from(selectedIds).map(Number) })
1570
+ });
1571
+ var data = await response.json();
1572
+ if (!response.ok) throw new Error(data.error || 'Bulk delete failed');
1573
+
1574
+ if (data.failed > 0) {
1575
+ if (window.toast) window.toast.error(data.failed + ' of ' + count + ' failed to delete');
1576
+ } else {
1577
+ if (window.toast) window.toast.success('Deleted ' + data.deleted + ' review' + (data.deleted === 1 ? '' : 's'));
1578
+ }
1579
+
1580
+ selectionInstance.exit();
1581
+ await loadRecentReviews();
1582
+ } catch (error) {
1583
+ console.error('Bulk delete PR error:', error);
1584
+ if (window.toast) window.toast.error('Bulk delete failed: ' + error.message);
1585
+ selectionInstance.exit();
1586
+ await loadRecentReviews();
1587
+ }
1588
+ });
1589
+ }
1590
+
1591
+ async function handleBulkDeleteLocal(selectedIds, selectionInstance) {
1592
+ var count = selectedIds.size;
1593
+ selectionInstance.showConfirm('Delete ' + count + ' session' + (count === 1 ? '' : 's') + '?', async function () {
1594
+ try {
1595
+ var response = await fetch('/api/local/sessions/bulk-delete', {
1596
+ method: 'POST',
1597
+ headers: { 'Content-Type': 'application/json' },
1598
+ body: JSON.stringify({ ids: Array.from(selectedIds).map(Number) })
1599
+ });
1600
+ var data = await response.json();
1601
+ if (!response.ok) throw new Error(data.error || 'Bulk delete failed');
1602
+
1603
+ if (data.failed > 0) {
1604
+ if (window.toast) window.toast.error(data.failed + ' of ' + count + ' failed to delete');
1605
+ } else {
1606
+ if (window.toast) window.toast.success('Deleted ' + data.deleted + ' session' + (data.deleted === 1 ? '' : 's'));
1607
+ }
1608
+
1609
+ selectionInstance.exit();
1610
+ await loadLocalReviews();
1611
+ } catch (error) {
1612
+ console.error('Bulk delete local error:', error);
1613
+ if (window.toast) window.toast.error('Bulk delete failed: ' + error.message);
1614
+ selectionInstance.exit();
1615
+ await loadLocalReviews();
1616
+ }
1617
+ });
1618
+ }
1619
+
1620
+ /**
1621
+ * Build pair-review URLs from selected collection rows.
1622
+ * @param {Set} selectedIds - PR URLs (data-pr-url values)
1623
+ * @param {string} tbodyId - tbody element ID
1624
+ * @param {string} [query] - optional query string (e.g. '?analyze=true')
1625
+ * @returns {string[]} array of pair-review URLs
1626
+ */
1627
+ function buildReviewUrls(selectedIds, tbodyId, query) {
1628
+ var tbody = document.getElementById(tbodyId);
1629
+ if (!tbody) return [];
1630
+ var urls = [];
1631
+ selectedIds.forEach(function (prUrl) {
1632
+ var row = tbody.querySelector('tr[data-pr-url="' + CSS.escape(prUrl) + '"]');
1633
+ if (!row) return;
1634
+ var owner = row.dataset.owner;
1635
+ var repo = row.dataset.repo;
1636
+ var number = row.dataset.number;
1637
+ if (owner && repo && number) {
1638
+ urls.push('/pr/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo) + '/' + number + (query || ''));
1639
+ }
1640
+ });
1641
+ return urls;
1642
+ }
1643
+
1644
+ /**
1645
+ * Open multiple review URLs via the server-side /api/bulk-open endpoint.
1646
+ * The server uses the OS `open` command to launch each URL in the default
1647
+ * browser, bypassing popup blockers entirely.
1648
+ */
1649
+ function bulkOpenUrls(urls) {
1650
+ if (urls.length === 0) return;
1651
+ fetch('/api/bulk-open', {
1652
+ method: 'POST',
1653
+ headers: { 'Content-Type': 'application/json' },
1654
+ body: JSON.stringify({ urls: urls })
1655
+ }).catch(function (err) {
1656
+ console.error('Bulk open failed:', err);
1657
+ if (window.toast) window.toast.error('Failed to open reviews');
1658
+ });
1659
+ }
1660
+
1661
+ function handleBulkOpen(selectedIds, selectionInstance) {
1662
+ var urls = buildReviewUrls(selectedIds, selectionInstance.config.tbodyId);
1663
+ selectionInstance.exit();
1664
+ bulkOpenUrls(urls);
1665
+ }
1666
+
1667
+ function handleBulkAnalyze(selectedIds, selectionInstance) {
1668
+ var urls = buildReviewUrls(selectedIds, selectionInstance.config.tbodyId, '?analyze=true');
1669
+ selectionInstance.exit();
1670
+ bulkOpenUrls(urls);
1671
+ }
1672
+
1142
1673
  // ─── Event Delegation ───────────────────────────────────────────────────────
1143
1674
 
1144
1675
  // Event delegation for buttons, show-more, tab switching
1145
1676
  document.addEventListener('click', function (event) {
1146
- // Delete worktree (PR mode)
1147
- const deleteBtn = event.target.closest('.btn-delete-worktree');
1677
+ // Delete review (PR mode)
1678
+ const deleteBtn = event.target.closest('.btn-delete-review');
1148
1679
  if (deleteBtn) {
1149
1680
  event.preventDefault();
1150
1681
  event.stopPropagation();
1151
- showDeleteWorktreeConfirm(deleteBtn);
1682
+ showDeleteReviewConfirm(deleteBtn);
1152
1683
  return;
1153
1684
  }
1154
1685
 
@@ -1176,9 +1707,30 @@
1176
1707
  return;
1177
1708
  }
1178
1709
 
1179
- // Click on a collection PR row to start review
1710
+ // Select toggle button
1711
+ var selectToggle = event.target.closest('.btn-select-toggle');
1712
+ if (selectToggle) {
1713
+ event.preventDefault();
1714
+ var tabId = selectToggle.dataset.selectionTab;
1715
+ var instances = { 'pr-tab': prSelection, 'local-tab': localSelection, 'review-requests-tab': reviewRequestsSelection, 'my-prs-tab': myPrsSelection };
1716
+ var instance = instances[tabId];
1717
+ if (instance) instance.toggle();
1718
+ return;
1719
+ }
1720
+
1721
+ // Click on a collection PR row — toggle checkbox in selection mode, else start review
1180
1722
  var collectionRow = event.target.closest('.collection-pr-row');
1181
- if (collectionRow && !event.target.closest('a')) {
1723
+ if (collectionRow && !event.target.closest('a') && !event.target.closest('.col-select')) {
1724
+ // If in selection mode, toggle the row's checkbox
1725
+ if (activeSelection && activeSelection.active && collectionRow.closest('.selection-mode')) {
1726
+ var cb = collectionRow.querySelector('.col-select input[type="checkbox"]');
1727
+ if (cb) {
1728
+ cb.checked = !cb.checked;
1729
+ cb.dispatchEvent(new Event('change'));
1730
+ }
1731
+ return;
1732
+ }
1733
+
1182
1734
  var prUrl = collectionRow.dataset.prUrl;
1183
1735
  if (prUrl) {
1184
1736
  // Switch to PR tab to show loading state (do NOT persist to
@@ -1222,6 +1774,8 @@
1222
1774
  if (unifiedTabBtn) {
1223
1775
  const tabBar = document.getElementById('unified-tab-bar');
1224
1776
  switchTab(tabBar, unifiedTabBtn, async function (tabId) {
1777
+ // Exit any active selection mode when switching tabs
1778
+ if (activeSelection) activeSelection.exit();
1225
1779
  // Persist tab choice
1226
1780
  localStorage.setItem(TAB_STORAGE_KEY, tabId);
1227
1781
  // Lazy-load local reviews on first switch
@@ -1304,6 +1858,57 @@
1304
1858
  // Note: No explicit Enter keypress handlers are needed here.
1305
1859
  // Both inputs are inside <form> elements, so pressing Enter
1306
1860
  // natively triggers form submission.
1861
+
1862
+ // ─── Create Select toggle buttons for each tab ──────────────────────────
1863
+
1864
+ function createSelectButton(tabId) {
1865
+ var btn = document.createElement('button');
1866
+ btn.className = 'btn-select-toggle';
1867
+ btn.type = 'button';
1868
+ btn.textContent = 'Select';
1869
+ btn.dataset.selectionTab = tabId;
1870
+ return btn;
1871
+ }
1872
+
1873
+ // PR tab: insert header between form and container
1874
+ var prTab = document.getElementById('pr-tab');
1875
+ if (prTab) {
1876
+ var prContainer = document.getElementById('recent-reviews-container');
1877
+ var prHeader = document.createElement('div');
1878
+ prHeader.className = 'select-mode-header visible';
1879
+ var prBtn = createSelectButton('pr-tab');
1880
+ prSelection._toggleBtn = prBtn;
1881
+ prHeader.appendChild(prBtn);
1882
+ prTab.insertBefore(prHeader, prContainer);
1883
+ }
1884
+
1885
+ // Local tab: insert header between form and container
1886
+ var localTab = document.getElementById('local-tab');
1887
+ if (localTab) {
1888
+ var localContainer = document.getElementById('local-reviews-container');
1889
+ var localHeader = document.createElement('div');
1890
+ localHeader.className = 'select-mode-header visible';
1891
+ var localBtn = createSelectButton('local-tab');
1892
+ localSelection._toggleBtn = localBtn;
1893
+ localHeader.appendChild(localBtn);
1894
+ localTab.insertBefore(localHeader, localContainer);
1895
+ }
1896
+
1897
+ // Review Requests tab: add to existing header
1898
+ var rrHeader = document.querySelector('#review-requests-tab .tab-pane-header');
1899
+ if (rrHeader) {
1900
+ var rrBtn = createSelectButton('review-requests-tab');
1901
+ reviewRequestsSelection._toggleBtn = rrBtn;
1902
+ rrHeader.insertBefore(rrBtn, rrHeader.firstChild);
1903
+ }
1904
+
1905
+ // My PRs tab: add to existing header
1906
+ var mpHeader = document.querySelector('#my-prs-tab .tab-pane-header');
1907
+ if (mpHeader) {
1908
+ var mpBtn = createSelectButton('my-prs-tab');
1909
+ myPrsSelection._toggleBtn = mpBtn;
1910
+ mpHeader.insertBefore(mpBtn, mpHeader.firstChild);
1911
+ }
1307
1912
  });
1308
1913
 
1309
1914
  // ─── bfcache Restoration ───────────────────────────────────────────────────