@in-the-loop-labs/pair-review 3.4.1 → 3.5.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.
package/public/js/pr.js CHANGED
@@ -305,6 +305,21 @@ class PRManager {
305
305
  refreshBtn.addEventListener('click', () => this.refreshPR());
306
306
  }
307
307
 
308
+ // Refresh external (GitHub) review comments. Handler lives on the
309
+ // instance so unit tests can call it directly (avoids duplicating the
310
+ // production click logic in tests, per CLAUDE.md).
311
+ // Skip wiring entirely when the external_comments feature toggle is
312
+ // off — the button is also CSS-hidden via .external-comments-only, so
313
+ // this is belt-and-suspenders.
314
+ if (this._externalCommentsEnabled()) {
315
+ const refreshExternalBtnPanel = document.getElementById('refresh-external-comments-btn-panel');
316
+ if (refreshExternalBtnPanel) {
317
+ refreshExternalBtnPanel.addEventListener('click', () => {
318
+ void this._handleExternalCommentsRefreshClick({ button: refreshExternalBtnPanel });
319
+ });
320
+ }
321
+ }
322
+
308
323
  // PR description popover
309
324
  this.setupPRDescriptionPopover();
310
325
 
@@ -612,6 +627,15 @@ class PRManager {
612
627
  // Fire-and-forget staleness check — shows badge or auto-refreshes
613
628
  this._stalenessPromise = this._checkStalenessOnLoad(owner, repo, number);
614
629
 
630
+ // Fire-and-forget external review-comment sync + render.
631
+ // Runs after the diff and user comments have been rendered so the
632
+ // external-comment manager has DOM anchors to attach blue rows to.
633
+ // Failures are swallowed inside _loadExternalComments and surface via
634
+ // console.warn; they must never block the main PR-load path.
635
+ void this._loadExternalComments().catch((err) => {
636
+ console.warn('[external-comments] _loadExternalComments threw', err);
637
+ });
638
+
615
639
  } catch (error) {
616
640
  console.error('Error loading PR:', error);
617
641
  this.showError(error.message);
@@ -623,16 +647,260 @@ class PRManager {
623
647
  }
624
648
 
625
649
  /**
626
- * Reload AI suggestions and user comments after an analysis completes.
627
- * Shared by the foreground `analysis_completed` handler and the deferred
628
- * `_dirtyAnalysis` branch in the visibilitychange listener.
650
+ * Sync external review comments against the source system and render the
651
+ * results. Non-blocking from the user's perspective: a sync failure does
652
+ * not prevent rendering of any cached rows from a previous run.
653
+ *
654
+ * No-op in local mode (external sources require a real GitHub PR).
655
+ */
656
+ /**
657
+ * Re-render every overlay (AI suggestions, user comments, external comments)
658
+ * after the underlying diff DOM has been rebuilt.
659
+ *
660
+ * Used by anything that destroys and re-mounts the diff (refreshPR, the
661
+ * whitespace toggle, pre-analysis refresh). Centralizing keeps the three
662
+ * renderers from drifting: each one has its own clear()/append cycle and
663
+ * forgetting any of them produces hard-to-spot bugs like "comments
664
+ * disappeared after refresh".
665
+ *
666
+ * @param {Object} [options]
667
+ * @param {string} [options.analysisRunId] - Optional run id to pin AI suggestions to.
668
+ * @param {boolean} [options.syncExternal=false] - When true, fire the
669
+ * external-comments sync POST before re-rendering. Used by `refreshPR`
670
+ * where the diff was just fetched fresh from GitHub and cached anchors
671
+ * may not match the new commit. GET-only callers (whitespace toggle,
672
+ * analysis rebuilds, post-analysis reload) pass the default and reuse
673
+ * the existing mirror.
674
+ */
675
+ async _rerenderAllOverlays({ analysisRunId, syncExternal = false } = {}) {
676
+ const includeDismissed = window.aiPanel?.showDismissedComments || false;
677
+ await this.loadUserComments(includeDismissed);
678
+ await this.loadAISuggestions(null, analysisRunId);
679
+ // Skip external-comment re-rendering entirely when the feature is off.
680
+ // The manager is never populated with rows in that case, so there's
681
+ // nothing to re-anchor.
682
+ if (!this._externalCommentsEnabled()) return;
683
+ try {
684
+ if (typeof window !== 'undefined' && window.externalCommentManager) {
685
+ if (this.currentPR && this.currentPR.id) {
686
+ window.externalCommentManager.reviewId = this.currentPR.id;
687
+ }
688
+ if (syncExternal) {
689
+ // refreshPR path: full sync+load through the canonical entry
690
+ // point. `_loadExternalComments` already owns the toast + error
691
+ // wiring for the sync result.
692
+ await this._loadExternalComments();
693
+ } else {
694
+ // GET-only path: the local mirror is current; just re-anchor
695
+ // rows on the freshly-rebuilt diff DOM.
696
+ await window.externalCommentManager.loadAndRender();
697
+ }
698
+ }
699
+ } catch (err) {
700
+ console.warn('[external-comments] re-render after diff rebuild failed', err);
701
+ }
702
+ }
703
+
704
+ /**
705
+ * Click handler for the "refresh external comments" header button.
706
+ *
707
+ * Extracted from `setupEventListeners` so unit tests can exercise the
708
+ * production handler directly without re-implementing it (CLAUDE.md
709
+ * forbids duplicating production code in tests). The DOM button id is
710
+ * the canonical attach point — pass it in for tests that build their
711
+ * own button.
712
+ *
713
+ * @param {Object} [options]
714
+ * @param {HTMLElement} [options.button] - The button element to toggle. Defaults to `#refresh-external-comments-btn-panel`.
715
+ * @returns {Promise<void>}
716
+ */
717
+ async _handleExternalCommentsRefreshClick({ button } = {}) {
718
+ // Defensive guard: even if a stale caller invokes this with the
719
+ // feature disabled, swallow it. UI wiring already skips this path.
720
+ if (!this._externalCommentsEnabled()) return;
721
+ const btn = button
722
+ || (typeof document !== 'undefined' ? document.getElementById('refresh-external-comments-btn-panel') : null);
723
+ if (!btn || btn.disabled) return;
724
+ btn.disabled = true;
725
+ btn.classList.add('is-refreshing');
726
+ btn.setAttribute('aria-busy', 'true');
727
+ btn.removeAttribute('data-state');
728
+ try {
729
+ await this._loadExternalComments();
730
+ } finally {
731
+ btn.disabled = false;
732
+ btn.classList.remove('is-refreshing');
733
+ btn.removeAttribute('aria-busy');
734
+ }
735
+ }
736
+
737
+ /**
738
+ * Feature toggle for the external-comments (GitHub PR review-comment)
739
+ * subsystem. Reads `window.PAIR_REVIEW_RUNTIME_CONFIG` which is set
740
+ * synchronously by `/runtime-config.js` BEFORE this file loads, so the
741
+ * check is safe at any call site. Defaults to enabled when the flag is
742
+ * missing — preserves behavior for any environment that omits the
743
+ * runtime-config script (e.g., older test fixtures).
744
+ * @returns {boolean}
745
+ */
746
+ _externalCommentsEnabled() {
747
+ if (typeof window === 'undefined') return true;
748
+ return window.PAIR_REVIEW_RUNTIME_CONFIG?.external_comments_enabled !== false;
749
+ }
750
+
751
+ async _loadExternalComments() {
752
+ if (typeof window !== 'undefined' && window.PAIR_REVIEW_LOCAL_MODE) return;
753
+ if (!this._externalCommentsEnabled()) return;
754
+ if (!this.currentPR || !this.currentPR.id) return;
755
+ if (typeof window === 'undefined' || !window.externalCommentManager) return;
756
+
757
+ window.externalCommentManager.reviewId = this.currentPR.id;
758
+
759
+ // Route through the manager's `syncAndRender`. That method owns the
760
+ // in-flight guard for the FULL sync+load sequence, so a GET-only
761
+ // caller (analysis rebuild, whitespace toggle) that hits
762
+ // `loadAndRender` during this window joins the same promise instead
763
+ // of racing the POST with a stale GET. The manager surfaces sync
764
+ // result and sync error separately so this method can fire the
765
+ // status-aware toasts without intercepting render.
766
+ let result;
767
+ try {
768
+ result = await window.externalCommentManager.syncAndRender({
769
+ syncFn: () => this._syncExternalComments(),
770
+ });
771
+ } catch (err) {
772
+ console.warn('[external-comments] syncAndRender failed', err);
773
+ return;
774
+ }
775
+
776
+ if (result && result.syncError) {
777
+ // Sync failed but render proceeded against the previously-cached
778
+ // mirror. Toast + button-error cue so the reviewer knows the
779
+ // counts may lag upstream.
780
+ this._showExternalSyncErrorToast(result.syncError);
781
+ this._markExternalRefreshErrorState();
782
+ console.warn('[external-comments] sync failed; rendering cached data only', result.syncError);
783
+ } else if (result && result.syncResult && typeof result.syncResult.lostAnchors === 'number' && result.syncResult.lostAnchors > 0) {
784
+ // Surface lost-anchor counts so the reviewer knows why visible
785
+ // external-comment counts may lag what GitHub shows — these are
786
+ // comments whose anchors were destroyed upstream (force-push, file
787
+ // delete, etc.) and have no place to render in the current diff.
788
+ this._showExternalLostAnchorsToast(result.syncResult.lostAnchors);
789
+ }
790
+ }
791
+
792
+ /**
793
+ * Pick a toast message by HTTP status for a failed external-comment sync.
794
+ * Falls back to a generic message when status is missing or unknown.
795
+ * @private
796
+ */
797
+ _showExternalSyncErrorToast(err) {
798
+ const status = err && typeof err.status === 'number' ? err.status : 0;
799
+ let message;
800
+ if (status === 401) {
801
+ message = 'GitHub token missing or invalid — external comments not refreshed.';
802
+ } else if (status === 403) {
803
+ message = 'GitHub denied the request (403) — external comments not refreshed.';
804
+ } else if (status === 429) {
805
+ message = 'GitHub rate limited — external comments not refreshed.';
806
+ } else {
807
+ message = 'Could not refresh GitHub review comments.';
808
+ }
809
+ try {
810
+ if (typeof window !== 'undefined') {
811
+ if (window.toast && typeof window.toast.showError === 'function') {
812
+ window.toast.showError(message);
813
+ return;
814
+ }
815
+ if (typeof window.showToast === 'function') {
816
+ window.showToast(message, 'error');
817
+ return;
818
+ }
819
+ }
820
+ } catch {
821
+ // toast helpers must never break the page; swallow.
822
+ }
823
+ }
824
+
825
+ /**
826
+ * Show a warning toast describing how many external comments couldn't be
827
+ * anchored to the current diff (lost-anchor count from the sync result).
828
+ * Mirrors `_showExternalSyncErrorToast` so failures and partial successes
829
+ * have symmetrical UI treatment.
830
+ * @param {number} count - Number of lost-anchor comments
831
+ * @private
832
+ */
833
+ _showExternalLostAnchorsToast(count) {
834
+ if (typeof window === 'undefined') return;
835
+ const noun = count === 1 ? 'comment' : 'comments';
836
+ const message = `${count} ${noun} lost their anchor due to upstream changes`;
837
+ try {
838
+ if (window.toast && typeof window.toast.showWarning === 'function') {
839
+ window.toast.showWarning(message);
840
+ return;
841
+ }
842
+ if (window.toast && typeof window.toast.showInfo === 'function') {
843
+ window.toast.showInfo(message);
844
+ return;
845
+ }
846
+ if (typeof window.showToast === 'function') {
847
+ window.showToast(message, 'warn');
848
+ return;
849
+ }
850
+ } catch {
851
+ // toast helpers must never break the page; swallow.
852
+ }
853
+ }
854
+
855
+ /**
856
+ * Briefly mark the refresh button so the user notices the failure even if
857
+ * they dismissed the toast. Cleared after 4s; no state machine — best
858
+ * effort cue. No-op when the button isn't present.
859
+ * @private
860
+ */
861
+ _markExternalRefreshErrorState() {
862
+ if (typeof document === 'undefined') return;
863
+ const btn = document.getElementById('refresh-external-comments-btn-panel');
864
+ if (!btn) return;
865
+ btn.setAttribute('data-state', 'error');
866
+ setTimeout(() => {
867
+ if (btn.getAttribute('data-state') === 'error') {
868
+ btn.removeAttribute('data-state');
869
+ }
870
+ }, 4000);
871
+ }
872
+
873
+ /**
874
+ * POST to the sync endpoint for the GitHub source. Coalesced server-side
875
+ * for concurrent (review, source) calls; safe to invoke from page-load and
876
+ * the manual refresh button in parallel.
877
+ * @returns {Promise<{ count: number, lostAnchors: number, syncedAt: string }>}
878
+ */
879
+ async _syncExternalComments() {
880
+ const source = 'github';
881
+ const res = await fetch(
882
+ `/api/reviews/${this.currentPR.id}/external-comments/sync?source=${source}`,
883
+ { method: 'POST' }
884
+ );
885
+ if (!res.ok) {
886
+ const body = await res.json().catch(() => ({}));
887
+ const err = new Error(body.error || `Sync failed with status ${res.status}`);
888
+ err.status = res.status;
889
+ throw err;
890
+ }
891
+ return res.json();
892
+ }
893
+
894
+ /**
895
+ * Reload AI suggestions, user comments, and external comments after an
896
+ * analysis completes. Shared by the foreground `analysis_completed`
897
+ * handler and the deferred `_dirtyAnalysis` branch in the
898
+ * visibilitychange listener. Routing through `_rerenderAllOverlays`
899
+ * keeps external-comment rows in sync with the other two overlays
900
+ * whenever post-analysis refresh fires.
629
901
  */
630
902
  _reloadAfterAnalysis() {
631
- const includeDismissed = window.aiPanel?.showDismissedComments ?? false;
632
- return Promise.all([
633
- this.loadAISuggestions(),
634
- this.loadUserComments(includeDismissed)
635
- ]);
903
+ return this._rerenderAllOverlays();
636
904
  }
637
905
 
638
906
  /**
@@ -842,10 +1110,11 @@ class PRManager {
842
1110
  // Re-fetch and re-render the diff
843
1111
  await this.loadAndDisplayFiles(owner, repo, number);
844
1112
 
845
- // Re-anchor comments and suggestions on the fresh DOM
846
- const includeDismissed = window.aiPanel?.showDismissedComments || false;
847
- await this.loadUserComments(includeDismissed);
848
- await this.loadAISuggestions(null, this.selectedRunId);
1113
+ // Re-anchor every overlay (user comments, AI suggestions, external
1114
+ // comments) via the shared helper so the three renderers can't drift.
1115
+ // The diff DOM was just rebuilt, so external-comment rows are gone
1116
+ // until loadAndRender re-inserts them.
1117
+ await this._rerenderAllOverlays({ analysisRunId: this.selectedRunId });
849
1118
 
850
1119
  // Restore scroll position after the DOM settles
851
1120
  requestAnimationFrame(() => {
@@ -5221,14 +5490,22 @@ class PRManager {
5221
5490
  // Reload the files/diff with fresh data
5222
5491
  await this.loadAndDisplayFiles(owner, repo, number);
5223
5492
 
5224
- // Re-render comments and AI suggestions on the fresh DOM
5225
- // (renderDiff clears the diff container, so we must re-populate)
5226
- const includeDismissed = window.aiPanel?.showDismissedComments || false;
5227
- await this.loadUserComments(includeDismissed);
5228
- // Note: Unlike loadPR() which skips this when analysisHistoryManager exists
5229
- // (because the manager triggers loadAISuggestions via onSelectionChange on init),
5230
- // refresh must call unconditionally since the manager won't re-fire its callback.
5231
- await this.loadAISuggestions(null, this.selectedRunId);
5493
+ // Re-render the three independent overlay layers on the fresh DOM
5494
+ // (renderDiff clears the diff container). Going through the shared
5495
+ // helper guarantees the three renderers can't drift again — adding
5496
+ // a fourth overlay only requires updating this one place.
5497
+ // `syncExternal: true` because refreshPR fetched a brand-new diff
5498
+ // (commit SHA may have changed). Cached external-comment anchors
5499
+ // need a sync POST to re-evaluate which ones are outdated against
5500
+ // the new HEAD — otherwise we'd render stale `is_outdated` flags.
5501
+ // Note: Unlike loadPR() which skips loadAISuggestions when
5502
+ // analysisHistoryManager exists (because the manager triggers it via
5503
+ // onSelectionChange on init), refresh must call unconditionally
5504
+ // since the manager won't re-fire its callback.
5505
+ await this._rerenderAllOverlays({
5506
+ analysisRunId: this.selectedRunId,
5507
+ syncExternal: true,
5508
+ });
5232
5509
 
5233
5510
  // Restore expanded folders
5234
5511
  this.expandedFolders = expandedFolders;
package/public/local.html CHANGED
@@ -525,11 +525,20 @@
525
525
 
526
526
  <!-- Segment Control -->
527
527
  <div class="segment-control" id="segment-control">
528
- <div class="segment-control-inner">
529
- <button class="segment-btn active" data-segment="ai">AI <span class="segment-count">(0)</span></button>
530
- <button class="segment-btn" data-segment="comments">User <span class="segment-count">(0)</span></button>
531
- <button class="segment-btn" data-segment="all">All <span class="segment-count">(0)</span></button>
528
+ <button type="button" class="segment-scroll segment-scroll--left" id="segment-scroll-left" hidden aria-label="Scroll segments left" tabindex="-1">
529
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M9.78 12.78a.75.75 0 0 1-1.06 0L4.47 8.53a.75.75 0 0 1 0-1.06l4.25-4.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L6.06 8l3.72 3.72a.75.75 0 0 1 0 1.06Z"/></svg>
530
+ </button>
531
+ <div class="segment-control-scroll" id="segment-control-scroll">
532
+ <div class="segment-control-inner">
533
+ <button class="segment-btn active" data-segment="ai">AI <span class="segment-count">(0)</span></button>
534
+ <button class="segment-btn" data-segment="comments">User <span class="segment-count">(0)</span></button>
535
+ <button class="segment-btn" data-segment="external" hidden>External <span class="segment-count">(0)</span></button>
536
+ <button class="segment-btn" data-segment="all">All <span class="segment-count">(0)</span></button>
537
+ </div>
532
538
  </div>
539
+ <button type="button" class="segment-scroll segment-scroll--right" id="segment-scroll-right" hidden aria-label="Scroll segments right" tabindex="-1">
540
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M6.22 3.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L9.94 8 6.22 4.28a.75.75 0 0 1 0-1.06Z"/></svg>
541
+ </button>
533
542
  </div>
534
543
 
535
544
  <!-- Level Filter -->
@@ -543,7 +552,9 @@
543
552
  <!-- Findings Summary -->
544
553
  <div class="findings-summary" id="findings-summary">
545
554
  <div class="findings-header">
546
- <span class="findings-count" id="findings-count">0 items</span>
555
+ <!-- Nav controls (prev | n of N | next) are injected by AIPanel.updateFindingsHeader.
556
+ Local mode has no external comments, so the actions slot stays empty. -->
557
+ <div class="findings-nav" id="findings-nav"></div>
547
558
  </div>
548
559
  <div class="findings-list" id="findings-list">
549
560
  <div class="findings-empty">
@@ -641,6 +652,11 @@
641
652
  window.PAIR_REVIEW_LOCAL_MODE = true;
642
653
  </script>
643
654
 
655
+ <!-- Runtime configuration: sets window.PAIR_REVIEW_RUNTIME_CONFIG and
656
+ the `external-comments-disabled` class on documentElement before
657
+ pr.js / AIPanel initialise. Must load BEFORE pr.js. -->
658
+ <script src="/runtime-config.js"></script>
659
+
644
660
  <!-- PR JavaScript (shared with local mode) -->
645
661
  <script src="/js/pr.js"></script>
646
662
 
package/public/pr.html CHANGED
@@ -321,11 +321,20 @@
321
321
 
322
322
  <!-- Segment Control -->
323
323
  <div class="segment-control" id="segment-control">
324
- <div class="segment-control-inner">
325
- <button class="segment-btn active" data-segment="ai">AI <span class="segment-count">(0)</span></button>
326
- <button class="segment-btn" data-segment="comments">User <span class="segment-count">(0)</span></button>
327
- <button class="segment-btn" data-segment="all">All <span class="segment-count">(0)</span></button>
324
+ <button type="button" class="segment-scroll segment-scroll--left" id="segment-scroll-left" hidden aria-label="Scroll segments left" tabindex="-1">
325
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M9.78 12.78a.75.75 0 0 1-1.06 0L4.47 8.53a.75.75 0 0 1 0-1.06l4.25-4.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L6.06 8l3.72 3.72a.75.75 0 0 1 0 1.06Z"/></svg>
326
+ </button>
327
+ <div class="segment-control-scroll" id="segment-control-scroll">
328
+ <div class="segment-control-inner">
329
+ <button class="segment-btn active" data-segment="ai">AI <span class="segment-count">(0)</span></button>
330
+ <button class="segment-btn" data-segment="comments">User <span class="segment-count">(0)</span></button>
331
+ <button class="segment-btn" data-segment="external">External <span class="segment-count">(0)</span></button>
332
+ <button class="segment-btn" data-segment="all">All <span class="segment-count">(0)</span></button>
333
+ </div>
328
334
  </div>
335
+ <button type="button" class="segment-scroll segment-scroll--right" id="segment-scroll-right" hidden aria-label="Scroll segments right" tabindex="-1">
336
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M6.22 3.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L9.94 8 6.22 4.28a.75.75 0 0 1 0-1.06Z"/></svg>
337
+ </button>
329
338
  </div>
330
339
 
331
340
  <!-- Level Filter -->
@@ -339,7 +348,16 @@
339
348
  <!-- Findings Summary -->
340
349
  <div class="findings-summary" id="findings-summary">
341
350
  <div class="findings-header">
342
- <span class="findings-count" id="findings-count">0 items</span>
351
+ <!-- Nav controls (prev | n of N | next) are injected by AIPanel.updateFindingsHeader.
352
+ Kept as a static slot so the sibling actions container survives re-render. -->
353
+ <div class="findings-nav" id="findings-nav"></div>
354
+ <div class="findings-header-actions">
355
+ <button class="findings-nav-btn btn-refresh-external-comments external-comments-only" id="refresh-external-comments-btn-panel" title="Refresh GitHub comments" aria-label="Refresh GitHub comments">
356
+ <svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12" aria-hidden="true">
357
+ <path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z"/>
358
+ </svg>
359
+ </button>
360
+ </div>
343
361
  </div>
344
362
  <div class="findings-list" id="findings-list">
345
363
  <div class="findings-empty">
@@ -429,9 +447,17 @@
429
447
  <!-- Chat Panel Component -->
430
448
  <script src="/js/components/ChatPanel.js"></script>
431
449
 
450
+ <!-- External review-comment renderer (must load after chat-panel + comment-manager) -->
451
+ <script src="/js/modules/external-comment-manager.js"></script>
452
+
432
453
  <!-- Panel Group Component -->
433
454
  <script src="/js/components/PanelGroup.js"></script>
434
455
 
456
+ <!-- Runtime configuration: sets window.PAIR_REVIEW_RUNTIME_CONFIG and
457
+ the `external-comments-disabled` class on documentElement before
458
+ pr.js / AIPanel initialise. Must load BEFORE pr.js. -->
459
+ <script src="/runtime-config.js"></script>
460
+
435
461
  <!-- PR JavaScript -->
436
462
  <script src="/js/pr.js"></script>
437
463
  </body>
@@ -17,19 +17,32 @@ const { StreamParser, parseClaudeLine } = require('./stream-parser');
17
17
  const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
18
18
 
19
19
  /**
20
- * Claude model definitions with tier mappings
20
+ * Claude model definitions with tier mappings.
21
+ *
22
+ * Effort is set via the CLAUDE_CODE_EFFORT_LEVEL env var (highest-precedence way
23
+ * to control reasoning effort; takes precedence over the --effort CLI flag and is
24
+ * not deprecated). Extended thinking is forced on globally via `--thinking enabled`
25
+ * in the constructor's base args; individual models can override this via extra_args
26
+ * (e.g., Haiku uses adaptive thinking for efficiency).
27
+ *
28
+ * Effort support by model (newest CLIs): Opus 4.8 / 4.7 support low|medium|high|
29
+ * xhigh|max; Opus 4.6 & Sonnet 4.6 support low|medium|high|max (no xhigh); Haiku
30
+ * has no effort levels.
21
31
  */
22
32
  const CLAUDE_MODELS = [
33
+ // ── Thorough tier ───────────────────────────────────────────────────────
23
34
  {
24
- id: 'opus-4.7-xhigh',
35
+ id: 'opus',
36
+ aliases: ['opus-4.7-xhigh'],
25
37
  cli_model: 'claude-opus-4-7',
26
38
  env: { CLAUDE_CODE_EFFORT_LEVEL: 'xhigh' },
27
39
  name: 'Opus 4.7 XHigh',
28
40
  tier: 'thorough',
29
- tagline: 'Latest Gen',
30
- description: 'Opus 4.7 (latest) with extra-high effort',
31
- badge: 'Latest',
32
- badgeClass: 'badge-power'
41
+ tagline: 'Maximum Depth',
42
+ description: 'Opus 4.7 with extra-high effort — deepest analysis',
43
+ badge: 'Most Thorough',
44
+ badgeClass: 'badge-power',
45
+ default: true
33
46
  },
34
47
  {
35
48
  id: 'opus-4.7-high',
@@ -37,33 +50,46 @@ const CLAUDE_MODELS = [
37
50
  env: { CLAUDE_CODE_EFFORT_LEVEL: 'high' },
38
51
  name: 'Opus 4.7 High',
39
52
  tier: 'thorough',
40
- tagline: 'Latest Gen',
41
- description: 'Opus 4.7 (latest) with high effort',
53
+ tagline: 'High Effort',
54
+ description: 'Opus 4.7 with high effort — thorough, quicker than XHigh',
55
+ badge: 'Thorough',
56
+ badgeClass: 'badge-power'
57
+ },
58
+ {
59
+ id: 'opus-4.8-xhigh',
60
+ cli_model: 'claude-opus-4-8',
61
+ env: { CLAUDE_CODE_EFFORT_LEVEL: 'xhigh' },
62
+ name: 'Opus 4.8 XHigh',
63
+ tier: 'thorough',
64
+ tagline: 'Newest',
65
+ description: 'Opus 4.8 (newest) with extra-high effort',
42
66
  badge: 'Latest',
43
67
  badgeClass: 'badge-power'
44
68
  },
45
69
  {
46
- id: 'opus',
47
- aliases: ['opus-4.6-high'],
48
- cli_model: 'claude-opus-4-6',
70
+ id: 'opus-4.8-high',
71
+ cli_model: 'claude-opus-4-8',
49
72
  env: { CLAUDE_CODE_EFFORT_LEVEL: 'high' },
50
- name: 'Opus 4.6 High',
73
+ name: 'Opus 4.8 High',
51
74
  tier: 'thorough',
52
- tagline: 'Maximum Depth',
53
- description: 'Opus 4.6 with high effort — deepest analysis',
54
- badge: 'Most Thorough',
55
- badgeClass: 'badge-power',
56
- default: true
75
+ tagline: 'Newest',
76
+ description: 'Opus 4.8 (newest) with high effort',
77
+ badge: 'Latest',
78
+ badgeClass: 'badge-power'
57
79
  },
58
80
  {
59
- id: 'haiku',
60
- name: 'Haiku 4.6',
61
- tier: 'fast',
62
- tagline: 'Lightning Fast',
63
- description: 'Quick analysis for simple changes',
64
- badge: 'Fastest',
65
- badgeClass: 'badge-speed'
81
+ id: 'opus-4.6-high',
82
+ aliases: ['opus-4.6-low', 'opus-4.6-medium', 'opus-4.5'],
83
+ cli_model: 'claude-opus-4-6',
84
+ env: { CLAUDE_CODE_EFFORT_LEVEL: 'high' },
85
+ name: 'Opus 4.6 High',
86
+ tier: 'thorough',
87
+ tagline: 'Previous Gen',
88
+ description: 'Opus 4.6 with high effort',
89
+ badge: 'Previous Gen',
90
+ badgeClass: 'badge-power'
66
91
  },
92
+ // ── Balanced tier ───────────────────────────────────────────────────────
67
93
  {
68
94
  id: 'sonnet-4.6',
69
95
  cli_model: 'claude-sonnet-4-6',
@@ -74,28 +100,6 @@ const CLAUDE_MODELS = [
74
100
  badge: 'Standard',
75
101
  badgeClass: 'badge-recommended'
76
102
  },
77
- {
78
- id: 'opus-4.6-low',
79
- cli_model: 'claude-opus-4-6',
80
- env: { CLAUDE_CODE_EFFORT_LEVEL: 'low' },
81
- name: 'Opus 4.6 Low',
82
- tier: 'balanced',
83
- tagline: 'Fast Opus',
84
- description: 'Opus 4.6 with low effort — quick and capable',
85
- badge: 'Balanced',
86
- badgeClass: 'badge-recommended'
87
- },
88
- {
89
- id: 'opus-4.6-medium',
90
- cli_model: 'claude-opus-4-6',
91
- env: { CLAUDE_CODE_EFFORT_LEVEL: 'medium' },
92
- name: 'Opus 4.6 Medium',
93
- tier: 'balanced',
94
- tagline: 'Balanced Opus',
95
- description: 'Opus 4.6 with medium effort — balanced depth',
96
- badge: 'Thorough',
97
- badgeClass: 'badge-power'
98
- },
99
103
  {
100
104
  id: 'opus-4.6-1m',
101
105
  cli_model: 'claude-opus-4-6[1m]',
@@ -106,15 +110,17 @@ const CLAUDE_MODELS = [
106
110
  badge: 'More Context',
107
111
  badgeClass: 'badge-power'
108
112
  },
113
+ // ── Fast tier ───────────────────────────────────────────────────────────
109
114
  {
110
- id: 'opus-4.5',
111
- cli_model: 'claude-opus-4-5-20251101',
112
- name: 'Opus 4.5',
113
- tier: 'thorough',
114
- tagline: 'Deep Thinker',
115
- description: 'Extended thinking for complex analysis',
116
- badge: 'Previous Gen',
117
- badgeClass: 'badge-power'
115
+ id: 'haiku',
116
+ cli_model: 'claude-haiku-4-5-20251001',
117
+ name: 'Haiku 4.5',
118
+ tier: 'fast',
119
+ tagline: 'Lightning Fast',
120
+ description: 'Quick analysis for simple changes',
121
+ badge: 'Fastest',
122
+ badgeClass: 'badge-speed',
123
+ extra_args: ['--thinking', 'adaptive']
118
124
  }
119
125
  ];
120
126
 
@@ -196,7 +202,12 @@ class ClaudeProvider extends AIProvider {
196
202
  // user's configured environment. To disable skills, add --disable-slash-commands
197
203
  // to extra_args in provider/model config.
198
204
  const hooksArgs = ['--settings', '{"disableAllHooks":true}'];
199
- const baseArgs = ['-p', '--verbose', ...cliModelArgs, '--output-format', 'stream-json', ...hooksArgs, ...permissionArgs];
205
+ // Force extended thinking on for every analysis call. The Claude CLI's
206
+ // `--thinking` flag accepts enabled|adaptive|disabled; we always want
207
+ // reasoning engaged for code review. User config extra_args appended later
208
+ // win over this (commander uses the last occurrence) if an override is set.
209
+ const thinkingArgs = ['--thinking', 'enabled'];
210
+ const baseArgs = ['-p', '--verbose', ...cliModelArgs, '--output-format', 'stream-json', ...thinkingArgs, ...hooksArgs, ...permissionArgs];
200
211
  if (maxBudget) {
201
212
  const budgetNum = parseFloat(maxBudget);
202
213
  if (isNaN(budgetNum) || budgetNum <= 0) {
@@ -242,7 +253,8 @@ class ClaudeProvider extends AIProvider {
242
253
  // - string: use this exact value for --model
243
254
  // - null: explicitly suppress --model (for tools that want the model set via env instead)
244
255
  const builtIn = CLAUDE_MODELS.find(m => m.id === modelId || (m.aliases && m.aliases.includes(modelId)));
245
- const configModel = configOverrides.models?.find(m => m.id === modelId);
256
+ const modelKeys = new Set([modelId, builtIn?.id, ...(builtIn?.aliases || [])].filter(Boolean));
257
+ const configModel = configOverrides.models?.find(m => modelKeys.has(m.id));
246
258
  const resolvedCliModel = configModel?.cli_model !== undefined
247
259
  ? configModel.cli_model
248
260
  : (builtIn?.cli_model !== undefined ? builtIn.cli_model : modelId);