@in-the-loop-labs/pair-review 3.4.1 → 3.5.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.
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>
@@ -118,6 +118,9 @@ function getChatProvider(id) {
118
118
  };
119
119
  if (overrides.model) provider.model = overrides.model;
120
120
  if (overrides.provider) provider.provider = overrides.provider;
121
+ if (overrides.availability_command !== undefined) {
122
+ provider.availability_command = overrides.availability_command;
123
+ }
121
124
  if (overrides.extra_args && Array.isArray(overrides.extra_args)) {
122
125
  provider.args = [...provider.args, ...overrides.extra_args];
123
126
  }
@@ -136,6 +139,9 @@ function getChatProvider(id) {
136
139
  if (overrides.command) merged.command = overrides.command;
137
140
  if (overrides.model) merged.model = overrides.model;
138
141
  if (overrides.provider) merged.provider = overrides.provider;
142
+ if (overrides.availability_command !== undefined) {
143
+ merged.availability_command = overrides.availability_command;
144
+ }
139
145
  if (overrides.env) merged.env = { ...merged.env, ...overrides.env };
140
146
  if (overrides.args) {
141
147
  merged.args = overrides.args;
@@ -197,8 +203,10 @@ function isCodexProvider(id) {
197
203
 
198
204
  /**
199
205
  * Check availability of a single chat provider.
200
- * For Pi, delegates to the existing AI provider availability cache.
201
- * For ACP providers, spawns `<command> --version` to verify the binary exists.
206
+ * Providers with `availability_command` run that command first.
207
+ * Without an availability command, Pi delegates to the existing AI provider
208
+ * availability cache and other providers spawn `<command> --version` to verify
209
+ * the binary exists.
202
210
  * @param {string} id - Provider ID
203
211
  * @param {Object} [_deps] - Dependency overrides for testing
204
212
  * @returns {Promise<{available: boolean, error?: string}>}
@@ -209,6 +217,19 @@ async function checkChatProviderAvailability(id, _deps) {
209
217
  return { available: false, error: `Unknown provider: ${id}` };
210
218
  }
211
219
 
220
+ const deps = { ...defaults, ..._deps };
221
+
222
+ if (provider.availability_command) {
223
+ return runCommandAvailabilityCheck({
224
+ deps,
225
+ command: provider.availability_command,
226
+ args: [],
227
+ displayCommand: 'availability command',
228
+ shell: true,
229
+ env: provider.env,
230
+ });
231
+ }
232
+
212
233
  // Pi delegates to existing AI provider availability
213
234
  if (provider.type === 'pi') {
214
235
  const cached = getCachedAvailability('pi');
@@ -218,30 +239,61 @@ async function checkChatProviderAvailability(id, _deps) {
218
239
  // Codex uses the same binary-check pattern as ACP providers
219
240
  // (falls through to the spawn check below)
220
241
 
221
- const deps = { ...defaults, ..._deps };
222
242
  const command = provider.command;
223
243
  const useShell = provider.useShell || false;
224
244
 
245
+ // For multi-word commands, use shell mode
246
+ const spawnCmd = useShell ? `${command} --version` : command;
247
+ const spawnArgs = useShell ? [] : ['--version'];
248
+ return runCommandAvailabilityCheck({
249
+ deps,
250
+ command: spawnCmd,
251
+ args: spawnArgs,
252
+ displayCommand: `${command} --version`,
253
+ shell: useShell,
254
+ env: provider.env,
255
+ });
256
+ }
257
+
258
+ /**
259
+ * Spawn a command and resolve based on its exit status. Shared by the
260
+ * configured `availability_command` path and the legacy `<command> --version`
261
+ * fallback.
262
+ *
263
+ * Notes on the option choices:
264
+ * - `stdio: ['ignore', 'ignore', 'ignore']` discards output so a verbose probe
265
+ * cannot fill an OS pipe buffer and block while waiting for a reader.
266
+ * - `shell: true` allows multi-word configured commands to run through the
267
+ * user's shell.
268
+ * - `once()` avoids leaking listeners or resolving twice if multiple child
269
+ * process events fire.
270
+ * - `displayCommand` is used in error messages so user-configured shell strings
271
+ * do not need to be printed verbatim.
272
+ *
273
+ * @param {{deps: {spawn: Function}, command: string, args: string[], displayCommand: string, shell: boolean, env?: Object}} opts
274
+ * @returns {Promise<{available: boolean, error?: string}>}
275
+ */
276
+ function runCommandAvailabilityCheck({ deps, command, args, displayCommand, shell, env }) {
225
277
  return new Promise((resolve) => {
226
278
  try {
227
- // For multi-word commands, use shell mode
228
- const spawnCmd = useShell ? `${command} --version` : command;
229
- const spawnArgs = useShell ? [] : ['--version'];
230
- const proc = deps.spawn(spawnCmd, spawnArgs, {
231
- stdio: ['ignore', 'pipe', 'pipe'],
279
+ const proc = deps.spawn(command, args, {
280
+ stdio: ['ignore', 'ignore', 'ignore'],
232
281
  timeout: 10000,
233
- shell: useShell,
282
+ shell,
283
+ env: { ...process.env, ...(env || {}) },
234
284
  });
235
285
 
236
- proc.on('error', (err) => {
286
+ proc.once('error', (err) => {
237
287
  resolve({ available: false, error: err.message });
238
288
  });
239
289
 
240
- proc.on('close', (code) => {
290
+ proc.once('close', (code, signal) => {
241
291
  if (code === 0) {
242
292
  resolve({ available: true });
293
+ } else if (signal) {
294
+ resolve({ available: false, error: `${displayCommand} timed out or was terminated (${signal})` });
243
295
  } else {
244
- resolve({ available: false, error: `${command} --version exited with code ${code}` });
296
+ resolve({ available: false, error: `${displayCommand} exited with code ${code}` });
245
297
  }
246
298
  });
247
299
  } catch (err) {
package/src/config.js CHANGED
@@ -39,7 +39,8 @@ const DEFAULT_CONFIG = {
39
39
  assisted_by_url: "https://github.com/in-the-loop-labs/pair-review", // URL for "Review assisted by" footer link
40
40
  hooks: {}, // Hook commands per event: { "review.started": { "my_hook": { "command": "..." } } }
41
41
  enable_graphite: false, // When true, shows Graphite links alongside GitHub links
42
- skip_update_notifier: false // When true, suppresses the "update available" notification on exit
42
+ skip_update_notifier: false, // When true, suppresses the "update available" notification on exit
43
+ external_comments: false // Opt-in: set to true to enable GitHub PR review-comment sync (External segment, refresh button, /api/reviews/*/external-comments routes)
43
44
  };
44
45
 
45
46
  /**