@in-the-loop-labs/pair-review 2.6.3 → 3.0.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 (150) hide show
  1. package/.pi/extensions/task/index.ts +1 -1
  2. package/.pi/skills/review-roulette/SKILL.md +1 -1
  3. package/LICENSE +201 -674
  4. package/README.md +2 -2
  5. package/bin/pair-review.js +1 -1
  6. package/package.json +2 -2
  7. package/plugin/.claude-plugin/plugin.json +2 -2
  8. package/plugin-code-critic/.claude-plugin/plugin.json +2 -2
  9. package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +1 -1
  10. package/public/css/ai-summary-modal.css +1 -1
  11. package/public/css/pr.css +194 -0
  12. package/public/index.html +168 -3
  13. package/public/js/components/AIPanel.js +17 -3
  14. package/public/js/components/AISummaryModal.js +1 -1
  15. package/public/js/components/AdvancedConfigTab.js +1 -1
  16. package/public/js/components/AnalysisConfigModal.js +1 -1
  17. package/public/js/components/ChatPanel.js +42 -7
  18. package/public/js/components/ConfirmDialog.js +22 -3
  19. package/public/js/components/CouncilProgressModal.js +14 -1
  20. package/public/js/components/DiffOptionsDropdown.js +411 -24
  21. package/public/js/components/EmojiPicker.js +1 -1
  22. package/public/js/components/KeyboardShortcuts.js +1 -1
  23. package/public/js/components/PanelGroup.js +1 -1
  24. package/public/js/components/PreviewModal.js +1 -1
  25. package/public/js/components/ReviewModal.js +1 -1
  26. package/public/js/components/SplitButton.js +1 -1
  27. package/public/js/components/StatusIndicator.js +1 -1
  28. package/public/js/components/SuggestionNavigator.js +13 -6
  29. package/public/js/components/TabTitle.js +96 -0
  30. package/public/js/components/TextInputDialog.js +1 -1
  31. package/public/js/components/TimeoutSelect.js +1 -1
  32. package/public/js/components/Toast.js +7 -1
  33. package/public/js/components/VoiceCentricConfigTab.js +1 -1
  34. package/public/js/index.js +649 -44
  35. package/public/js/local.js +570 -77
  36. package/public/js/modules/analysis-history.js +4 -3
  37. package/public/js/modules/comment-manager.js +6 -1
  38. package/public/js/modules/comment-minimizer.js +304 -0
  39. package/public/js/modules/diff-context.js +1 -1
  40. package/public/js/modules/diff-renderer.js +1 -1
  41. package/public/js/modules/file-comment-manager.js +1 -1
  42. package/public/js/modules/file-list-merger.js +1 -1
  43. package/public/js/modules/gap-coordinates.js +1 -1
  44. package/public/js/modules/hunk-parser.js +1 -1
  45. package/public/js/modules/line-tracker.js +1 -1
  46. package/public/js/modules/panel-resizer.js +1 -1
  47. package/public/js/modules/storage-cleanup.js +1 -1
  48. package/public/js/modules/suggestion-manager.js +1 -1
  49. package/public/js/pr.js +83 -7
  50. package/public/js/repo-settings.js +1 -1
  51. package/public/js/utils/category-emoji.js +1 -1
  52. package/public/js/utils/file-order.js +1 -1
  53. package/public/js/utils/markdown.js +1 -1
  54. package/public/js/utils/suggestion-ui.js +1 -1
  55. package/public/js/utils/tier-icons.js +1 -1
  56. package/public/js/utils/time.js +1 -1
  57. package/public/js/ws-client.js +1 -1
  58. package/public/local.html +14 -0
  59. package/public/pr.html +3 -0
  60. package/public/setup.html +1 -1
  61. package/src/ai/analyzer.js +18 -12
  62. package/src/ai/claude-cli.js +1 -1
  63. package/src/ai/claude-provider.js +1 -1
  64. package/src/ai/codex-provider.js +1 -1
  65. package/src/ai/copilot-provider.js +1 -1
  66. package/src/ai/cursor-agent-provider.js +1 -1
  67. package/src/ai/gemini-provider.js +1 -1
  68. package/src/ai/index.js +1 -1
  69. package/src/ai/opencode-provider.js +1 -1
  70. package/src/ai/pi-provider.js +1 -1
  71. package/src/ai/prompts/baseline/consolidation/balanced.js +1 -1
  72. package/src/ai/prompts/baseline/consolidation/fast.js +1 -1
  73. package/src/ai/prompts/baseline/consolidation/thorough.js +1 -1
  74. package/src/ai/prompts/baseline/level1/balanced.js +1 -1
  75. package/src/ai/prompts/baseline/level1/fast.js +1 -1
  76. package/src/ai/prompts/baseline/level1/thorough.js +1 -1
  77. package/src/ai/prompts/baseline/level2/balanced.js +1 -1
  78. package/src/ai/prompts/baseline/level2/fast.js +1 -1
  79. package/src/ai/prompts/baseline/level2/thorough.js +1 -1
  80. package/src/ai/prompts/baseline/level3/balanced.js +1 -1
  81. package/src/ai/prompts/baseline/level3/fast.js +1 -1
  82. package/src/ai/prompts/baseline/level3/thorough.js +1 -1
  83. package/src/ai/prompts/baseline/orchestration/balanced.js +1 -1
  84. package/src/ai/prompts/baseline/orchestration/fast.js +1 -1
  85. package/src/ai/prompts/baseline/orchestration/thorough.js +1 -1
  86. package/src/ai/prompts/config.js +1 -1
  87. package/src/ai/prompts/index.js +1 -1
  88. package/src/ai/prompts/line-number-guidance.js +1 -1
  89. package/src/ai/prompts/render-for-skill.js +1 -1
  90. package/src/ai/prompts/shared/diff-instructions.js +1 -1
  91. package/src/ai/prompts/shared/output-schema.js +1 -1
  92. package/src/ai/prompts/shared/valid-files.js +1 -1
  93. package/src/ai/prompts/sparse-checkout-guidance.js +1 -1
  94. package/src/ai/provider-availability.js +1 -1
  95. package/src/ai/provider.js +1 -1
  96. package/src/ai/stream-parser.js +1 -1
  97. package/src/chat/acp-bridge.js +1 -1
  98. package/src/chat/api-reference.js +1 -1
  99. package/src/chat/chat-providers.js +1 -1
  100. package/src/chat/claude-code-bridge.js +1 -1
  101. package/src/chat/codex-bridge.js +1 -1
  102. package/src/chat/pi-bridge.js +1 -1
  103. package/src/chat/prompt-builder.js +1 -1
  104. package/src/chat/session-manager.js +1 -1
  105. package/src/config.js +3 -1
  106. package/src/database.js +591 -40
  107. package/src/events/review-events.js +1 -1
  108. package/src/git/base-branch.js +173 -0
  109. package/src/git/gitattributes.js +1 -1
  110. package/src/git/sha-abbrev.js +35 -0
  111. package/src/git/worktree.js +1 -1
  112. package/src/github/client.js +33 -2
  113. package/src/github/parser.js +1 -1
  114. package/src/hooks/hook-runner.js +100 -0
  115. package/src/hooks/payloads.js +212 -0
  116. package/src/local-review.js +469 -130
  117. package/src/local-scope.js +58 -0
  118. package/src/main.js +56 -5
  119. package/src/mcp-stdio.js +1 -1
  120. package/src/protocol-handler.js +1 -1
  121. package/src/routes/analyses.js +74 -11
  122. package/src/routes/chat.js +34 -1
  123. package/src/routes/config.js +2 -1
  124. package/src/routes/context-files.js +1 -1
  125. package/src/routes/councils.js +1 -1
  126. package/src/routes/github-collections.js +1 -1
  127. package/src/routes/local.js +735 -69
  128. package/src/routes/mcp.js +21 -11
  129. package/src/routes/pr.js +91 -13
  130. package/src/routes/reviews.js +1 -1
  131. package/src/routes/setup.js +2 -1
  132. package/src/routes/shared.js +1 -1
  133. package/src/routes/worktrees.js +213 -149
  134. package/src/server.js +31 -1
  135. package/src/setup/local-setup.js +47 -6
  136. package/src/setup/pr-setup.js +29 -6
  137. package/src/utils/auto-context.js +1 -1
  138. package/src/utils/category-emoji.js +1 -1
  139. package/src/utils/comment-formatter.js +1 -1
  140. package/src/utils/diff-annotator.js +1 -1
  141. package/src/utils/diff-file-list.js +1 -1
  142. package/src/utils/instructions.js +1 -1
  143. package/src/utils/json-extractor.js +1 -1
  144. package/src/utils/line-validation.js +1 -1
  145. package/src/utils/logger.js +1 -1
  146. package/src/utils/paths.js +1 -1
  147. package/src/utils/safe-parse-json.js +1 -1
  148. package/src/utils/stats-calculator.js +1 -1
  149. package/src/ws/index.js +1 -1
  150. package/src/ws/server.js +1 -1
