@in-the-loop-labs/pair-review 3.0.6 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/package.json +2 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/skills/analyze/references/level1-balanced.md +8 -0
  5. package/plugin-code-critic/skills/analyze/references/level1-fast.md +7 -0
  6. package/plugin-code-critic/skills/analyze/references/level1-thorough.md +8 -0
  7. package/plugin-code-critic/skills/analyze/references/level2-balanced.md +9 -0
  8. package/plugin-code-critic/skills/analyze/references/level2-fast.md +8 -0
  9. package/plugin-code-critic/skills/analyze/references/level2-thorough.md +9 -0
  10. package/plugin-code-critic/skills/analyze/references/level3-balanced.md +9 -0
  11. package/plugin-code-critic/skills/analyze/references/level3-fast.md +8 -0
  12. package/plugin-code-critic/skills/analyze/references/level3-thorough.md +9 -0
  13. package/plugin-code-critic/skills/analyze/references/orchestration-balanced.md +9 -0
  14. package/plugin-code-critic/skills/analyze/references/orchestration-fast.md +5 -0
  15. package/plugin-code-critic/skills/analyze/references/orchestration-thorough.md +9 -0
  16. package/public/css/analysis-config.css +83 -0
  17. package/public/css/pr.css +191 -4
  18. package/public/index.html +20 -0
  19. package/public/js/components/AIPanel.js +1 -1
  20. package/public/js/components/AdvancedConfigTab.js +83 -8
  21. package/public/js/components/AnalysisConfigModal.js +155 -5
  22. package/public/js/components/ChatPanel.js +22 -5
  23. package/public/js/components/CouncilProgressModal.js +239 -22
  24. package/public/js/components/TimeoutSelect.js +2 -0
  25. package/public/js/components/VoiceCentricConfigTab.js +179 -12
  26. package/public/js/index.js +119 -1
  27. package/public/js/local.js +141 -47
  28. package/public/js/modules/suggestion-manager.js +2 -1
  29. package/public/js/pr.js +71 -12
  30. package/public/js/repo-settings.js +2 -2
  31. package/public/local.html +32 -11
  32. package/public/pr.html +2 -0
  33. package/src/ai/analyzer.js +371 -111
  34. package/src/ai/claude-provider.js +2 -0
  35. package/src/ai/codex-provider.js +1 -1
  36. package/src/ai/copilot-provider.js +2 -0
  37. package/src/ai/executable-provider.js +534 -0
  38. package/src/ai/gemini-provider.js +2 -0
  39. package/src/ai/index.js +9 -1
  40. package/src/ai/pi-provider.js +10 -8
  41. package/src/ai/prompts/baseline/consolidation/balanced.js +54 -2
  42. package/src/ai/prompts/baseline/consolidation/fast.js +31 -1
  43. package/src/ai/prompts/baseline/consolidation/thorough.js +46 -3
  44. package/src/ai/prompts/baseline/level1/balanced.js +12 -0
  45. package/src/ai/prompts/baseline/level1/fast.js +11 -0
  46. package/src/ai/prompts/baseline/level1/thorough.js +12 -0
  47. package/src/ai/prompts/baseline/level2/balanced.js +13 -0
  48. package/src/ai/prompts/baseline/level2/fast.js +12 -0
  49. package/src/ai/prompts/baseline/level2/thorough.js +13 -0
  50. package/src/ai/prompts/baseline/level3/balanced.js +13 -0
  51. package/src/ai/prompts/baseline/level3/fast.js +12 -0
  52. package/src/ai/prompts/baseline/level3/thorough.js +13 -0
  53. package/src/ai/prompts/baseline/orchestration/balanced.js +15 -0
  54. package/src/ai/prompts/baseline/orchestration/fast.js +11 -0
  55. package/src/ai/prompts/baseline/orchestration/thorough.js +15 -0
  56. package/src/ai/prompts/render-for-skill.js +3 -0
  57. package/src/ai/prompts/shared/output-schema.js +8 -0
  58. package/src/ai/provider.js +89 -4
  59. package/src/chat/prompt-builder.js +17 -1
  60. package/src/chat/session-manager.js +32 -28
  61. package/src/config.js +15 -2
  62. package/src/database.js +59 -15
  63. package/src/git/base-branch.js +113 -29
  64. package/src/local-review.js +15 -9
  65. package/src/main.js +3 -2
  66. package/src/routes/analyses.js +34 -8
  67. package/src/routes/chat.js +15 -8
  68. package/src/routes/config.js +3 -120
  69. package/src/routes/councils.js +15 -6
  70. package/src/routes/executable-analysis.js +494 -0
  71. package/src/routes/local.js +152 -15
  72. package/src/routes/mcp.js +9 -4
  73. package/src/routes/pr.js +166 -29
  74. package/src/routes/reviews.js +31 -5
  75. package/src/routes/shared.js +72 -5
  76. package/src/routes/worktrees.js +4 -2
  77. package/src/utils/comment-formatter.js +28 -11
  78. package/src/utils/instructions.js +22 -8
  79. package/src/utils/logger.js +20 -10
