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

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 (81) 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 +103 -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 +87 -9
  21. package/public/js/components/AnalysisConfigModal.js +206 -5
  22. package/public/js/components/ChatPanel.js +22 -5
  23. package/public/js/components/CouncilProgressModal.js +241 -23
  24. package/public/js/components/TimeoutSelect.js +2 -0
  25. package/public/js/components/VoiceCentricConfigTab.js +183 -13
  26. package/public/js/index.js +119 -1
  27. package/public/js/local.js +166 -51
  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 +538 -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 +91 -4
  59. package/src/chat/prompt-builder.js +39 -4
  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/github/parser.js +1 -1
  65. package/src/local-review.js +15 -9
  66. package/src/local-scope.js +83 -0
  67. package/src/main.js +3 -2
  68. package/src/routes/analyses.js +34 -8
  69. package/src/routes/chat.js +15 -8
  70. package/src/routes/config.js +3 -120
  71. package/src/routes/councils.js +15 -6
  72. package/src/routes/executable-analysis.js +494 -0
  73. package/src/routes/local.js +152 -15
  74. package/src/routes/mcp.js +9 -4
  75. package/src/routes/pr.js +166 -29
  76. package/src/routes/reviews.js +31 -5
  77. package/src/routes/shared.js +72 -5
  78. package/src/routes/worktrees.js +4 -2
  79. package/src/utils/comment-formatter.js +28 -11
  80. package/src/utils/instructions.js +22 -8
  81. 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() {
@@ -705,10 +770,13 @@ class VoiceCentricConfigTab {
705
770
 
706
771
  _populateProviderDropdown(select) {
707
772
  const currentValue = select.value;
773
+ const isConsolidation = select.dataset.target === 'orchestration';
708
774
  select.innerHTML = '';
709
775
  const providerIds = Object.keys(this.providers).filter(id => {
710
776
  const p = this.providers[id];
711
- return !p.availability || p.availability.available;
777
+ if (p.availability && !p.availability.available) return false;
778
+ if (isConsolidation && p.capabilities?.consolidation === false) return false;
779
+ return true;
712
780
  }).sort((a, b) => (this.providers[a].name || a).localeCompare(this.providers[b].name || b));
713
781
 
714
782
  for (const id of providerIds) {
@@ -779,6 +847,87 @@ class VoiceCentricConfigTab {
779
847
  }
780
848
  }
781
849
 
850
+ /**
851
+ * Update UI state for a reviewer row based on whether its provider is executable.
852
+ * Hides the tier dropdown and shows a note for executable providers.
853
+ * @param {HTMLSelectElement} providerSelect - The provider dropdown that changed
854
+ */
855
+ _updateExecutableState(providerSelect) {
856
+ const providerId = providerSelect.value;
857
+ const provider = this.providers[providerId];
858
+ const isExecutable = provider?.isExecutable || false;
859
+ const noCustomInstructions = provider?.capabilities?.custom_instructions === false;
860
+ const container = providerSelect.closest('.voice-row');
861
+ if (!container) return;
862
+
863
+ const tierSelect = container.querySelector('.voice-tier');
864
+ if (tierSelect) {
865
+ tierSelect.style.display = isExecutable ? 'none' : '';
866
+ }
867
+
868
+ // Hide per-reviewer instructions toggle and area when provider doesn't support them
869
+ const idx = providerSelect.dataset?.index;
870
+ const instrToggle = container.querySelector(`.toggle-instructions-icon[data-index="${idx}"]`);
871
+ if (instrToggle) {
872
+ instrToggle.style.display = noCustomInstructions ? 'none' : '';
873
+ }
874
+ const instrArea = container.querySelector(`.voice-instructions-area[data-index="${idx}"]`);
875
+ if (instrArea && noCustomInstructions) {
876
+ instrArea.style.display = 'none';
877
+ }
878
+
879
+ // Add or remove the executable note
880
+ let note = container.querySelector('.executable-note');
881
+ if (isExecutable && !note) {
882
+ note = document.createElement('span');
883
+ note.className = 'executable-note';
884
+ note.textContent = 'External tool';
885
+ note.title = 'External tool \u2014 runs its own analysis pipeline';
886
+ container.appendChild(note);
887
+ } else if (!isExecutable && note) {
888
+ note.remove();
889
+ }
890
+ }
891
+
892
+ /**
893
+ * Update level toggle state based on whether all voices are executable.
894
+ * If all voices are executable, disable level checkboxes and show a note.
895
+ * If any native voice is present, re-enable.
896
+ */
897
+ _updateLevelToggleState() {
898
+ const panel = this.modal.querySelector('#tab-panel-council');
899
+ if (!panel) return;
900
+
901
+ const reviewers = panel.querySelectorAll('.vc-reviewer');
902
+ let allExecutable = reviewers.length > 0;
903
+ reviewers.forEach(wrapper => {
904
+ const providerSelect = wrapper.querySelector('.voice-provider');
905
+ const providerId = providerSelect?.value;
906
+ const provider = this.providers[providerId];
907
+ if (!provider?.isExecutable) {
908
+ allExecutable = false;
909
+ }
910
+ });
911
+
912
+ const checkboxes = panel.querySelectorAll('.vc-level-checkbox');
913
+ checkboxes.forEach(cb => {
914
+ cb.disabled = allExecutable;
915
+ });
916
+
917
+ // Add or remove the all-executable note
918
+ const togglesContainer = panel.querySelector('.vc-level-toggles');
919
+ if (!togglesContainer) return;
920
+ let note = togglesContainer.querySelector('.vc-levels-disabled-note');
921
+ if (allExecutable && !note) {
922
+ note = document.createElement('p');
923
+ note.className = 'vc-levels-disabled-note section-hint-text';
924
+ note.textContent = 'Level selection does not apply when all reviewers are external tools';
925
+ togglesContainer.appendChild(note);
926
+ } else if (!allExecutable && note) {
927
+ note.remove();
928
+ }
929
+ }
930
+
782
931
  /**
783
932
  * Mount all TimeoutSelect instances on the panel.
784
933
  * Called after HTML is injected into the DOM.
@@ -808,10 +957,11 @@ class VoiceCentricConfigTab {
808
957
  // --- Config read/write ---
809
958
 
810
959
  _defaultConfig() {
960
+ const defaultTimeout = this._getProviderDefaultTimeout(this._defaultProvider);
811
961
  return {
812
- voices: [{ provider: this._defaultProvider, model: this._defaultModel, tier: 'balanced', timeout: VoiceCentricConfigTab.DEFAULT_TIMEOUT }],
962
+ voices: [{ provider: this._defaultProvider, model: this._defaultModel, tier: 'balanced', timeout: defaultTimeout }],
813
963
  enabledLevels: [1, 2, 3],
814
- orchestration: { provider: this._defaultProvider, model: this._defaultModel, tier: 'balanced', timeout: VoiceCentricConfigTab.DEFAULT_TIMEOUT }
964
+ orchestration: { provider: this._defaultProvider, model: this._defaultModel, tier: 'balanced', timeout: defaultTimeout }
815
965
  };
816
966
  }
817
967
 
@@ -836,7 +986,9 @@ class VoiceCentricConfigTab {
836
986
  const timeout = timeoutSelect ? parseInt(timeoutSelect.value, 10) : VoiceCentricConfigTab.DEFAULT_TIMEOUT;
837
987
  const idx = wrapper.dataset.index;
838
988
  const instrInput = wrapper.querySelector(`.voice-instructions-input[data-index="${idx}"]`);
839
- const customInstructions = instrInput?.value?.trim() || undefined;
989
+ const providerInfo = this.providers[provider];
990
+ const supportsCustomInstructions = providerInfo?.capabilities?.custom_instructions !== false;
991
+ const customInstructions = supportsCustomInstructions ? (instrInput?.value?.trim() || undefined) : undefined;
840
992
 
841
993
  if (provider && model) {
842
994
  const voice = { provider, model, tier, timeout };
@@ -939,7 +1091,7 @@ class VoiceCentricConfigTab {
939
1091
  list.innerHTML = '';
940
1092
  const voices = vcConfig.voices || [];
941
1093
  if (voices.length === 0) {
942
- voices.push({ provider: this._defaultProvider, model: this._defaultModel, tier: 'balanced', timeout: VoiceCentricConfigTab.DEFAULT_TIMEOUT });
1094
+ voices.push({ provider: this._defaultProvider, model: this._defaultModel, tier: 'balanced', timeout: this._getProviderDefaultTimeout(this._defaultProvider) });
943
1095
  }
944
1096
  voices.forEach((voice, i) => {
945
1097
  const wrapper = document.createElement('div');
@@ -954,7 +1106,9 @@ class VoiceCentricConfigTab {
954
1106
  if (providerSelect) {
955
1107
  this._populateProviderDropdown(providerSelect);
956
1108
  providerSelect.value = voice.provider;
1109
+ providerSelect.dataset.previousProvider = voice.provider;
957
1110
  this._updateModelDropdown(providerSelect);
1111
+ this._updateExecutableState(providerSelect);
958
1112
  const modelSelect = row.querySelector('.voice-model');
959
1113
  if (modelSelect) modelSelect.value = voice.model;
960
1114
  const tierSelect = row.querySelector('.voice-tier');
@@ -965,13 +1119,17 @@ class VoiceCentricConfigTab {
965
1119
  TimeoutSelect.mount(mount, { className: 'vc-timeout', title: 'Per-reviewer timeout' });
966
1120
  }
967
1121
  const timeoutEl = row.querySelector('.vc-timeout');
1122
+ const providerDefaultTimeout = this._getProviderDefaultTimeout(voice.provider);
968
1123
  if (timeoutEl && voice.timeout) {
969
1124
  timeoutEl.value = String(voice.timeout);
970
- // Show the dropdown if non-default
971
- if (voice.timeout !== VoiceCentricConfigTab.DEFAULT_TIMEOUT) {
1125
+ // Show the dropdown if non-default for this provider
1126
+ if (voice.timeout !== providerDefaultTimeout) {
972
1127
  timeoutEl.style.display = '';
973
1128
  }
974
1129
  this._updateTimeoutIcon(panel, String(i), String(voice.timeout));
1130
+ } else if (timeoutEl) {
1131
+ // No saved timeout — apply the provider's default
1132
+ timeoutEl.value = String(providerDefaultTimeout);
975
1133
  }
976
1134
  }
977
1135
 
@@ -985,6 +1143,7 @@ class VoiceCentricConfigTab {
985
1143
  });
986
1144
 
987
1145
  this._updateRemoveButtonVisibility();
1146
+ this._updateLevelToggleState();
988
1147
  }
989
1148
 
990
1149
  // Apply level toggles
@@ -1002,6 +1161,7 @@ class VoiceCentricConfigTab {
1002
1161
  if (providerSelect) {
1003
1162
  this._populateProviderDropdown(providerSelect);
1004
1163
  providerSelect.value = vcConfig.orchestration.provider;
1164
+ providerSelect.dataset.previousProvider = vcConfig.orchestration.provider;
1005
1165
  this._updateModelDropdown(providerSelect);
1006
1166
  const modelSelect = orchRow.querySelector('.voice-model');
1007
1167
  if (modelSelect) modelSelect.value = vcConfig.orchestration.model;
@@ -1012,13 +1172,18 @@ class VoiceCentricConfigTab {
1012
1172
 
1013
1173
  // Restore orchestration timeout
1014
1174
  const orchTimeoutSelect = panel.querySelector('#vc-orchestration-timeout');
1175
+ const orchProviderDefaultTimeout = this._getProviderDefaultTimeout(vcConfig.orchestration.provider);
1015
1176
  if (orchTimeoutSelect && vcConfig.orchestration.timeout) {
1016
1177
  orchTimeoutSelect.value = String(vcConfig.orchestration.timeout);
1017
- // Show the dropdown if non-default
1018
- if (vcConfig.orchestration.timeout !== VoiceCentricConfigTab.DEFAULT_TIMEOUT) {
1178
+ // Show the dropdown if non-default for this provider
1179
+ if (vcConfig.orchestration.timeout !== orchProviderDefaultTimeout) {
1019
1180
  orchTimeoutSelect.style.display = '';
1020
1181
  }
1021
1182
  this._updateOrchestrationTimeoutIcon(panel, String(vcConfig.orchestration.timeout));
1183
+ } else if (orchTimeoutSelect) {
1184
+ // No saved timeout — apply the provider's default
1185
+ orchTimeoutSelect.value = String(orchProviderDefaultTimeout);
1186
+ this._updateOrchestrationTimeoutIcon(panel, String(orchProviderDefaultTimeout));
1022
1187
  }
1023
1188
 
1024
1189
  // Restore orchestration custom instructions
@@ -1245,6 +1410,11 @@ class VoiceCentricConfigTab {
1245
1410
  }
1246
1411
  this._markClean();
1247
1412
  await this.loadCouncils();
1413
+ const selector = this.modal.querySelector('#vc-council-selector');
1414
+ if (selector) {
1415
+ selector.value = this.selectedCouncilId;
1416
+ selector.classList.remove('new-council-selected');
1417
+ }
1248
1418
  }
1249
1419
 
1250
1420
  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
  })();