@@ -1,4 +1,4 @@
1
- // SPDX-License-Identifier: GPL-3.0-or-later
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
2
  /**
3
3
  * Local Mode Manager
4
4
  *
@@ -230,10 +230,6 @@ class LocalManager {
230
230
 
231
231
  // Override triggerAIAnalysis for local mode
232
232
  manager.triggerAIAnalysis = async function() {
233
- // Timeout (ms) for stale check — git commands can hang on locked repos.
234
- // Defined locally to avoid relying on cross-script const from pr.js.
235
- const STALE_TIMEOUT = 2000;
236
-
237
233
  if (manager.isAnalyzing) {
238
234
  manager.reopenModal();
239
235
  return;
@@ -257,19 +253,13 @@ class LocalManager {
257
253
  return;
258
254
  }
259
255
 
260
- // Run stale check and settings fetch in parallel to minimize dialog delay
261
- // Use AbortController so the fetch is truly cancelled on timeout,
262
- // freeing the HTTP connection for subsequent requests.
256
+ // Run stale check and settings fetch in parallel to minimize dialog delay.
257
+ // Reuse the on-load staleness promise if still available, otherwise fetch fresh.
263
258
  const _tParallel0 = performance.now();
264
- const staleAbort = new AbortController();
265
- const staleTimer = setTimeout(() => {
266
- console.debug(`[Analyze] stale-check timed out after ${STALE_TIMEOUT}ms, aborting`);
267
- staleAbort.abort();
268
- }, STALE_TIMEOUT);
269
- const staleCheckWithTimeout = fetch(`/api/local/${reviewId}/check-stale`, { signal: staleAbort.signal })
270
- .then(r => r.ok ? r.json() : null)
271
- .then(result => { clearTimeout(staleTimer); return result; })
272
- .catch(() => { clearTimeout(staleTimer); return null; });
259
+ const staleCheckWithTimeout = manager._stalenessPromise
260
+ ? manager._stalenessPromise
261
+ : self._fetchLocalStaleness();
262
+ manager._stalenessPromise = null; // consume it
273
263
  const [staleResult, repoSettings, reviewSettings] = await Promise.all([
274
264
  staleCheckWithTimeout,
275
265
  manager.fetchRepoSettings().catch(() => null),
@@ -644,9 +634,11 @@ class LocalManager {
644
634
  }
645
635
 
646
636
  /**
647
- * Refresh the diff from the working directory
637
+ * Refresh the diff from the working directory.
638
+ * @param {Object} [opts] - Options
639
+ * @param {boolean} [opts.silent] - When true, auto-update on HEAD change without dialog
648
640
  */