@@ -525,10 +525,13 @@ class VoiceCentricConfigTab {
525
525
  }
526
526
  });
527
527
 
528
- // Provider change -> update model dropdowns
528
+ // Provider change -> update model dropdowns + executable state + timeout default
529
529
  panel.addEventListener('change', (e) => {
530
530
  if (e.target.classList.contains('voice-provider')) {
531
531
  this._updateModelDropdown(e.target);
532
+ this._updateExecutableState(e.target);
533
+ this._updateLevelToggleState();
534
+ this._applyProviderDefaultTimeout(e.target);
532
535
  }
533
536
  // Model change -> update tier to match model's recommended tier
534
537
  if (e.target.classList.contains('voice-model')) {
@@ -597,13 +600,16 @@ class VoiceCentricConfigTab {
597
600
  TimeoutSelect.mount(mount, { className: 'vc-timeout', title: 'Per-reviewer timeout' });
598
601
  }
599
602
 
600
- // Populate provider dropdown
603
+ // Populate provider dropdown and update executable state
601
604
  const newProviderSelect = list.querySelector(`.voice-provider[data-index="${index}"]`);
602
605
  if (newProviderSelect) {
603
606
  this._populateProviderDropdown(newProviderSelect);
607
+ this._applyProviderDefaultTimeout(newProviderSelect);
608
+ this._updateExecutableState(newProviderSelect);
604
609
  }
605
610
 
606
611
  this._updateRemoveButtonVisibility();
612
+ this._updateLevelToggleState();
607
613
  this._markDirty();
608
614
  }
609
615
 
@@ -619,6 +625,7 @@ class VoiceCentricConfigTab {
619
625
 
620
626
  this._reindexReviewers();
621
627
  this._updateRemoveButtonVisibility();
628
+ this._updateLevelToggleState();
622
629
  this._markDirty();
623
630
  }
624
631
 
@@ -669,6 +676,16 @@ class VoiceCentricConfigTab {
669
676
  iconBtn.classList.toggle('has-instructions', hasContent);
670
677
  }
671
678
 
679
+ /**
680
+ * Get the default timeout for a provider, falling back to the static DEFAULT_TIMEOUT.
681
+ * @param {string} providerId - Provider ID (e.g., 'pi', 'claude')
682
+ * @returns {number} Default timeout in ms
683
+ */
684
+ _getProviderDefaultTimeout(providerId) {
685
+ const provider = this.providers[providerId];
686
+ return provider?.defaultTimeout ?? VoiceCentricConfigTab.DEFAULT_TIMEOUT;
687
+ }
688
+
672
689
  /**
673
690
  * Update the clock/timeout icon styling to indicate non-default timeout.
674
691
  * @param {Element} panel - The council panel element
@@ -680,7 +697,9 @@ class VoiceCentricConfigTab {
680
697
  const iconBtn = wrapper?.querySelector(`.toggle-timeout-icon[data-index="${index}"]`);
681
698
  if (!iconBtn) return;
682
699
 
683
- const isNonDefault = parseInt(value, 10) !== VoiceCentricConfigTab.DEFAULT_TIMEOUT;
700
+ const providerId = wrapper?.querySelector('.voice-provider')?.value;
701
+ const defaultTimeout = this._getProviderDefaultTimeout(providerId);
702
+ const isNonDefault = parseInt(value, 10) !== defaultTimeout;
684
703
  iconBtn.classList.toggle('has-custom-timeout', isNonDefault);
685
704
  }
686
705
 
@@ -688,10 +707,56 @@ class VoiceCentricConfigTab {
688
707
  const iconBtn = panel.querySelector('#vc-orchestration-timeout-toggle');
689
708
  if (!iconBtn) return;
690
709
 
691
- const isNonDefault = parseInt(value, 10) !== VoiceCentricConfigTab.DEFAULT_TIMEOUT;
710
+ const orchRow = panel.querySelector('#vc-orchestration-voice');
711
+ const providerId = orchRow?.querySelector('.voice-provider')?.value;
712
+ const defaultTimeout = this._getProviderDefaultTimeout(providerId);
713
+ const isNonDefault = parseInt(value, 10) !== defaultTimeout;
692
714
  iconBtn.classList.toggle('has-custom-timeout', isNonDefault);
693
715
  }
694
716
 
717
+ /**
718
+ * When a voice's provider changes, update its timeout to the new provider's default,
719
+ * preserving explicit user overrides via Math.max when the user had customized the value.
720
+ * @param {HTMLSelectElement} providerSelect - The provider dropdown that changed
721
+ */
722
+ _applyProviderDefaultTimeout(providerSelect) {
723
+ const panel = this.modal.querySelector('#tab-panel-council');
724
+ if (!panel) return;
725
+
726
+ const providerId = providerSelect.value;
727
+ const newDefault = this._getProviderDefaultTimeout(providerId);
728
+ const oldProviderId = providerSelect.dataset.previousProvider;
729
+ const oldDefault = oldProviderId ? this._getProviderDefaultTimeout(oldProviderId) : null;
730
+
731
+ // Determine which timeout element to update
732
+ const isOrchestration = providerSelect.dataset.target === 'orchestration';
733
+ if (isOrchestration) {
734
+ const timeoutEl = panel.querySelector('#vc-orchestration-timeout');
735
+ if (timeoutEl) {
736
+ const currentValue = parseInt(timeoutEl.value, 10);
737
+ const resolvedTimeout = (oldDefault !== null && currentValue !== oldDefault)
738
+ ? Math.max(currentValue, newDefault)
739
+ : newDefault;
740
+ timeoutEl.value = String(resolvedTimeout);
741
+ this._updateOrchestrationTimeoutIcon(panel, String(resolvedTimeout));
742
+ }
743
+ } else {
744
+ const idx = providerSelect.dataset.index;
745
+ const wrapper = providerSelect.closest('.vc-reviewer');
746
+ const timeoutEl = wrapper?.querySelector('.vc-timeout');
747
+ if (timeoutEl) {
748
+ const currentValue = parseInt(timeoutEl.value, 10);
749
+ const resolvedTimeout = (oldDefault !== null && currentValue !== oldDefault)
750
+ ? Math.max(currentValue, newDefault)
751
+ : newDefault;
752
+ timeoutEl.value = String(resolvedTimeout);
753
+ this._updateTimeoutIcon(panel, idx, String(resolvedTimeout));
754
+ }
755
+ }
756
+
757
+ providerSelect.dataset.previousProvider = providerId;
758
+ }
759
+
695
760
  // --- Dropdown / model management ---
696
761
 
697
762
  _updateAllVoiceDropdowns() {
@@ -779,6 +844,87 @@ class VoiceCentricConfigTab {
779
844
  }
780
845
  }
781
846
 
847
+ /**
848
+ * Update UI state for a reviewer row based on whether its provider is executable.
849
+ * Hides the tier dropdown and shows a note for executable providers.
850
+ * @param {HTMLSelectElement} providerSelect - The provider dropdown that changed
851
+ */
852
+ _updateExecutableState(providerSelect) {
853
+ const providerId = providerSelect.value;
854
+ const provider = this.providers[providerId];
855
+ const isExecutable = provider?.isExecutable || false;
856
+ const noCustomInstructions = provider?.capabilities?.custom_instructions === false;
857
+ const container = providerSelect.closest('.voice-row');
858
+ if (!container) return;
859
+
860
+ const tierSelect = container.querySelector('.voice-tier');
861
+ if (tierSelect) {
862
+ tierSelect.style.display = isExecutable ? 'none' : '';
863
+ }
864
+
865
+ // Hide per-reviewer instructions toggle and area when provider doesn't support them
866
+ const idx = providerSelect.dataset?.index;
867
+ const instrToggle = container.querySelector(`.toggle-instructions-icon[data-index="${idx}"]`);
868
+ if (instrToggle) {
869
+ instrToggle.style.display = noCustomInstructions ? 'none' : '';
870
+ }
871
+ const instrArea = container.querySelector(`.voice-instructions-area[data-index="${idx}"]`);
872
+ if (instrArea && noCustomInstructions) {
873
+ instrArea.style.display = 'none';
874
+ }
875
+
876
+ // Add or remove the executable note
877
+ let note = container.querySelector('.executable-note');
878
+ if (isExecutable && !note) {
879
+ note = document.createElement('span');
880
+ note.className = 'executable-note';
881
+ note.textContent = 'External tool';
882
+ note.title = 'External tool \u2014 runs its own analysis pipeline';
883
+ container.appendChild(note);
884
+ } else if (!isExecutable && note) {
885
+ note.remove();
886
+ }
887
+ }
888
+
889
+ /**
890
+ * Update level toggle state based on whether all voices are executable.
891
+ * If all voices are executable, disable level checkboxes and show a note.
892
+ * If any native voice is present, re-enable.
893
+ */
894
+ _updateLevelToggleState() {
895
+ const panel = this.modal.querySelector('#tab-panel-council');
896
+ if (!panel) return;
897
+
898
+ const reviewers = panel.querySelectorAll('.vc-reviewer');
899
+ let allExecutable = reviewers.length > 0;
900
+ reviewers.forEach(wrapper => {
901
+ const providerSelect = wrapper.querySelector('.voice-provider');
902
+ const providerId = providerSelect?.value;
903
+ const provider = this.providers[providerId];
904
+ if (!provider?.isExecutable) {
905
+ allExecutable = false;
906
+ }
907
+ });
908
+
909
+ const checkboxes = panel.querySelectorAll('.vc-level-checkbox');
910
+ checkboxes.forEach(cb => {
911
+ cb.disabled = allExecutable;
912
+ });
913
+
914
+ // Add or remove the all-executable note
915
+ const togglesContainer = panel.querySelector('.vc-level-toggles');
916
+ if (!togglesContainer) return;
917
+ let note = togglesContainer.querySelector('.vc-levels-disabled-note');
918
+ if (allExecutable && !note) {
919
+ note = document.createElement('p');
920
+ note.className = 'vc-levels-disabled-note section-hint-text';
921
+ note.textContent = 'Level selection does not apply when all reviewers are external tools';
922
+ togglesContainer.appendChild(note);
923
+ } else if (!allExecutable && note) {
924
+ note.remove();
925
+ }
926
+ }
927
+
782
928
  /**
783
929
  * Mount all TimeoutSelect instances on the panel.
784
930
  * Called after HTML is injected into the DOM.
@@ -808,10 +954,11 @@ class VoiceCentricConfigTab {
808
954
  // --- Config read/write ---
809
955
 
810
956
  _defaultConfig() {
957
+ const defaultTimeout = this._getProviderDefaultTimeout(this._defaultProvider);
811
958
  return {
812
- voices: [{ provider: this._defaultProvider, model: this._defaultModel, tier: 'balanced', timeout: VoiceCentricConfigTab.DEFAULT_TIMEOUT }],
959
+ voices: [{ provider: this._defaultProvider, model: this._defaultModel, tier: 'balanced', timeout: defaultTimeout }],
813
960
  enabledLevels: [1, 2, 3],
814
- orchestration: { provider: this._defaultProvider, model: this._defaultModel, tier: 'balanced', timeout: VoiceCentricConfigTab.DEFAULT_TIMEOUT }
961
+ orchestration: { provider: this._defaultProvider, model: this._defaultModel, tier: 'balanced', timeout: defaultTimeout }
815
962
  };
816
963
  }
817
964
 
@@ -836,7 +983,9 @@ class VoiceCentricConfigTab {
836
983
  const timeout = timeoutSelect ? parseInt(timeoutSelect.value, 10) : VoiceCentricConfigTab.DEFAULT_TIMEOUT;
837
984
  const idx = wrapper.dataset.index;
838
985
  const instrInput = wrapper.querySelector(`.voice-instructions-input[data-index="${idx}"]`);
839
- const customInstructions = instrInput?.value?.trim() || undefined;
986
+ const providerInfo = this.providers[provider];
987
+ const supportsCustomInstructions = providerInfo?.capabilities?.custom_instructions !== false;
988
+ const customInstructions = supportsCustomInstructions ? (instrInput?.value?.trim() || undefined) : undefined;
840
989
 
841
990
  if (provider && model) {
842
991
  const voice = { provider, model, tier, timeout };
@@ -939,7 +1088,7 @@ class VoiceCentricConfigTab {
939
1088
  list.innerHTML = '';
940
1089
  const voices = vcConfig.voices || [];
941
1090
  if (voices.length === 0) {
942
- voices.push({ provider: this._defaultProvider, model: this._defaultModel, tier: 'balanced', timeout: VoiceCentricConfigTab.DEFAULT_TIMEOUT });
1091
+ voices.push({ provider: this._defaultProvider, model: this._defaultModel, tier: 'balanced', timeout: this._getProviderDefaultTimeout(this._defaultProvider) });
943
1092
  }
944
1093
  voices.forEach((voice, i) => {
945
1094
  const wrapper = document.createElement('div');
@@ -954,7 +1103,9 @@ class VoiceCentricConfigTab {
954
1103
  if (providerSelect) {
955
1104
  this._populateProviderDropdown(providerSelect);
956
1105
  providerSelect.value = voice.provider;
1106
+ providerSelect.dataset.previousProvider = voice.provider;
957
1107
  this._updateModelDropdown(providerSelect);
1108
+ this._updateExecutableState(providerSelect);
958
1109
  const modelSelect = row.querySelector('.voice-model');
959
1110
  if (modelSelect) modelSelect.value = voice.model;
960
1111
  const tierSelect = row.querySelector('.voice-tier');
@@ -965,13 +1116,17 @@ class VoiceCentricConfigTab {
965
1116
  TimeoutSelect.mount(mount, { className: 'vc-timeout', title: 'Per-reviewer timeout' });
966
1117
  }
967
1118
  const timeoutEl = row.querySelector('.vc-timeout');
1119
+ const providerDefaultTimeout = this._getProviderDefaultTimeout(voice.provider);
968
1120
  if (timeoutEl && voice.timeout) {
969
1121
  timeoutEl.value = String(voice.timeout);
970
- // Show the dropdown if non-default
971
- if (voice.timeout !== VoiceCentricConfigTab.DEFAULT_TIMEOUT) {
1122
+ // Show the dropdown if non-default for this provider
1123
+ if (voice.timeout !== providerDefaultTimeout) {
972
1124
  timeoutEl.style.display = '';
973
1125
  }
974
1126
  this._updateTimeoutIcon(panel, String(i), String(voice.timeout));
1127
+ } else if (timeoutEl) {
1128
+ // No saved timeout — apply the provider's default
1129
+ timeoutEl.value = String(providerDefaultTimeout);
975
1130
  }
976
1131
  }
977
1132
 
@@ -985,6 +1140,7 @@ class VoiceCentricConfigTab {
985
1140
  });
986
1141
 
987
1142
  this._updateRemoveButtonVisibility();
1143
+ this._updateLevelToggleState();
988
1144
  }
989
1145
 
990
1146
  // Apply level toggles
@@ -1002,6 +1158,7 @@ class VoiceCentricConfigTab {
1002
1158
  if (providerSelect) {
1003
1159
  this._populateProviderDropdown(providerSelect);
1004
1160
  providerSelect.value = vcConfig.orchestration.provider;
1161
+ providerSelect.dataset.previousProvider = vcConfig.orchestration.provider;
1005
1162
  this._updateModelDropdown(providerSelect);
1006
1163
  const modelSelect = orchRow.querySelector('.voice-model');
1007
1164
  if (modelSelect) modelSelect.value = vcConfig.orchestration.model;
@@ -1012,13 +1169,18 @@ class VoiceCentricConfigTab {
1012
1169
 
1013
1170
  // Restore orchestration timeout
1014
1171
  const orchTimeoutSelect = panel.querySelector('#vc-orchestration-timeout');
1172
+ const orchProviderDefaultTimeout = this._getProviderDefaultTimeout(vcConfig.orchestration.provider);
1015
1173
  if (orchTimeoutSelect && vcConfig.orchestration.timeout) {
1016
1174
  orchTimeoutSelect.value = String(vcConfig.orchestration.timeout);
1017
- // Show the dropdown if non-default
1018
- if (vcConfig.orchestration.timeout !== VoiceCentricConfigTab.DEFAULT_TIMEOUT) {
1175
+ // Show the dropdown if non-default for this provider
1176
+ if (vcConfig.orchestration.timeout !== orchProviderDefaultTimeout) {
1019
1177
  orchTimeoutSelect.style.display = '';
1020
1178
  }
1021
1179
  this._updateOrchestrationTimeoutIcon(panel, String(vcConfig.orchestration.timeout));
1180
+ } else if (orchTimeoutSelect) {
1181
+ // No saved timeout — apply the provider's default
1182
+ orchTimeoutSelect.value = String(orchProviderDefaultTimeout);
1183
+ this._updateOrchestrationTimeoutIcon(panel, String(orchProviderDefaultTimeout));
1022
1184
  }
1023
1185
 
1024
1186
  // Restore orchestration custom instructions
@@ -1245,6 +1407,11 @@ class VoiceCentricConfigTab {
1245
1407
  }
1246
1408
  this._markClean();
1247
1409
  await this.loadCouncils();
1410
+ const selector = this.modal.querySelector('#vc-council-selector');
1411
+ if (selector) {
1412
+ selector.value = this.selectedCouncilId;
1413
+ selector.classList.remove('new-council-selected');
1414
+ }
1248
1415
  }
1249
1416
 
1250
1417
  async _postCouncil(name, config) {
@@ -553,6 +553,9 @@
553
553
  container.classList.remove('recent-reviews-loading');
554
554
  localReviewsPagination.loaded = true;
555
555
 
556
+ // Apply analysis-in-progress spinners to any rows with active analyses
557
+ fetchAndApplyActiveAnalyses();
558
+
556
559
  } catch (error) {
557
560
  console.error('Error loading local reviews:', error);
558
561
  container.innerHTML =
@@ -593,6 +596,9 @@
593
596
  tbody.insertAdjacentHTML('beforeend', data.sessions.map(renderLocalReviewRow).join(''));
594
597
  if (localSelection.active) localSelection.onRowsAdded();
595
598
 
599
+ // Apply analysis spinners to newly added rows
600
+ fetchAndApplyActiveAnalyses();
601
+
596
602
  localReviewsPagination.lastTimestamp = data.sessions[data.sessions.length - 1].updated_at;
597
603
  localReviewsPagination.hasMore = !!data.hasMore;
598
604
 
@@ -764,8 +770,10 @@
764
770
  ? '<a href="https://github.com/' + encodeURIComponent(review.author) + '" target="_blank" rel="noopener">' + escapeHtml(review.author) + '</a>'
765
771
  : '';
766
772
 
773
+ var reviewIdAttr = review.review_id ? ' data-analysis-review-id="' + review.review_id + '"' : '';
774
+
767
775
  return '' +
768
- '<tr data-review-id="' + review.id + '">' +
776
+ '<tr data-review-id="' + review.id + '"' + reviewIdAttr + '>' +
769
777
  '<td class="col-repo">' + escapeHtml(review.repository) + '</td>' +
770
778
  '<td class="col-pr"><a href="' + link + '">#' + review.pr_number + '</a></td>' +
771
779
  '<td class="col-title" title="' + escapeHtml(review.pr_title) + '">' + escapeHtml(review.pr_title) + '</td>' +
@@ -927,6 +935,9 @@
927
935
  container.innerHTML = html;
928
936
  container.classList.remove('recent-reviews-loading');
929
937
 
938
+ // Apply analysis-in-progress spinners to any rows with active analyses
939
+ fetchAndApplyActiveAnalyses();
940
+
930
941
  } catch (error) {
931
942
  console.error('Error loading recent reviews:', error);
932
943
  container.innerHTML =
@@ -992,6 +1003,9 @@
992
1003
  tbody.insertAdjacentHTML('beforeend', data.reviews.map(renderRecentReviewRow).join(''));
993
1004
  if (prSelection.active) prSelection.onRowsAdded();
994
1005
 
1006
+ // Apply analysis spinners to newly added rows
1007
+ fetchAndApplyActiveAnalyses();
1008
+
995
1009
  // Update pagination state - advance the cursor
996
1010
  recentReviewsPagination.lastTimestamp = data.reviews[data.reviews.length - 1].last_accessed_at;
997
1011
  recentReviewsPagination.hasMore = !!data.hasMore;
@@ -1138,6 +1152,7 @@
1138
1152
  window.__pairReview.chatProviders = chatProviders;
1139
1153
  window.__pairReview.enableGraphite = config.enable_graphite === true;
1140
1154
  window.__pairReview.chatSpinner = config.chat_spinner || 'dots';
1155
+ window.__pairReview.chatEnterToSend = config.chat_enter_to_send !== false;
1141
1156
 
1142
1157
  // Set chat feature state based on config and provider availability
1143
1158
  let chatState = 'disabled';
@@ -1924,4 +1939,107 @@
1924
1939
  }
1925
1940
  });
1926
1941
 
1942
+ // ─── Analysis-in-progress Spinners ──────────────────────────────────────────
1943
+
1944
+ /** Set of reviewIds (integers) that currently have a spinner on the page */
1945
+ var activeSpinnerReviewIds = new Set();
1946
+
1947
+ /**
1948
+ * Find a table row matching the given reviewId.
1949
+ * Checks both PR rows (data-analysis-review-id) and local rows (data-session-id).
1950
+ * @param {number} reviewId - The reviews.id to find
1951
+ * @returns {HTMLElement|null}
1952
+ */
1953
+ function findRowByReviewId(reviewId) {
1954
+ return document.querySelector(
1955
+ 'tr[data-analysis-review-id="' + reviewId + '"], tr[data-session-id="' + reviewId + '"]'
1956
+ );
1957
+ }
1958
+
1959
+ /**
1960
+ * Add a spinner to the title/name cell of a row.
1961
+ * @param {HTMLElement} row - The <tr> element
1962
+ * @param {number} reviewId - The reviewId to track
1963
+ */
1964
+ function addSpinnerToRow(row, reviewId) {
1965
+ if (row.querySelector('.index-analysis-spinner')) return;
1966
+
1967
+ var spinner = document.createElement('span');
1968
+ spinner.className = 'index-analysis-spinner';
1969
+ spinner.title = 'Analysis in progress';
1970
+
1971
+ // PR rows: prepend in .col-title; local rows: prepend in .col-local-name
1972
+ var cell = row.querySelector('.col-title') || row.querySelector('.col-local-name');
1973
+ if (cell) {
1974
+ cell.insertBefore(spinner, cell.firstChild);
1975
+ }
1976
+ activeSpinnerReviewIds.add(reviewId);
1977
+ }
1978
+
1979
+ /**
1980
+ * Remove a spinner from a row.
1981
+ * @param {HTMLElement} row - The <tr> element
1982
+ * @param {number} reviewId - The reviewId to untrack
1983
+ */
1984
+ function removeSpinnerFromRow(row, reviewId) {
1985
+ var spinner = row.querySelector('.index-analysis-spinner');
1986
+ if (spinner) spinner.remove();
1987
+ activeSpinnerReviewIds.delete(reviewId);
1988
+ }
1989
+
1990
+ /**
1991
+ * Fetch active analyses and apply/remove spinners to matching rows.
1992
+ */
1993
+ function fetchAndApplyActiveAnalyses() {
1994
+ fetch('/api/analyses/active')
1995
+ .then(function (res) { return res.json(); })
1996
+ .then(function (data) {
1997
+ if (!data.active) return;
1998
+
1999
+ var activeReviewIds = new Set();
2000
+ data.active.forEach(function (entry) {
2001
+ var rid = entry.reviewId;
2002
+ if (rid == null) return;
2003
+ activeReviewIds.add(rid);
2004
+ var row = findRowByReviewId(rid);
2005
+ if (row) addSpinnerToRow(row, rid);
2006
+ });
2007
+
2008
+ // Remove spinners for analyses that are no longer active
2009
+ activeSpinnerReviewIds.forEach(function (rid) {
2010
+ if (!activeReviewIds.has(rid)) {
2011
+ var row = findRowByReviewId(rid);
2012
+ if (row) removeSpinnerFromRow(row, rid);
2013
+ else activeSpinnerReviewIds.delete(rid);
2014
+ }
2015
+ });
2016
+ })
2017
+ .catch(function () { /* ignore — spinners are best-effort */ });
2018
+ }
2019
+
2020
+ // Subscribe to real-time analysis events via WebSocket
2021
+ if (window.wsClient) {
2022
+ window.wsClient.connect();
2023
+ window.wsClient.subscribe('index:analyses', function (msg) {
2024
+ var reviewId = msg.reviewId;
2025
+ if (reviewId == null) return;
2026
+
2027
+ if (msg.type === 'analysis_started') {
2028
+ var row = findRowByReviewId(reviewId);
2029
+ if (row) addSpinnerToRow(row, reviewId);
2030
+ } else if (msg.type === 'analysis_ended') {
2031
+ var endRow = findRowByReviewId(reviewId);
2032
+ if (endRow) removeSpinnerFromRow(endRow, reviewId);
2033
+ else activeSpinnerReviewIds.delete(reviewId);
2034
+ }
2035
+ });
2036
+
2037
+ // On reconnect, re-fetch active analyses to catch anything missed
2038
+ window.addEventListener('wsReconnected', fetchAndApplyActiveAnalyses);
2039
+ }
2040
+
2041
+ // Expose for use after data loads (loadRecentReviews / loadLocalReviews)
2042
+ window.__pairReview = window.__pairReview || {};
2043
+ window.__pairReview.refreshAnalysisSpinners = fetchAndApplyActiveAnalyses;
2044
+
1927
2045
  })();