649
- async refreshDiff() {
641
+ async refreshDiff(opts = {}) {
650
642
  const manager = window.prManager;
651
643
  const refreshBtn = document.getElementById('local-refresh-btn');
652
644
 
@@ -669,64 +661,25 @@ class LocalManager {
669
661
  const result = await response.json();
670
662
  console.log('Diff refreshed:', result.stats);
671
663
 
672
- // Check if HEAD has changed (user made a commit)
673
- if (result.sessionChanged && result.newSessionId) {
674
- // Show confirmation dialog to user
675
- const originalSha = result.originalHeadSha ? result.originalHeadSha.substring(0, 7) : 'unknown';
676
- const newSha = result.newHeadSha ? result.newHeadSha.substring(0, 7) : 'unknown';
677
-
678
- if (window.confirmDialog) {
679
- const dialogResult = await window.confirmDialog.show({
680
- title: 'HEAD Has Changed',
681
- message: `A new commit was detected (${originalSha} -> ${newSha}). Your comments and AI suggestions are tied to the previous commit.\n\nWould you like to switch to the new session for the current HEAD?`,
682
- confirmText: 'Switch to New Session',
683
- cancelText: 'Stay on Current Session',
684
- confirmClass: 'btn-primary'
685
- });
686
-
687
- if (dialogResult === 'confirm') {
688
- // Redirect to the new session
689
- window.location.href = `/local/${result.newSessionId}`;
664
+ // HEAD change handling branch scope is auto-updated by the backend;
665
+ // non-branch scope requires user decision via resolve-head-change.
666
+ if (result.headShaChanged) {
667
+ const LS = window.LocalScope;
668
+ const hasBranch = LS ? LS.scopeIncludes(this.scopeStart, this.scopeEnd, 'branch') : false;
669
+
670
+ if (!hasBranch) {
671
+ // Non-branch scope: let the user (or silent mode) decide
672
+ const resolved = await this._resolveHeadChange(result, opts);
673
+ if (!resolved) {
674
+ // User cancelled keep old diff, early return
690
675
  return;
691
676
  }
692
- } else {
693
- // Fallback if confirmDialog is not available
694
- const switchSession = confirm(
695
- `HEAD has changed (${originalSha} -> ${newSha}). ` +
696
- `Your comments and AI suggestions are tied to the previous commit. ` +
697
- `Switch to the new session?`
698
- );
699
-
700
- if (switchSession) {
701
- window.location.href = `/local/${result.newSessionId}`;
702
- return;
703
- }
704
- }
705
-
706
- // User chose to stay, show info toast
707
- if (window.toast) {
708
- window.toast.showInfo('Staying on current session. Refresh again to see this option.');
709
677
  }
678
+ // Branch scope: backend already updated SHA and persisted diff — fall through
710
679
  }
711
680
 
712
681
  // Reload the diff display
713
- await this.loadLocalDiff();
714
-
715
- // Re-render comments and AI suggestions on the fresh DOM
716
- // (renderDiff clears the diff container, so we must re-populate)
717
- const includeDismissed = window.aiPanel?.showDismissedComments || false;
718
- await manager.loadUserComments(includeDismissed);
719
- // Note: Unlike loadLocalReview() which skips this when analysisHistoryManager exists
720
- // (because the manager triggers loadAISuggestions via onSelectionChange on init),
721
- // refresh must call unconditionally since the manager won't re-fire its callback.
722
- await manager.loadAISuggestions(null, manager.selectedRunId);
723
-
724
- // Show success toast
725
- if (window.toast) {
726
- window.toast.showSuccess('Diff refreshed successfully');
727
- } else if (window.showToast) {
728
- window.showToast('Diff refreshed successfully', 'success');
729
- }
682
+ await this._applyRefreshedDiff(manager, result);
730
683
 
731
684
  } catch (error) {
732
685
  console.error('Error refreshing diff:', error);
@@ -746,6 +699,198 @@ class LocalManager {
746
699
  }
747
700
  }
748
701
 
702
+ /**
703
+ * Handle a non-branch-scope HEAD SHA change.
704
+ * Shows a 3-option dialog (or auto-updates in silent mode).
705
+ * @returns {boolean} true if the session was updated in-place (caller should apply diff),
706
+ * false if cancelled or redirecting away (caller should skip _applyRefreshedDiff)
707
+ */
708
+ async _resolveHeadChange(result, opts) {
709
+ const abbrevLen = this.localData?.shaAbbrevLength || 7;
710
+ const originalSha = result.previousHeadSha ? result.previousHeadSha.substring(0, abbrevLen) : 'unknown';
711
+ const newSha = result.currentHeadSha ? result.currentHeadSha.substring(0, abbrevLen) : 'unknown';
712
+
713
+ let action = 'update'; // default for silent mode
714
+
715
+ if (!opts.silent && window.confirmDialog) {
716
+ const dialogResult = await window.confirmDialog.show({
717
+ title: 'New Commit Detected',
718
+ message: `HEAD has moved from ${originalSha} to ${newSha}. Your review is based on the old commit.`,
719
+ confirmText: 'Continue This Session',
720
+ confirmDesc: 'Keep comments and suggestions, refresh diff to new HEAD',
721
+ confirmClass: 'btn-primary',
722
+ secondaryText: 'Start New Session',
723
+ secondaryDesc: 'Begin a fresh review from the new commit',
724
+ cancelText: 'Ignore the Change',
725
+ cancelDesc: 'Continue reviewing using the previous diff'
726
+ });
727
+
728
+ if (dialogResult === 'confirm') {
729
+ action = 'update';
730
+ } else if (dialogResult === 'secondary') {
731
+ action = 'new-session';
732
+ } else {
733
+ // Cancel — keep old diff
734
+ if (window.toast) {
735
+ window.toast.showInfo('Staying on current session with previous diff.');
736
+ }
737
+ return false;
738
+ }
739
+ } else if (!opts.silent) {
740
+ // Fallback if confirmDialog is not available
741
+ const switchSession = confirm(
742
+ `HEAD has changed (${originalSha} \u2192 ${newSha}). ` +
743
+ `Update this session with the new diff?`
744
+ );
745
+ action = switchSession ? 'update' : 'cancel';
746
+ if (action === 'cancel') return false;
747
+ }
748
+
749
+ // Call resolve-head-change endpoint
750
+ const resp = await fetch(`/api/local/${this.reviewId}/resolve-head-change`, {
751
+ method: 'POST',
752
+ headers: { 'Content-Type': 'application/json' },
753
+ body: JSON.stringify({ action, newHeadSha: result.currentHeadSha })
754
+ });
755
+
756
+ if (!resp.ok) {
757
+ const err = await resp.json();
758
+ throw new Error(err.error || 'Failed to resolve head change');
759
+ }
760
+
761
+ const data = await resp.json();
762
+
763
+ if (data.action === 'redirect') {
764
+ // UNIQUE conflict — redirect to existing session
765
+ window.location.href = `/local/${data.sessionId}`;
766
+ return false; // navigating away — caller must not fire _applyRefreshedDiff
767
+ }
768
+
769
+ if (data.action === 'new-session') {
770
+ window.location.href = `/local/${data.newSessionId}`;
771
+ return false; // navigating away — caller must not fire _applyRefreshedDiff
772
+ }
773
+
774
+ // action === 'updated' — session SHA + diff updated, continue to reload
775
+ return true;
776
+ }
777
+
778
+ /**
779
+ * Reload the diff display, re-anchor comments, notify chat, clear stale state.
780
+ * Shared by refreshDiff() for both normal refreshes and HEAD-change updates.
781
+ */
782
+ async _applyRefreshedDiff(manager, result) {
783
+ // Notify chat agent about diff refresh
784
+ if (window.chatPanel) {
785
+ if (result.headShaChanged) {
786
+ const prev = result.previousHeadSha;
787
+ const abbrevLen = this.localData?.shaAbbrevLength || 7;
788
+ window.chatPanel.queueDiffStateNotification(
789
+ `HEAD SHA changed: ${prev ? prev.substring(0, abbrevLen) : 'unknown'} \u2192 ${result.currentHeadSha ? result.currentHeadSha.substring(0, abbrevLen) : 'unknown'}.`
790
+ );
791
+ }
792
+ window.chatPanel.queueDiffStateNotification(
793
+ 'Local diff refreshed from working directory.'
794
+ );
795
+ }
796
+
797
+ // Reload the diff display
798
+ await this.loadLocalDiff();
799
+
800
+ // Re-render comments and AI suggestions on the fresh DOM
801
+ // (renderDiff clears the diff container, so we must re-populate)
802
+ const includeDismissed = window.aiPanel?.showDismissedComments || false;
803
+ await manager.loadUserComments(includeDismissed);
804
+ // Note: Unlike loadLocalReview() which skips this when analysisHistoryManager exists
805
+ // (because the manager triggers loadAISuggestions via onSelectionChange on init),
806
+ // refresh must call unconditionally since the manager won't re-fire its callback.
807
+ await manager.loadAISuggestions(null, manager.selectedRunId);
808
+
809
+ // Clear stale state after successful refresh
810
+ manager._hideStaleBadge();
811
+ manager._stalenessPromise = null;
812
+
813
+ // Show success toast
814
+ if (window.toast) {
815
+ window.toast.showSuccess('Diff refreshed successfully');
816
+ } else if (window.showToast) {
817
+ window.showToast('Diff refreshed successfully', 'success');
818
+ }
819
+ }
820
+
821
+ /**
822
+ * Check staleness on page load and show badge or auto-refresh.
823
+ *
824
+ * Logic mirrors PRManager._checkStalenessOnLoad but uses the local
825
+ * GET endpoint and only supports the 'stale' badge type (no merged/closed).
826
+ * @returns {Promise<Object|null>} The staleness result, or null on failure.
827
+ */
828
+ async _checkLocalStalenessOnLoad() {
829
+ try {
830
+ const result = await this._fetchLocalStaleness();
831
+ if (!result) return null;
832
+
833
+ // Notify chat of HEAD SHA change even when diff digest is unchanged
834
+ // (e.g. git commit --amend with identical content, or rebase)
835
+ const abbrevLen = this.localData?.shaAbbrevLength || 7;
836
+ if (result.headShaChanged && window.chatPanel) {
837
+ window.chatPanel.queueDiffStateNotification(
838
+ `HEAD SHA changed (${result.previousHeadSha ? result.previousHeadSha.substring(0, abbrevLen) : 'unknown'} → ${result.currentHeadSha ? result.currentHeadSha.substring(0, abbrevLen) : 'unknown'}). The branch may have been rebased.`
839
+ );
840
+ }
841
+ if (result.isStale !== true) return result;
842
+
843
+ // Stale — decide: silent refresh or show badge
844
+ const manager = window.prManager;
845
+ const hasData = await manager._hasActiveSessionData();
846
+ if (hasData) {
847
+ console.debug('[Local] working directory stale, session has data — showing badge');
848
+ manager._showStaleBadge('stale', 'Working directory has changed');
849
+ if (window.chatPanel) {
850
+ // Notify chat of HEAD SHA change only when we have session data to protect
851
+ // (the !hasData path calls refreshDiff() which queues its own notification)
852
+ if (result.headShaChanged) {
853
+ window.chatPanel.queueDiffStateNotification(
854
+ `HEAD SHA changed (${result.previousHeadSha ? result.previousHeadSha.substring(0, abbrevLen) : 'unknown'} → ${result.currentHeadSha ? result.currentHeadSha.substring(0, abbrevLen) : 'unknown'}). The branch may have been rebased.`
855
+ );
856
+ }
857
+ window.chatPanel.queueDiffStateNotification(
858
+ 'Working directory has changed since the diff was captured.'
859
+ );
860
+ }
861
+ } else {
862
+ // No user work to protect — refresh silently (auto-update on HEAD change)
863
+ console.debug('[Local] working directory stale, no session data — auto-refreshing');
864
+ await this.refreshDiff({ silent: true });
865
+ }
866
+ return result;
867
+ } catch {
868
+ // Fail silently — staleness badge is best-effort
869
+ return null;
870
+ }
871
+ }
872
+
873
+ /**
874
+ * Fetch staleness data from the local review endpoint with a timeout.
875
+ * Uses GET to check the local review staleness endpoint.
876
+ * @returns {Promise<Object|null>} The parsed staleness result, or null on failure/timeout.
877
+ */
878
+ async _fetchLocalStaleness() {
879
+ try {
880
+ const staleAbort = new AbortController();
881
+ const staleTimer = setTimeout(() => staleAbort.abort(), STALE_TIMEOUT);
882
+ const response = await fetch(
883
+ `/api/local/${this.reviewId}/check-stale`,
884
+ { signal: staleAbort.signal }
885
+ );
886
+ clearTimeout(staleTimer);
887
+ if (!response.ok) return null;
888
+ return await response.json();
889
+ } catch {
890
+ return null;
891
+ }
892
+ }
893
+
749
894
  /**
750
895
  * Load local review data
751
896
  */
@@ -770,20 +915,47 @@ class LocalManager {
770
915
  const reviewData = await response.json();
771
916
  this.localData = reviewData;
772
917
 
918
+ // Read scope from metadata (backend now returns these)
919
+ const LS = window.LocalScope;
920
+ const scopeStart = reviewData.scopeStart || (LS ? LS.DEFAULT_SCOPE.start : 'unstaged');
921
+ const scopeEnd = reviewData.scopeEnd || (LS ? LS.DEFAULT_SCOPE.end : 'untracked');
922
+ this.scopeStart = scopeStart;
923
+ this.scopeEnd = scopeEnd;
924
+
773
925
  // Create a currentPR-like object for compatibility
926
+ const hasBranch = LS ? LS.scopeIncludes(scopeStart, scopeEnd, 'branch') : false;
774
927
  manager.currentPR = {
775
928
  id: reviewData.id,
776
929
  owner: 'local',
777
930
  repo: reviewData.repository,
778
931
  number: reviewData.id,
779
- title: `Local Changes - ${reviewData.branch}`,
932
+ title: hasBranch
933
+ ? `Branch Changes - ${reviewData.branch} vs ${reviewData.baseBranch}`
934
+ : `Local Changes - ${reviewData.branch}`,
780
935
  head_branch: reviewData.branch,
781
- base_branch: reviewData.branch,
936
+ base_branch: hasBranch ? reviewData.baseBranch : reviewData.branch,
782
937
  head_sha: reviewData.localHeadSha,
938
+ shaAbbrevLength: reviewData.shaAbbrevLength || 7,
783
939
  reviewType: 'local',
784
940
  localPath: reviewData.localPath
785
941
  };
786
942
 
943
+ // Re-initialize DiffOptionsDropdown with scope options
944
+ const branchAvailable = Boolean(reviewData.branchAvailable);
945
+ if (manager.diffOptionsDropdown) {
946
+ manager.diffOptionsDropdown.destroy();
947
+ }
948
+ const diffOptionsBtn = document.getElementById('diff-options-btn');
949
+ if (diffOptionsBtn && window.DiffOptionsDropdown) {
950
+ manager.diffOptionsDropdown = new window.DiffOptionsDropdown(diffOptionsBtn, {
951
+ onToggleWhitespace: (hide) => manager.handleWhitespaceToggle(hide),
952
+ onToggleMinimize: (minimized) => manager.handleMinimizeToggle(minimized),
953
+ onScopeChange: (start, end) => this._handleScopeChange(start, end),
954
+ initialScope: { start: scopeStart, end: scopeEnd },
955
+ branchAvailable
956
+ });
957
+ }
958
+
787
959
  // Update header with local info
788
960
  this.updateLocalHeader(reviewData);
789
961
 
@@ -813,6 +985,7 @@ class LocalManager {
813
985
  manager.analysisHistoryManager = new window.AnalysisHistoryManager({
814
986
  reviewId: this.reviewId,
815
987
  mode: 'local',
988
+ shaAbbrevLength: reviewData.shaAbbrevLength || 7,
816
989
  onSelectionChange: (runId, _run) => {
817
990
  manager.selectedRunId = runId;
818
991
  manager.loadAISuggestions(null, runId);
@@ -837,6 +1010,9 @@ class LocalManager {
837
1010
  window.prManager._initReviewEventListeners();
838
1011
  }
839
1012
 
1013
+ // Fire-and-forget staleness check — shows badge or auto-refreshes
1014
+ manager._stalenessPromise = this._checkLocalStalenessOnLoad();
1015
+
840
1016
  } catch (error) {
841
1017
  console.error('Error loading local review:', error);
842
1018
  manager.showError(error.message);
@@ -951,6 +1127,36 @@ class LocalManager {
951
1127
  branchText.textContent = reviewData.branch || 'unknown';
952
1128
  }
953
1129
 
1130
+ // Set descriptive tab title
1131
+ if (window.tabTitle && reviewData.branch) {
1132
+ window.tabTitle.setBase(reviewData.branch);
1133
+ }
1134
+
1135
+ // Show base branch badge when branch is in scope
1136
+ const LS = window.LocalScope;
1137
+ const scopeStart = this.scopeStart || (LS ? LS.DEFAULT_SCOPE.start : 'unstaged');
1138
+ const scopeEnd = this.scopeEnd || (LS ? LS.DEFAULT_SCOPE.end : 'untracked');
1139
+ const hasBranch = LS ? LS.scopeIncludes(scopeStart, scopeEnd, 'branch') : false;
1140
+
1141
+ const branchVs = document.getElementById('local-branch-vs');
1142
+ const baseBranchEl = document.getElementById('local-base-branch');
1143
+ const baseBranchText = document.getElementById('local-base-branch-text');
1144
+ if (hasBranch && reviewData.baseBranch) {
1145
+ if (branchVs) branchVs.style.display = '';
1146
+ if (baseBranchEl) baseBranchEl.style.display = '';
1147
+ if (baseBranchText) baseBranchText.textContent = reviewData.baseBranch;
1148
+ } else {
1149
+ if (branchVs) branchVs.style.display = 'none';
1150
+ if (baseBranchEl) baseBranchEl.style.display = 'none';
1151
+ }
1152
+
1153
+ // Update refresh button tooltip based on scope
1154
+ const refreshBtn = document.getElementById('local-refresh-btn');
1155
+ if (refreshBtn) {
1156
+ const scopeLabel = LS ? LS.scopeLabel(scopeStart, scopeEnd) : 'directory';
1157
+ refreshBtn.title = `Refresh diff (${scopeLabel})`;
1158
+ }
1159
+
954
1160
  // Update branch name (toolbar) and wire up copy button
955
1161
  const branchName = document.getElementById('pr-branch-name');
956
1162
  if (branchName) {
@@ -977,7 +1183,8 @@ class LocalManager {
977
1183
  // Update commit SHA and wire up copy button
978
1184
  const commitSha = document.getElementById('pr-commit-sha');
979
1185
  if (commitSha && reviewData.localHeadSha) {
980
- commitSha.textContent = reviewData.localHeadSha.substring(0, 7);
1186
+ const abbrevLen = reviewData.shaAbbrevLength || 7;
1187
+ commitSha.textContent = reviewData.localHeadSha.substring(0, abbrevLen);
981
1188
  commitSha.dataset.fullSha = reviewData.localHeadSha;
982
1189
  }
983
1190
 
@@ -1100,10 +1307,26 @@ class LocalManager {
1100
1307
  const generatedFiles = new Set(data.generated_files || []);
1101
1308
 
1102
1309
  if (!diffContent) {
1103
- // Clear the diff container
1104
1310
  const diffContainer = document.getElementById('diff-container');
1105
1311
  if (diffContainer) {
1106
- diffContainer.innerHTML = '<div class="no-diff">No unstaged changes to review. Make some changes to your files and click the <strong>Refresh</strong> button to reload.</div>';
1312
+ const reviewData = this.localData;
1313
+ const branchInfo = reviewData?.branchInfo;
1314
+ const LS = window.LocalScope;
1315
+ const hasBranch = LS ? LS.scopeIncludes(this.scopeStart, this.scopeEnd, 'branch') : false;
1316
+
1317
+ // Show scope-aware empty message
1318
+ if (!hasBranch && branchInfo) {
1319
+ const scopeLabel = LS ? LS.scopeLabel(this.scopeStart, this.scopeEnd) : 'current scope';
1320
+ diffContainer.innerHTML = `<div class="no-diff">No changes in ${scopeLabel} scope.</div>`;
1321
+ } else {
1322
+ const scopeLabel = LS ? LS.scopeLabel(this.scopeStart, this.scopeEnd) : 'current scope';
1323
+ diffContainer.innerHTML = `<div class="no-diff">No changes in ${scopeLabel} scope. Change <strong>Diff scope</strong> or make some changes and click <strong>Refresh</strong> to reload.</div>`;
1324
+ }
1325
+
1326
+ // If branch has commits ahead and branch is not in scope, offer to expand
1327
+ if (!hasBranch && branchInfo) {
1328
+ this.showBranchReviewDialog(branchInfo);
1329
+ }
1107
1330
  }
1108
1331
 
1109
1332
  // Clear the file navigation sidebar
@@ -1204,6 +1427,271 @@ class LocalManager {
1204
1427
  }
1205
1428
  }
1206
1429
 
1430
+ /**
1431
+ * Apply the result of a scope-change POST to local state, UI, and diff.
1432
+ * Shared by _handleScopeChange and showBranchReviewDialog.handleConfirm.
1433
+ * @param {string} scopeStart - New start stop
1434
+ * @param {string} scopeEnd - New end stop
1435
+ * @param {Object} result - Response body from POST set-scope
1436
+ */
1437
+ async _applyScopeResult(scopeStart, scopeEnd, result) {
1438
+ const manager = window.prManager;
1439
+ const LS = window.LocalScope;
1440
+
1441
+ // Update local state
1442
+ this.scopeStart = scopeStart;
1443
+ this.scopeEnd = scopeEnd;
1444
+
1445
+ // Update localData
1446
+ if (this.localData) {
1447
+ this.localData.scopeStart = scopeStart;
1448
+ this.localData.scopeEnd = scopeEnd;
1449
+ if (result.baseBranch) {
1450
+ this.localData.baseBranch = result.baseBranch;
1451
+ }
1452
+ if (result.localMode) {
1453
+ this.localData.localMode = result.localMode;
1454
+ }
1455
+ }
1456
+
1457
+ // Update currentPR
1458
+ const hasBranch = LS ? LS.includesBranch(scopeStart) : false;
1459
+ if (manager?.currentPR) {
1460
+ manager.currentPR.base_branch = hasBranch
1461
+ ? (result.baseBranch || this.localData?.baseBranch || manager.currentPR.head_branch)
1462
+ : manager.currentPR.head_branch;
1463
+ manager.currentPR.title = hasBranch
1464
+ ? `Branch Changes - ${manager.currentPR.head_branch} vs ${manager.currentPR.base_branch}`
1465
+ : `Local Changes - ${manager.currentPR.head_branch}`;
1466
+ }
1467
+
1468
+ // Update header and reload diff
1469
+ this.updateLocalHeader(this.localData);
1470
+ await this.loadLocalDiff();
1471
+
1472
+ // Re-anchor comments and suggestions
1473
+ const includeDismissed = window.aiPanel?.showDismissedComments || false;
1474
+ await manager.loadUserComments(includeDismissed);
1475
+ await manager.loadAISuggestions(null, manager.selectedRunId);
1476
+
1477
+ // Only update dropdown if user hasn't clicked again since this request started
1478
+ if (manager?.diffOptionsDropdown) {
1479
+ const current = manager.diffOptionsDropdown.scope;
1480
+ if (current.start === scopeStart && current.end === scopeEnd) {
1481
+ manager.diffOptionsDropdown.clearScopeStatus();
1482
+ }
1483
+ }
1484
+ }
1485
+
1486
+ /**
1487
+ * Handle scope change from DiffOptionsDropdown.
1488
+ * POSTs new scope to backend, reloads diff on success.
1489
+ * @param {string} scopeStart - New start stop
1490
+ * @param {string} scopeEnd - New end stop
1491
+ */
1492
+ async _handleScopeChange(scopeStart, scopeEnd) {
1493
+ const manager = window.prManager;
1494
+ const LS = window.LocalScope;
1495
+ const oldStart = this.scopeStart;
1496
+ const oldEnd = this.scopeEnd;
1497
+
1498
+ try {
1499
+ const resp = await fetch(`/api/local/${this.reviewId}/set-scope`, {
1500
+ method: 'POST',
1501
+ headers: { 'Content-Type': 'application/json' },
1502
+ body: JSON.stringify({ scopeStart, scopeEnd })
1503
+ });
1504
+
1505
+ if (!resp.ok) {
1506
+ const err = await resp.json();
1507
+ throw new Error(err.error || 'Failed to set scope');
1508
+ }
1509
+
1510
+ const result = await resp.json();
1511
+ await this._applyScopeResult(scopeStart, scopeEnd, result);
1512
+
1513
+ // Notify chat agent about scope change
1514
+ if (window.chatPanel) {
1515
+ const label = LS ? LS.scopeLabel(scopeStart, scopeEnd) : `${scopeStart}\u2013${scopeEnd}`;
1516
+ window.chatPanel.queueDiffStateNotification(
1517
+ `Diff scope changed to ${label}. The set of reviewed files has changed.`
1518
+ );
1519
+ }
1520
+
1521
+ if (window.toast) {
1522
+ const label = LS ? LS.scopeLabel(scopeStart, scopeEnd) : `${scopeStart}\u2013${scopeEnd}`;
1523
+ window.toast.showSuccess(`Scope: ${label}`);
1524
+ }
1525
+ } catch (error) {
1526
+ console.error('Failed to change scope:', error);
1527
+ if (window.toast) {
1528
+ window.toast.showError('Failed to change scope: ' + error.message);
1529
+ }
1530
+ // Rollback dropdown only if user hasn't clicked again
1531
+ if (manager?.diffOptionsDropdown) {
1532
+ const current = manager.diffOptionsDropdown.scope;
1533
+ if (current.start === scopeStart && current.end === scopeEnd) {
1534
+ manager.diffOptionsDropdown.scope = { start: oldStart, end: oldEnd };
1535
+ manager.diffOptionsDropdown.clearScopeStatus();
1536
+ }
1537
+ }
1538
+ }
1539
+ }
1540
+
1541
+ /**
1542
+ * Show a dialog prompting the user to review branch changes.
1543
+ * Uses the same modal pattern as ConfirmDialog/TextInputDialog.
1544
+ * @param {Object} branchInfo - Branch info with commitCount and baseBranch
1545
+ */
1546
+ showBranchReviewDialog(branchInfo) {
1547
+ // Remove any existing branch review dialog
1548
+ const existing = document.getElementById('branch-review-dialog');
1549
+ if (existing) existing.remove();
1550
+
1551
+ const commitLabel = branchInfo.commitCount === 1 ? 'commit' : 'commits';
1552
+
1553
+ const overlay = document.createElement('div');
1554
+ overlay.id = 'branch-review-dialog';
1555
+ overlay.className = 'modal-overlay';
1556
+ overlay.style.display = 'flex';
1557
+
1558
+ overlay.innerHTML = `
1559
+ <div class="modal-backdrop" data-action="cancel"></div>
1560
+ <div class="modal-container confirm-dialog-container" style="width: 440px; height: auto;">
1561
+ <div class="modal-header">
1562
+ <h3>Branch Has Changes</h3>
1563
+ <button class="modal-close-btn" data-action="cancel" title="Close">
1564
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
1565
+ <path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"/>
1566
+ </svg>
1567
+ </button>
1568
+ </div>
1569
+
1570
+ <div class="modal-body" style="padding: 16px 20px;">
1571
+ <p style="margin: 0 0 12px 0; font-size: 14px;">
1572
+ No uncommitted changes. This branch has <strong>${branchInfo.commitCount}</strong> ${commitLabel} ahead of <code style="padding: 2px 6px; background: var(--color-bg-tertiary); border-radius: 4px; font-size: 12px;">${branchInfo.baseBranch}</code>.
1573
+ </p>
1574
+ <label style="font-size: 12px; color: var(--color-text-tertiary); cursor: pointer; display: inline-flex; align-items: center; gap: 6px;">
1575
+ <input type="checkbox" id="branch-review-dont-ask" style="cursor: pointer;">
1576
+ Don't ask again for this repository
1577
+ </label>
1578
+ </div>
1579
+
1580
+ <div class="modal-footer">
1581
+ <button class="btn btn-secondary" data-action="cancel">Cancel</button>
1582
+ <button class="btn btn-primary" id="branch-review-confirm-btn" data-action="confirm">
1583
+ Expand Scope to Branch
1584
+ </button>
1585
+ </div>
1586
+ </div>
1587
+ `;
1588
+
1589
+ document.body.appendChild(overlay);
1590
+
1591
+ const reviewId = this.reviewId;
1592
+ const self = this;
1593
+
1594
+ const closeDialog = () => {
1595
+ overlay.style.display = 'none';
1596
+ overlay.remove();
1597
+ document.removeEventListener('keydown', keyHandler);
1598
+ };
1599
+
1600
+ const handleConfirm = async () => {
1601
+ const confirmBtn = overlay.querySelector('#branch-review-confirm-btn');
1602
+ if (confirmBtn) {
1603
+ confirmBtn.disabled = true;
1604
+ confirmBtn.textContent = 'Expanding...';
1605
+ }
1606
+
1607
+ // Save "don't ask" preference if checked
1608
+ const dontAsk = overlay.querySelector('#branch-review-dont-ask');
1609
+ if (dontAsk?.checked) {
1610
+ try {
1611
+ await fetch(`/api/local/${reviewId}/branch-review-preference`, {
1612
+ method: 'POST',
1613
+ headers: { 'Content-Type': 'application/json' },
1614
+ body: JSON.stringify({ preference: -1 })
1615
+ });
1616
+ } catch { /* non-fatal */ }
1617
+ }
1618
+
1619
+ try {
1620
+ const LS = window.LocalScope;
1621
+ const newEnd = self.scopeEnd || (LS ? LS.DEFAULT_SCOPE.end : 'untracked');
1622
+ const resp = await fetch(`/api/local/${reviewId}/set-scope`, {
1623
+ method: 'POST',
1624
+ headers: { 'Content-Type': 'application/json' },
1625
+ body: JSON.stringify({
1626
+ scopeStart: 'branch',
1627
+ scopeEnd: newEnd,
1628
+ baseBranch: branchInfo.baseBranch
1629
+ })
1630
+ });
1631
+ if (!resp.ok) {
1632
+ const err = await resp.json();
1633
+ throw new Error(err.error || 'Failed to expand scope');
1634
+ }
1635
+
1636
+ const result = await resp.json();
1637
+
1638
+ // Update the dropdown branchAvailable flag
1639
+ const manager = window.prManager;
1640
+ if (manager?.diffOptionsDropdown) {
1641
+ manager.diffOptionsDropdown.branchAvailable = true;
1642
+ manager.diffOptionsDropdown.scope = { start: 'branch', end: newEnd };
1643
+ }
1644
+
1645
+ closeDialog();
1646
+
1647
+ await self._applyScopeResult('branch', newEnd, result);
1648
+
1649
+ if (window.chatPanel) {
1650
+ const label = LS ? LS.scopeLabel('branch', newEnd) : 'branch';
1651
+ window.chatPanel.queueDiffStateNotification(
1652
+ `Diff scope changed to ${label} via branch review. The set of reviewed files has changed.`
1653
+ );
1654
+ }
1655
+
1656
+ if (window.toast) {
1657
+ const label = LS ? LS.scopeLabel('branch', newEnd) : 'Branch';
1658
+ window.toast.showSuccess(`Scope expanded to ${label}`);
1659
+ }
1660
+ } catch (error) {
1661
+ if (confirmBtn) {
1662
+ confirmBtn.disabled = false;
1663
+ confirmBtn.textContent = 'Expand Scope to Branch';
1664
+ }
1665
+ console.error('Failed to expand scope to branch:', error);
1666
+ if (window.toast) {
1667
+ window.toast.showError('Failed to expand scope: ' + error.message);
1668
+ }
1669
+ }
1670
+ };
1671
+
1672
+ // Event delegation for clicks
1673
+ overlay.addEventListener('click', (e) => {
1674
+ const action = e.target.closest('[data-action]')?.dataset.action;
1675
+ if (action === 'confirm') {
1676
+ handleConfirm();
1677
+ } else if (action === 'cancel') {
1678
+ closeDialog();
1679
+ }
1680
+ });
1681
+
1682
+ // Keyboard handler
1683
+ const keyHandler = (e) => {
1684
+ if (e.key === 'Escape') {
1685
+ closeDialog();
1686
+ } else if (e.key === 'Enter') {
1687
+ e.preventDefault();
1688
+ const btn = overlay.querySelector('#branch-review-confirm-btn');
1689
+ if (!btn?.disabled) handleConfirm();
1690
+ }
1691
+ };
1692
+ document.addEventListener('keydown', keyHandler);
1693
+ }
1694
+
1207
1695
  /**
1208
1696
  * Get the progress dots container element
1209
1697
  * @returns {HTMLElement|null}
@@ -1255,6 +1743,11 @@ class LocalManager {
1255
1743
  }
1256
1744
 
1257
1745
  // Initialize LocalManager when in local mode
1258
- if (window.PAIR_REVIEW_LOCAL_MODE) {
1746
+ if (typeof window !== 'undefined' && window.PAIR_REVIEW_LOCAL_MODE) {
1259
1747
  window.localManager = new LocalManager();
1260
1748
  }
1749
+
1750
+ // Export for testing (Node.js environment)
1751
+ if (typeof module !== 'undefined' && module.exports) {
1752
+ module.exports = { LocalManager };
1753
+ }