@in-the-loop-labs/pair-review 3.5.2 → 3.7.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 (93) hide show
  1. package/README.md +4 -0
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/public/css/analysis-config.css +1807 -0
  6. package/public/css/pr.css +1029 -2169
  7. package/public/index.html +11 -0
  8. package/public/js/components/AIPanel.js +39 -23
  9. package/public/js/components/AdvancedConfigTab.js +56 -4
  10. package/public/js/components/AnalysisConfigModal.js +41 -25
  11. package/public/js/components/ChatPanel.js +163 -3
  12. package/public/js/components/KeyboardShortcuts.js +10 -26
  13. package/public/js/components/ReviewModal.js +135 -13
  14. package/public/js/components/TourBar.js +248 -0
  15. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  16. package/public/js/index.js +175 -16
  17. package/public/js/local.js +64 -8
  18. package/public/js/modules/cancel-background-job.js +183 -0
  19. package/public/js/modules/hunk-summary-renderer.js +116 -0
  20. package/public/js/modules/storage-cleanup.js +16 -0
  21. package/public/js/modules/suggestion-manager.js +25 -1
  22. package/public/js/modules/tour-renderer.js +755 -0
  23. package/public/js/pr.js +1826 -56
  24. package/public/js/repo-links.js +328 -0
  25. package/public/js/utils/modal-detection.js +77 -0
  26. package/public/js/utils/provider-model.js +88 -0
  27. package/public/js/utils/storage-keys.js +50 -0
  28. package/public/local.html +24 -0
  29. package/public/pr.html +24 -0
  30. package/public/repo-settings.html +1 -0
  31. package/public/setup.html +2 -0
  32. package/src/ai/abort-signal-wiring.js +130 -0
  33. package/src/ai/analyzer.js +125 -18
  34. package/src/ai/background-queue.js +290 -0
  35. package/src/ai/claude-cli.js +1 -1
  36. package/src/ai/claude-provider.js +50 -7
  37. package/src/ai/codex-provider.js +28 -5
  38. package/src/ai/copilot-provider.js +22 -3
  39. package/src/ai/cursor-agent-provider.js +22 -6
  40. package/src/ai/executable-provider.js +4 -19
  41. package/src/ai/gemini-provider.js +22 -5
  42. package/src/ai/hunk-hashing.js +161 -0
  43. package/src/ai/index.js +2 -0
  44. package/src/ai/opencode-provider.js +21 -5
  45. package/src/ai/pi-provider.js +21 -5
  46. package/src/ai/prompts/hunk-summary.js +199 -0
  47. package/src/ai/prompts/tour.js +232 -0
  48. package/src/ai/provider.js +21 -1
  49. package/src/ai/summary-generator.js +469 -0
  50. package/src/ai/tour-generator.js +568 -0
  51. package/src/config.js +778 -10
  52. package/src/database.js +282 -1
  53. package/src/external/github-adapter.js +114 -25
  54. package/src/git/base-branch.js +11 -4
  55. package/src/github/client.js +482 -588
  56. package/src/github/errors.js +55 -0
  57. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  58. package/src/github/impl/graphql/pending-review.js +153 -0
  59. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  60. package/src/github/impl/graphql/stack-walker.js +210 -0
  61. package/src/github/impl/host/pending-review-comments.js +338 -0
  62. package/src/github/impl/rest/pending-review.js +251 -0
  63. package/src/github/impl/rest/review-lifecycle.js +226 -0
  64. package/src/github/impl/rest/stack-walker.js +309 -0
  65. package/src/github/operations/pending-review-comments.js +79 -0
  66. package/src/github/operations/pending-review.js +89 -0
  67. package/src/github/operations/review-lifecycle.js +126 -0
  68. package/src/github/operations/stack-walker.js +87 -0
  69. package/src/github/parser.js +230 -4
  70. package/src/github/stack-walker.js +14 -189
  71. package/src/links/repo-links.js +230 -0
  72. package/src/local-review.js +201 -172
  73. package/src/main.js +133 -30
  74. package/src/routes/analyses.js +30 -7
  75. package/src/routes/bulk-analysis-configs.js +295 -0
  76. package/src/routes/config.js +118 -3
  77. package/src/routes/context-files.js +2 -29
  78. package/src/routes/external-comments.js +20 -10
  79. package/src/routes/github-collections.js +3 -1
  80. package/src/routes/local.js +410 -13
  81. package/src/routes/mcp.js +47 -4
  82. package/src/routes/middleware/validate-review-id.js +53 -0
  83. package/src/routes/pr.js +556 -71
  84. package/src/routes/reviews.js +145 -29
  85. package/src/routes/setup.js +8 -3
  86. package/src/routes/stack-analysis.js +33 -9
  87. package/src/routes/worktrees.js +3 -2
  88. package/src/server.js +2 -0
  89. package/src/setup/pr-setup.js +37 -11
  90. package/src/setup/stack-setup.js +13 -3
  91. package/src/single-port.js +6 -3
  92. package/src/utils/diff-hunks.js +65 -0
  93. package/src/utils/json-extractor.js +5 -2
package/public/js/pr.js CHANGED
@@ -80,18 +80,17 @@ class PRManager {
80
80
  }
81
81
 
82
82
  /**
83
- * Generate a safe localStorage key for repository-specific settings
84
- * Uses base64 encoding to handle special characters in owner/repo names
83
+ * Generate a safe localStorage key for repository-specific settings.
84
+ * Delegates to the shared helper in public/js/utils/storage-keys.js
85
+ * (loaded before pr.js) so keys stay byte-identical with the index/bulk page,
86
+ * which writes the same per-repo keys this page reads.
85
87
  * @param {string} prefix - Key prefix (e.g., 'pair-review-model')
86
88
  * @param {string} owner - Repository owner
87
89
  * @param {string} repo - Repository name
88
90
  * @returns {string} Safe localStorage key
89
91
  */
90
92
  static getRepoStorageKey(prefix, owner, repo) {
91
- // Use encodeURIComponent + btoa to safely handle Unicode characters
92
- // btoa() only accepts Latin1, so we encode Unicode first
93
- const repoId = btoa(unescape(encodeURIComponent(`${owner}/${repo}`))).replace(/=/g, '');
94
- return `${prefix}:${repoId}`;
93
+ return window.getRepoStorageKey(prefix, owner, repo);
95
94
  }
96
95
 
97
96
  constructor() {
@@ -138,6 +137,126 @@ class PRManager {
138
137
  this.diffOptionsDropdown = null;
139
138
  // Comment minimizer — manages minimize mode indicators
140
139
  this.commentMinimizer = window.CommentMinimizer ? new window.CommentMinimizer() : null;
140
+ // Hunk summary renderer (Phase 5) — inline natural-language summaries
141
+ this.hunkSummaryRenderer = window.HunkSummaryRenderer ? new window.HunkSummaryRenderer(this) : null;
142
+ // Per-render anchor map, file-scoped so a content-hash shared across two
143
+ // files (renamed-with-tiny-edits, copy-pasted boilerplate, identical
144
+ // stubs) can't let the later file's anchor overwrite the earlier one's:
145
+ // Map<filePath, Map<contentHash, anchorRow>>
146
+ // where anchorRow is the first code-line <tr> of the hunk. Symmetric with
147
+ // `_pendingSummariesByHash` so both sides of the queue/anchor handshake
148
+ // key on (filePath, hash).
149
+ this._summaryAnchorsByHash = new Map();
150
+ // Per-render file map: filePath -> Set<contentHash>
151
+ this._summaryHashesByFile = new Map();
152
+ // Summaries that arrived (via WS or fetch) before their hunk had been
153
+ // hashed, scoped by file path so a content-hash collision across files
154
+ // can't let one file's anchor consume another file's queued summary:
155
+ // Map<filePath, Map<contentHash, summary>>
156
+ // The '' bucket holds summaries queued without a file path (the legacy
157
+ // ungrouped-fetch fallback in _fetchHunkSummaryMap).
158
+ this._pendingSummariesByHash = new Map();
159
+ // Per-file summary visibility (persisted in localStorage per-review)
160
+ this.summariesHiddenFiles = new Set();
161
+ // Render-generation token — incremented at the top of renderDiff() so any
162
+ // fire-and-forget _fetchHunkSummaryMap() from a prior render can detect
163
+ // it's stale and bail (refresh / whitespace toggle / scope change race).
164
+ this._renderGen = 0;
165
+ // ---- Lazy diff-body rendering (perf for very large PRs) -----------
166
+ // Each file's wrapper + header render eagerly, but the <tbody> of diff
167
+ // lines is built on demand: when the body scrolls near the viewport
168
+ // (IntersectionObserver), when the file is expanded, or when a code path
169
+ // needs to anchor into it (see ensureFileBodyRendered). Collapsed bodies
170
+ // are `display:none`, so they never intersect and stay unrendered until
171
+ // expanded. Key = file path; value = lazy entry (see renderFileDiff).
172
+ this._lazyFileBodies = new Map();
173
+ // The IntersectionObserver watching `.d2h-file-body` elements for the
174
+ // current render generation. Recreated on every renderDiff().
175
+ this._fileBodyObserver = null;
176
+ // Cached /api/config response (lazy-loaded)
177
+ this._appConfigPromise = null;
178
+ // Review-level summary visibility (persisted in localStorage per-review)
179
+ this._summariesHidden = false;
180
+ // Whether the background summary job is currently running for this review.
181
+ // Cleared by the `review:background_job_finished` event for jobType=summaries.
182
+ this._summariesGenerating = false;
183
+ // Whether any hunk summaries exist for this review (loaded from the server
184
+ // or mounted live during generation). Gates the toolbar button's `.active`
185
+ // (blue) state: before any summary exists the button stays colorless so the
186
+ // user can tell nothing has been generated yet. Set true by
187
+ // `_applyHunkSummaries` and the initial existing-summaries fetch.
188
+ this._summariesGenerated = false;
189
+ // Whether any non-trivial hunk summary EXISTS for this review — mounted
190
+ // OR merely queued because its lazy file body hasn't rendered yet. This is
191
+ // deliberately separate from `_summariesGenerated` (which gates the
192
+ // `.active` blue styling and tracks summaries actually in the DOM): with
193
+ // lazy bodies a valid summary can arrive before its anchor exists, and the
194
+ // toolbar must still treat the feature as "has data" so a click toggles
195
+ // visibility instead of dispatching a duplicate generation job. Set by
196
+ // `_applyHunkSummaries` / `_fetchHunkSummaryMap`; reset in renderDiff.
197
+ this._summariesAvailable = false;
198
+ // Tri-state: true when /api/config reports summaries.enabled, false when
199
+ // disabled, null until /api/config resolves. Per-file toggle buttons are
200
+ // gated on this so users on disabled deployments don't see them flicker
201
+ // in.
202
+ this._summariesEnabled = null;
203
+ // Tri-state mirror of `summaries.auto_generate` in /api/config. When
204
+ // false, the click handler hits the manual-start endpoint instead of
205
+ // expecting a server-initiated kickoff. Null until /api/config resolves.
206
+ this._summariesAutoGenerate = null;
207
+ // ---- Tour state (Phase 8) -----------------------------------------
208
+ // Lazy-instantiated TourBar / TourRenderer; populated on first open.
209
+ this._tourBar = null;
210
+ this._tourRenderer = null;
211
+ // Tri-state mirror of `tours.enabled` in /api/config. Tours are
212
+ // independent of summaries on both the server and the client (see
213
+ // the explanatory comment in setupEventHandlers()).
214
+ this._toursEnabled = null;
215
+ // Tri-state mirror of `tours.auto_generate` in /api/config. When false,
216
+ // the click handler hits the manual-start endpoint instead of expecting
217
+ // a server-initiated kickoff. Null until /api/config resolves.
218
+ this._toursAutoGenerate = null;
219
+ // Cached stops from the most recent /api/reviews/:id/tour fetch, or null
220
+ // when nothing has been loaded for this review yet.
221
+ this._tourStops = null;
222
+ // 0-based index of the current stop while a tour is open; -1 when no
223
+ // tour is mounted.
224
+ this._tourActiveIndex = -1;
225
+ // Whether the background tour-generation job is currently running; drives
226
+ // the pulse on the toolbar button.
227
+ this._tourGenerating = false;
228
+ // When a `review:tour_ready` event fires while a tour is already mounted,
229
+ // we stash the new stops here rather than yank the current tour out from
230
+ // under the user. The pending stops are applied on the next exit or
231
+ // restart. Cleared once consumed.
232
+ this._tourStopsPendingRestart = null;
233
+ // Bound keydown handler; tracked so it can be removed on tour exit.
234
+ this._tourKeydownHandler = null;
235
+ // Re-entry guard for `_promptCancelJob`. The cancel-confirm dialog
236
+ // opens off the same pulsing toolbar button that triggered it, and
237
+ // the button stays clickable while the dialog is up — `ConfirmDialog`
238
+ // is a singleton, so a second click would overwrite the first
239
+ // invocation's callbacks and leave its Promise dangling. Held true
240
+ // for the lifetime of the dialog; cleared in a `finally`.
241
+ this._cancelPromptOpen = false;
242
+ // Re-entrance latch for the async `_advanceTour` probe loop. Holds the
243
+ // `_tourGen` value the in-flight call belongs to (or -1 when no call
244
+ // is in flight). Generation-scoped — not a plain boolean — so a
245
+ // teardown that bumps `_tourGen` auto-invalidates the holder and the
246
+ // next reopen passes the latch check without any teardown path having
247
+ // to remember to reset the flag explicitly.
248
+ this._advanceInFlightGen = -1;
249
+ // Tour-open generation. Bumped on every open and exit so an in-flight
250
+ // async `_advanceTour` can detect that the tour it started navigating
251
+ // has since been torn down (Escape, exit button, toolbar toggle) and
252
+ // bail instead of mutating a dead tour's state.
253
+ this._tourGen = 0;
254
+ // Drain promise stashed by `_exitTour`. Resolves once every async
255
+ // teardown step from the prior tour (fire-and-forget context-file
256
+ // DELETEs + their loadContextFiles reloads) has settled. The next
257
+ // open awaits this before reading wrappers so a stale DELETE can't
258
+ // rip the newly-mounted tour's wrapper out from under it.
259
+ this._tourCleanupPending = null;
141
260
  // Cached staleness check promise — shared between on-load and triggerAIAnalysis
142
261
  this._stalenessPromise = null;
143
262
  // Unique client ID for self-echo suppression on WebSocket review events.
@@ -320,6 +439,67 @@ class PRManager {
320
439
  }
321
440
  }
322
441
 
442
+ // Hunk summary toolbar toggle (Phase 5).
443
+ // Hidden by default in HTML; revealed asynchronously when /api/config
444
+ // reports summaries.enabled. The same config check also controls the
445
+ // per-file `.file-header-summary-toggle` buttons (which are created
446
+ // hidden by createFileHeader and revealed/removed once config resolves).
447
+ const summaryToggle = document.getElementById('summary-toggle-btn');
448
+ if (summaryToggle) {
449
+ summaryToggle.addEventListener('click', () => this._handleSummaryToggleClick());
450
+ }
451
+ this._getAppConfig().then((cfg) => {
452
+ const summariesCfg = (cfg && cfg.summaries) || {};
453
+ this._summariesEnabled = summariesCfg.enabled === true;
454
+ this._summariesAutoGenerate = summariesCfg.auto_generate !== false;
455
+ if (this._summariesEnabled) {
456
+ if (summaryToggle) {
457
+ summaryToggle.style.display = '';
458
+ this._syncSummaryToolbarButton();
459
+ }
460
+ document
461
+ .querySelectorAll('.file-header-summary-toggle.summary-toggle-pending')
462
+ .forEach((btn) => {
463
+ btn.classList.remove('summary-toggle-pending');
464
+ btn.style.display = '';
465
+ });
466
+ } else {
467
+ document
468
+ .querySelectorAll('.file-header-summary-toggle')
469
+ .forEach((btn) => btn.remove());
470
+ }
471
+ });
472
+
473
+ // Tour toolbar toggle (Phase 8). Hidden by default in HTML; revealed
474
+ // asynchronously once /api/config confirms `tours.enabled` is on.
475
+ // Tours are independent of `summaries.enabled` — both server- and
476
+ // client-side gates check only `tours.enabled`.
477
+ const tourToggle = document.getElementById('tour-toggle-btn');
478
+ if (tourToggle) {
479
+ tourToggle.addEventListener('click', () => this._handleTourToggleClick());
480
+ }
481
+ this._getAppConfig().then((cfg) => {
482
+ const toursCfg = (cfg && cfg.tours) || {};
483
+ this._toursEnabled = toursCfg.enabled === true;
484
+ this._toursAutoGenerate = toursCfg.auto_generate !== false;
485
+ if (this._toursEnabled && tourToggle) {
486
+ tourToggle.style.display = '';
487
+ this._syncTourToolbarButton();
488
+ }
489
+ // NOTE: do NOT probe /api/reviews/:id/tour here when no diff has
490
+ // rendered yet — `currentPR.id` is not populated until
491
+ // init()/LocalManager loads the review. The probe is normally
492
+ // deferred to renderDiff() (which fires after currentPR is set).
493
+ //
494
+ // RACE: if renderDiff() has ALREADY run by the time /api/config
495
+ // resolves, its `_toursEnabled === true` check failed (was still
496
+ // null) and the probe was skipped. Catch that case here so the
497
+ // tour toolbar still surfaces a generated tour.
498
+ if (this._toursEnabled && this._renderGen > 0) {
499
+ this._loadAndStashTour({ cancelOnRender: false }).catch(() => {});
500
+ }
501
+ });
502
+
323
503
  // PR description popover
324
504
  this.setupPRDescriptionPopover();
325
505
 
@@ -438,7 +618,7 @@ class PRManager {
438
618
  * @param {Object} reviewSettings - Review settings from fetchLastReviewSettings()
439
619
  * @returns {Promise<Object>} Config object suitable for startAnalysis / startLocalAnalysis
440
620
  */
441
- async _buildDefaultAnalysisConfig(repoSettings, reviewSettings) {
621
+ async _buildDefaultAnalysisConfig(repoSettings, reviewSettings, appConfig = {}, providersInfo = null) {
442
622
  const defaultTab = repoSettings?.default_tab || 'single';
443
623
  const councilId = repoSettings?.default_council_id || reviewSettings?.last_council_id || null;
444
624
 
@@ -467,13 +647,44 @@ class PRManager {
467
647
  };
468
648
  }
469
649
 
650
+ // Resolve provider and model as a MATCHED pair. Resolving each half
651
+ // independently (repo || app || hardcoded) can mix a provider from one
652
+ // scope with a model from another, yielding an invalid pair (e.g.
653
+ // gemini/opus) that startAnalysis would forward to the backend as-is.
654
+ const providers = providersInfo || await this._getProvidersInfo();
655
+ const { provider, model } = window.resolveProviderModelPair([
656
+ { provider: repoSettings?.default_provider, model: repoSettings?.default_model },
657
+ { provider: appConfig.default_provider, model: appConfig.default_model }
658
+ ], providers);
659
+
470
660
  return {
471
- provider: repoSettings?.default_provider || 'claude',
472
- model: repoSettings?.default_model || 'opus',
661
+ provider,
662
+ model,
473
663
  customInstructions: null
474
664
  };
475
665
  }
476
666
 
667
+ async _fetchAutoAnalysisConfigFromUrl() {
668
+ const params = new URLSearchParams(window.location.search);
669
+ const configId = params.get('analysisConfigId');
670
+ if (!configId) return { requested: false, config: null, error: null };
671
+
672
+ try {
673
+ const response = await fetch(`/api/bulk-analysis-configs/${encodeURIComponent(configId)}`);
674
+ if (!response.ok) {
675
+ throw new Error('Stored analysis settings were not found');
676
+ }
677
+ const data = await response.json();
678
+ if (!data.analysisConfig) {
679
+ throw new Error('Stored analysis settings response was empty');
680
+ }
681
+ return { requested: true, config: data.analysisConfig, error: null };
682
+ } catch (error) {
683
+ console.warn('Failed to fetch bulk analysis config:', error);
684
+ return { requested: true, config: null, error };
685
+ }
686
+ }
687
+
477
688
  /**
478
689
  * Auto-trigger analysis if ?analyze=true is present in the URL.
479
690
  * Skips refresh if data was just loaded fresh by loadPR (to avoid redundant fetches).
@@ -488,6 +699,7 @@ class PRManager {
488
699
  const autoAnalyze = new URLSearchParams(window.location.search).get('analyze');
489
700
  if (autoAnalyze === 'true' && !this.isAnalyzing) {
490
701
  this._autoAnalyzeRequested = true;
702
+ let shouldCleanUrl = true;
491
703
  try {
492
704
  // Skip refresh if we just loaded fresh data (loadPR sets _justLoaded = true).
493
705
  // Otherwise, refresh to ensure we have the latest PR data in case the worktree
@@ -504,19 +716,39 @@ class PRManager {
504
716
  }
505
717
  }
506
718
 
507
- // Fetch repo settings so we honour the repository's default provider/council
508
- const [repoSettings, reviewSettings] = await Promise.all([
509
- this.fetchRepoSettings().catch(() => null),
510
- this.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null }))
511
- ]);
512
- const config = await this._buildDefaultAnalysisConfig(repoSettings, reviewSettings);
719
+ const storedConfig = await this._fetchAutoAnalysisConfigFromUrl();
720
+ let config;
721
+ if (storedConfig.requested) {
722
+ if (!storedConfig.config) {
723
+ // The stored bulk-analysis config expired (TTL/eviction/restart).
724
+ // The PR diff has already rendered, so don't replace it with a
725
+ // full-screen error whose Retry button would just re-trigger the
726
+ // same failed lookup. Warn, strip the stale params (so a refresh
727
+ // won't re-trigger), and leave the PR usable for manual analysis.
728
+ const message = 'Could not load the selected bulk analysis settings. Start analysis manually to choose new settings.';
729
+ if (window.toast) window.toast.showWarning(message);
730
+ return;
731
+ }
732
+ config = storedConfig.config;
733
+ } else {
734
+ // Fetch repo settings so we honour the repository's default provider/council
735
+ const [repoSettings, reviewSettings, appConfig] = await Promise.all([
736
+ this.fetchRepoSettings().catch(() => null),
737
+ this.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null })),
738
+ this._getAppConfig()
739
+ ]);
740
+ config = await this._buildDefaultAnalysisConfig(repoSettings, reviewSettings, appConfig);
741
+ }
513
742
 
514
743
  await this.startAnalysis(owner, repo, prNumber, null, config);
515
744
  } finally {
516
745
  this._autoAnalyzeRequested = false;
517
- const cleanUrl = new URL(window.location);
518
- cleanUrl.searchParams.delete('analyze');
519
- history.replaceState(null, '', cleanUrl);
746
+ if (shouldCleanUrl) {
747
+ const cleanUrl = new URL(window.location);
748
+ cleanUrl.searchParams.delete('analyze');
749
+ cleanUrl.searchParams.delete('analysisConfigId');
750
+ history.replaceState(null, '', cleanUrl);
751
+ }
520
752
  }
521
753
  }
522
754
  }
@@ -903,6 +1135,1103 @@ class PRManager {
903
1135
  return this._rerenderAllOverlays();
904
1136
  }
905
1137
 
1138
+ /**
1139
+ * Resolve the cached `/api/config` payload, fetching it on first use.
1140
+ * @returns {Promise<Object>}
1141
+ */
1142
+ _getAppConfig() {
1143
+ if (!this._appConfigPromise) {
1144
+ this._appConfigPromise = fetch('/api/config')
1145
+ .then((r) => (r.ok ? r.json() : {}))
1146
+ .catch(() => ({}));
1147
+ }
1148
+ return this._appConfigPromise;
1149
+ }
1150
+
1151
+ /**
1152
+ * Fetch and cache the provider/model metadata from /api/providers. Used to
1153
+ * resolve a coherent provider/model pair for the non-modal auto-analyze path
1154
+ * (the modal loads its own copy). Resolves to the `providers` array, or [] on
1155
+ * failure so callers can fall back to provider-agnostic defaults.
1156
+ */
1157
+ _getProvidersInfo() {
1158
+ if (!this._providersInfoPromise) {
1159
+ this._providersInfoPromise = fetch('/api/providers')
1160
+ .then((r) => (r.ok ? r.json() : {}))
1161
+ .then((data) => (Array.isArray(data.providers) ? data.providers : []))
1162
+ .catch(() => []);
1163
+ }
1164
+ return this._providersInfoPromise;
1165
+ }
1166
+
1167
+ /**
1168
+ * Wire one file's hunk anchor rows to their server-supplied content hashes
1169
+ * as that file's body renders (called from _renderFileBodyNow), then mount
1170
+ * any summary that already arrived for those hunks.
1171
+ *
1172
+ * Pre-lazy-render this happened once globally in _kickOffHunkSummaries;
1173
+ * with lazy bodies a file's anchor rows don't exist until it renders, so
1174
+ * anchoring is now incremental. The server computes hashes from the
1175
+ * canonical (non-whitespace-filtered) diff so they stay aligned with
1176
+ * persisted summary keys regardless of `?w=1`; records lacking a
1177
+ * `contentHash` are logged-and-skipped.
1178
+ *
1179
+ * The bridge for "summary arrived before its anchor existed" is the existing
1180
+ * `_pendingSummariesByHash` map: _renderOneSummary / _applyHunkSummaries
1181
+ * queue there when no anchor is found, and we drain matching entries here.
1182
+ * @param {Array<{file,header,anchorRow,contentHash}>} records
1183
+ */
1184
+ _registerHunkAnchorsForFile(records) {
1185
+ if (!Array.isArray(records) || records.length === 0) return;
1186
+ const filesWithMounts = new Set();
1187
+ for (const rec of records) {
1188
+ if (!rec.anchorRow || !rec.anchorRow.isConnected) continue;
1189
+ const hex = rec.contentHash;
1190
+ if (!hex) {
1191
+ console.warn(
1192
+ `[HunkSummary] no server contentHash for ${rec.file} ` +
1193
+ `hunk ${rec.header}; skipping (summaries will not anchor here).`
1194
+ );
1195
+ continue;
1196
+ }
1197
+ rec.anchorRow.dataset.hunkStart = hex;
1198
+ // Scope the anchor by file so a hash collision across files can't let
1199
+ // the later file's anchor clobber the earlier file's (which would mount
1200
+ // a summary against the wrong hunk). Symmetric with the file-scoped
1201
+ // pending-summary queue.
1202
+ let anchors = this._summaryAnchorsByHash.get(rec.file);
1203
+ if (!anchors) {
1204
+ anchors = new Map();
1205
+ this._summaryAnchorsByHash.set(rec.file, anchors);
1206
+ }
1207
+ anchors.set(hex, rec.anchorRow);
1208
+ let bucket = this._summaryHashesByFile.get(rec.file);
1209
+ if (!bucket) {
1210
+ bucket = new Set();
1211
+ this._summaryHashesByFile.set(rec.file, bucket);
1212
+ }
1213
+ bucket.add(hex);
1214
+
1215
+ // Mount a summary that arrived before this anchor existed. Look it up by
1216
+ // THIS file + hash so a content-hash that also appears in another file
1217
+ // (renamed-with-tiny-edits, copy-pasted boilerplate, identical stubs)
1218
+ // can't let this file's anchor consume the other file's queued summary.
1219
+ const pending = this._takePendingSummary(rec.file, hex);
1220
+ if (pending) {
1221
+ const row = this._renderOneSummary(pending, rec.file);
1222
+ if (row) {
1223
+ this._summariesGenerated = true;
1224
+ this._summariesAvailable = true;
1225
+ filesWithMounts.add(rec.file);
1226
+ }
1227
+ // If it couldn't mount (anchor not connected after all),
1228
+ // _renderOneSummary re-queued it scoped to rec.file for a later pass.
1229
+ }
1230
+ }
1231
+ if (this._summariesGenerated) this._syncSummaryToolbarButton();
1232
+ for (const filePath of filesWithMounts) this._refreshFileSummaryToggle(filePath);
1233
+ }
1234
+
1235
+ /**
1236
+ * Load the review's hunk summaries from the server and apply them to the
1237
+ * anchors that exist so far (gated by `summaries.enabled` in `/api/config`).
1238
+ *
1239
+ * With lazy bodies, anchor wiring is incremental (_registerHunkAnchorsForFile
1240
+ * runs as each body renders), so this method no longer walks render records.
1241
+ * Summaries whose file hasn't rendered yet are queued in
1242
+ * `_pendingSummariesByHash` (via _applyHunkSummaries / _renderOneSummary) and
1243
+ * drained when that body renders.
1244
+ *
1245
+ * Order:
1246
+ * 1. Config gate first — bail before paying any cost when the feature is off.
1247
+ * 2. Restore localStorage visibility state.
1248
+ * 3. Fetch existing summaries and apply/queue them.
1249
+ *
1250
+ * Race-safety: `_renderGen` is captured at entry and rechecked after every
1251
+ * `await`. If `renderDiff()` ran again mid-flight, we stop touching the (now
1252
+ * stale) maps and DOM.
1253
+ * @returns {Promise<void>}
1254
+ */
1255
+ async _fetchHunkSummaryMap() {
1256
+ const gen = this._renderGen;
1257
+
1258
+ // 1. Config gate — bail before doing any work when the feature is off.
1259
+ const cfg = await this._getAppConfig();
1260
+ if (gen !== this._renderGen) return;
1261
+ if (!(cfg.summaries && cfg.summaries.enabled)) return;
1262
+
1263
+ // 2. Restore localStorage visibility state.
1264
+ if (this.currentPR?.id) {
1265
+ const hidden = window.localStorage.getItem(`pair-review:summaries-hidden:${this.currentPR.id}`) === '1';
1266
+ this._summariesHidden = hidden;
1267
+ document.body.classList.toggle('summaries-hidden', hidden);
1268
+ this._syncSummaryToolbarButton();
1269
+ this._restoreSummariesHiddenFiles();
1270
+ // Apply per-file hidden state to wrappers already in the DOM, syncing
1271
+ // both the wrapper class AND the toggle button so its visible state and
1272
+ // aria-pressed match the persisted hidden flag.
1273
+ for (const filePath of this.summariesHiddenFiles) {
1274
+ const wrapper = document.querySelector(
1275
+ `.d2h-file-wrapper[data-file-name="${CSS.escape(filePath)}"]`
1276
+ );
1277
+ if (!wrapper) continue;
1278
+ wrapper.classList.add('summaries-hidden-file');
1279
+ const btn = wrapper.querySelector('.file-header-summary-toggle');
1280
+ if (btn) this._syncFileSummaryToggleButton(btn, filePath);
1281
+ }
1282
+ }
1283
+
1284
+ // 3. Load existing summaries from the server.
1285
+ if (!this.currentPR?.id) return;
1286
+
1287
+ try {
1288
+ const resp = await fetch(`/api/reviews/${this.currentPR.id}/hunk-summaries`);
1289
+ if (gen !== this._renderGen) return;
1290
+ if (!resp.ok) return;
1291
+ const data = await resp.json();
1292
+ if (gen !== this._renderGen) return;
1293
+ const summaries = Array.isArray(data.summaries) ? data.summaries : [];
1294
+ // Group by file path so we can refresh each file's toggle button once.
1295
+ const byFile = new Map();
1296
+ for (const summary of summaries) {
1297
+ const fp = summary.file_path;
1298
+ if (!fp) continue;
1299
+ if (!byFile.has(fp)) byFile.set(fp, []);
1300
+ byFile.get(fp).push(summary);
1301
+ }
1302
+ // If summaries lack file_path, fall back to ungrouped rendering. Set
1303
+ // `_summariesGenerated` only after a summary actually mounts against the
1304
+ // current render anchors — never from the raw fetch count — so stale-hash
1305
+ // rows the anchor filter rejects can't flip the toolbar into Hide/Show
1306
+ // mode with nothing in the DOM. The grouped path defers this to
1307
+ // `_applyHunkSummaries`, which sets the flag on a successful mount.
1308
+ if (byFile.size === 0 && summaries.length > 0) {
1309
+ let mountedAny = false;
1310
+ let availableAny = false;
1311
+ for (const summary of summaries) {
1312
+ if (summary.summary_text) availableAny = true;
1313
+ if (this._renderOneSummary(summary)) mountedAny = true;
1314
+ }
1315
+ if (availableAny) this._summariesAvailable = true;
1316
+ if (mountedAny) this._summariesGenerated = true;
1317
+ if (mountedAny || availableAny) this._syncSummaryToolbarButton();
1318
+ } else {
1319
+ for (const [filePath, fileSummaries] of byFile.entries()) {
1320
+ this._applyHunkSummaries(filePath, fileSummaries);
1321
+ }
1322
+ }
1323
+ // Mirror the queue's view of whether summaries are still being generated.
1324
+ // The `review:background_job_finished` WS event clears this when the job
1325
+ // completes mid-session.
1326
+ this._summariesGenerating = data.generating === true;
1327
+ this._syncSummaryToolbarButton();
1328
+ } catch (err) {
1329
+ console.warn('[HunkSummary] failed to load summaries:', err);
1330
+ }
1331
+ }
1332
+
1333
+ /**
1334
+ * Apply a batch of summaries delivered via the WS
1335
+ * `review:hunk_summaries_ready` event for a single file. Validates each
1336
+ * summary's hash against the per-file hash bucket so a hash collision
1337
+ * across files can't pull a summary into the wrong file's view, and
1338
+ * re-enables the per-file toggle button once a file has at least one
1339
+ * summary mounted.
1340
+ * @param {string} filePath - File path the summaries belong to
1341
+ * @param {Array<Object>} summaries - Summary rows for that file
1342
+ */
1343
+ _applyHunkSummaries(filePath, summaries) {
1344
+ if (!Array.isArray(summaries)) return;
1345
+ const allowedHashes = this._summaryHashesByFile.get(filePath) || new Set();
1346
+ let mountedAny = false;
1347
+ let availableAny = false;
1348
+ for (const summary of summaries) {
1349
+ if (!summary?.content_hash) continue;
1350
+ if (allowedHashes.size > 0 && !allowedHashes.has(summary.content_hash)) {
1351
+ if (!this._warnedCrossFileHashMismatch) {
1352
+ this._warnedCrossFileHashMismatch = true;
1353
+ console.warn(
1354
+ `[HunkSummary] dropping summary for ${filePath}: hash ${summary.content_hash} ` +
1355
+ 'not present in file hash bucket. Likely cross-file collision or stale render.'
1356
+ );
1357
+ }
1358
+ continue;
1359
+ }
1360
+ // A non-trivial summary that belongs to this render (it passed the hash
1361
+ // filter) means the feature has data even if its body hasn't rendered
1362
+ // yet — _renderOneSummary will queue it rather than mount it in that
1363
+ // case. Track availability separately from mounted rows.
1364
+ if (summary.summary_text) availableAny = true;
1365
+ const row = this._renderOneSummary(summary, filePath);
1366
+ if (row) mountedAny = true;
1367
+ }
1368
+ if (availableAny) this._summariesAvailable = true;
1369
+ if (mountedAny) {
1370
+ // At least one summary mounted — the feature now has visible data, so
1371
+ // the toolbar button can show its `.active` (blue) state.
1372
+ this._summariesGenerated = true;
1373
+ }
1374
+ // Refresh the toolbar when either flag changed: `.active` tracks
1375
+ // `_summariesGenerated` (rows in the DOM) while the Generate-vs-Show/Hide
1376
+ // decision tracks `_summariesAvailable` (mounted OR queued). Refreshing on
1377
+ // availableAny prevents a not-yet-rendered file from leaving the toolbar
1378
+ // in "Generate" mode, where a click would start a duplicate job.
1379
+ if (mountedAny || availableAny) this._syncSummaryToolbarButton();
1380
+ if (mountedAny && filePath) this._refreshFileSummaryToggle(filePath);
1381
+ }
1382
+
1383
+ /**
1384
+ * Refresh the per-file summary toggle button for `filePath` so it reflects
1385
+ * the current state: enabled iff there is at least one mounted summary in
1386
+ * that file's hash bucket.
1387
+ * @param {string} filePath
1388
+ */
1389
+ _refreshFileSummaryToggle(filePath) {
1390
+ if (!filePath) return;
1391
+ const wrapper = document.querySelector(
1392
+ `.d2h-file-wrapper[data-file-name="${CSS.escape(filePath)}"]`
1393
+ );
1394
+ if (!wrapper) return;
1395
+ const btn = wrapper.querySelector('.file-header-summary-toggle');
1396
+ if (!btn) return;
1397
+ this._syncFileSummaryToggleButton(btn, filePath);
1398
+ }
1399
+
1400
+ /**
1401
+ * Apply the canonical per-file summary toggle button state derived from
1402
+ * `_summaryHashesByFile` and `summariesHiddenFiles`. Sets `disabled`,
1403
+ * `summaries-off`, `aria-pressed`, and `title` on the button.
1404
+ *
1405
+ * Used by three call sites that must agree on the button's visible state:
1406
+ * - createFileHeader (initial render)
1407
+ * - _fetchHunkSummaryMap (rehydrate after localStorage restore)
1408
+ * - _refreshFileSummaryToggle (when summaries arrive late)
1409
+ * - toggleFileSummaries (user click)
1410
+ *
1411
+ * @param {HTMLButtonElement} btn
1412
+ * @param {string} filePath
1413
+ */
1414
+ _syncFileSummaryToggleButton(btn, filePath) {
1415
+ if (!btn || !filePath) return;
1416
+ const hasSummaries = (this._summaryHashesByFile.get(filePath)?.size || 0) > 0;
1417
+ const isHidden = this.summariesHiddenFiles.has(filePath);
1418
+ btn.classList.toggle('summaries-off', isHidden);
1419
+ btn.setAttribute('aria-pressed', isHidden ? 'false' : 'true');
1420
+ if (!hasSummaries) {
1421
+ btn.disabled = true;
1422
+ btn.title = 'No summaries available';
1423
+ } else {
1424
+ btn.disabled = false;
1425
+ btn.title = isHidden ? 'Show file summaries' : 'Hide file summaries';
1426
+ }
1427
+ }
1428
+
1429
+ /**
1430
+ * Queue a summary that arrived before its hunk anchor existed, scoped by
1431
+ * file path so a content-hash collision across files can't let one file's
1432
+ * anchor consume another file's queued summary. Summaries arriving without
1433
+ * a file path (legacy ungrouped fetch) land in the '' bucket.
1434
+ * @param {string|undefined} filePath
1435
+ * @param {Object} summary - must have `content_hash`
1436
+ */
1437
+ _queuePendingSummary(filePath, summary) {
1438
+ const key = filePath || '';
1439
+ let bucket = this._pendingSummariesByHash.get(key);
1440
+ if (!bucket) {
1441
+ bucket = new Map();
1442
+ this._pendingSummariesByHash.set(key, bucket);
1443
+ }
1444
+ bucket.set(summary.content_hash, summary);
1445
+ }
1446
+
1447
+ /**
1448
+ * Pull (and remove) a queued summary for (filePath, hash). Checks the
1449
+ * file-scoped bucket first, then the '' bucket that holds summaries queued
1450
+ * without a file path (legacy ungrouped fetch). Returns null when nothing
1451
+ * is queued for that pair.
1452
+ * @param {string|undefined} filePath
1453
+ * @param {string} hash
1454
+ * @returns {Object|null}
1455
+ */
1456
+ _takePendingSummary(filePath, hash) {
1457
+ for (const key of [filePath || '', '']) {
1458
+ const bucket = this._pendingSummariesByHash.get(key);
1459
+ if (bucket && bucket.has(hash)) {
1460
+ const summary = bucket.get(hash);
1461
+ bucket.delete(hash);
1462
+ if (bucket.size === 0) this._pendingSummariesByHash.delete(key);
1463
+ return summary;
1464
+ }
1465
+ }
1466
+ return null;
1467
+ }
1468
+
1469
+ /**
1470
+ * Resolve the anchor row for (filePath, hash) against the file-scoped
1471
+ * `_summaryAnchorsByHash` map.
1472
+ *
1473
+ * When `filePath` is known (the grouped path), the lookup is strictly
1474
+ * scoped to that file so a content-hash shared across files can't pull a
1475
+ * summary onto the wrong file's anchor. When it's absent — the legacy
1476
+ * ungrouped-fetch fallback where summaries arrive without a `file_path` —
1477
+ * there's no file to scope by, so we accept the first file whose bucket
1478
+ * carries the hash. This mirrors `_takePendingSummary`'s `''` fallback
1479
+ * bucket: file-scoped first, best-effort otherwise.
1480
+ * @param {string|undefined} filePath
1481
+ * @param {string} hash
1482
+ * @returns {HTMLTableRowElement|null}
1483
+ */
1484
+ _findSummaryAnchor(filePath, hash) {
1485
+ if (filePath) {
1486
+ return this._summaryAnchorsByHash.get(filePath)?.get(hash) || null;
1487
+ }
1488
+ for (const anchors of this._summaryAnchorsByHash.values()) {
1489
+ const anchor = anchors.get(hash);
1490
+ if (anchor) return anchor;
1491
+ }
1492
+ return null;
1493
+ }
1494
+
1495
+ /**
1496
+ * Render a single summary row, or queue it if the matching hunk hasn't
1497
+ * been hashed yet (race between WS broadcast and post-render hashing).
1498
+ * Trivial / model-skipped / model-malformed rows are ignored.
1499
+ * @param {Object} summary - { content_hash, summary_text, trivial_reason }
1500
+ * @param {string} [filePath] - File the summary belongs to. Used to scope
1501
+ * both the anchor lookup and the pending-queue key so a cross-file hash
1502
+ * collision can't misroute it to the wrong file's hunk.
1503
+ * @returns {HTMLTableRowElement|null} The mounted row, or null if queued/skipped.
1504
+ */
1505
+ _renderOneSummary(summary, filePath) {
1506
+ if (!summary || !summary.content_hash) return null;
1507
+ if (!summary.summary_text) return null; // trivial / opt-out — nothing to show
1508
+ const hash = summary.content_hash;
1509
+ const anchor = this._findSummaryAnchor(filePath, hash);
1510
+ if (!anchor || !anchor.isConnected) {
1511
+ // Anchor missing or detached (stale render) → defer; the next render
1512
+ // pass that re-establishes the hash will drain this map.
1513
+ this._queuePendingSummary(filePath, summary);
1514
+ return null;
1515
+ }
1516
+ if (!this.hunkSummaryRenderer) return null;
1517
+ return this.hunkSummaryRenderer.renderInline(anchor, summary);
1518
+ }
1519
+
1520
+ /**
1521
+ * Storage key for per-file summary visibility. Mirrors the
1522
+ * `pair-review:summaries-hidden:${reviewId}` review-level key.
1523
+ * @param {number|string} reviewId
1524
+ * @returns {string}
1525
+ */
1526
+ static summariesHiddenFilesStorageKey(reviewId) {
1527
+ return `pair-review:summaries-hidden-files:${reviewId}`;
1528
+ }
1529
+
1530
+ /**
1531
+ * Toggle the visibility of summaries for a single file. Updates the
1532
+ * `summariesHiddenFiles` set, the wrapper's CSS class, the per-file toggle
1533
+ * button's `summaries-off` class, and persists the set per-review.
1534
+ * @param {string} filePath
1535
+ * @param {HTMLElement} fileWrapper - The `.d2h-file-wrapper` element
1536
+ */
1537
+ toggleFileSummaries(filePath, fileWrapper) {
1538
+ if (!filePath || !fileWrapper) return;
1539
+ const isHidden = this.summariesHiddenFiles.has(filePath);
1540
+ if (isHidden) {
1541
+ this.summariesHiddenFiles.delete(filePath);
1542
+ } else {
1543
+ this.summariesHiddenFiles.add(filePath);
1544
+ }
1545
+ fileWrapper.classList.toggle('summaries-hidden-file', !isHidden);
1546
+ const btn = fileWrapper.querySelector('.file-header-summary-toggle');
1547
+ if (btn) this._syncFileSummaryToggleButton(btn, filePath);
1548
+ if (this.currentPR?.id != null) {
1549
+ try {
1550
+ window.localStorage.setItem(
1551
+ PRManager.summariesHiddenFilesStorageKey(this.currentPR.id),
1552
+ JSON.stringify([...this.summariesHiddenFiles])
1553
+ );
1554
+ } catch {
1555
+ // localStorage unavailable; in-session state still applies.
1556
+ }
1557
+ }
1558
+ }
1559
+
1560
+ /**
1561
+ * Hydrate `summariesHiddenFiles` from localStorage for the current review.
1562
+ * Safe to call multiple times — the state always reflects what's in storage.
1563
+ */
1564
+ _restoreSummariesHiddenFiles() {
1565
+ if (!this.currentPR?.id) return;
1566
+ try {
1567
+ const raw = window.localStorage.getItem(
1568
+ PRManager.summariesHiddenFilesStorageKey(this.currentPR.id)
1569
+ );
1570
+ if (!raw) {
1571
+ this.summariesHiddenFiles = new Set();
1572
+ return;
1573
+ }
1574
+ const arr = JSON.parse(raw);
1575
+ this.summariesHiddenFiles = new Set(Array.isArray(arr) ? arr : []);
1576
+ } catch {
1577
+ this.summariesHiddenFiles = new Set();
1578
+ }
1579
+ }
1580
+
1581
+ /**
1582
+ * Toggle review-level summary visibility. Persists per-review.
1583
+ */
1584
+ toggleSummariesVisibility() {
1585
+ this._summariesHidden = !this._summariesHidden;
1586
+ document.body.classList.toggle('summaries-hidden', this._summariesHidden);
1587
+ if (this.currentPR?.id != null) {
1588
+ try {
1589
+ window.localStorage.setItem(
1590
+ `pair-review:summaries-hidden:${this.currentPR.id}`,
1591
+ this._summariesHidden ? '1' : '0'
1592
+ );
1593
+ } catch {
1594
+ // localStorage unavailable; in-session state still applies.
1595
+ }
1596
+ }
1597
+ this._syncSummaryToolbarButton();
1598
+ }
1599
+
1600
+ /**
1601
+ * Reflect the current state (visible / hidden / generating) on the
1602
+ * toolbar toggle button. The button gets:
1603
+ * - `.active` when summaries are visible
1604
+ * - `.generating` when a background summary job is in flight
1605
+ * - `title` + `aria-label` + `data-label` (CSS hover fallback) all kept
1606
+ * in sync so the user always knows what the button does.
1607
+ */
1608
+ _syncSummaryToolbarButton() {
1609
+ const btn = document.getElementById('summary-toggle-btn');
1610
+ if (!btn) return;
1611
+ // `.active` (blue) only once summaries actually exist AND are visible.
1612
+ // Before any generation the button stays colorless so the pre-generated
1613
+ // state is visually distinct from "generated but hidden".
1614
+ btn.classList.toggle('active', this._summariesGenerated && !this._summariesHidden);
1615
+ btn.classList.toggle('generating', this._summariesGenerating === true);
1616
+
1617
+ let label;
1618
+ if (this._summariesGenerating) {
1619
+ // Hint at the cancel affordance — clicking the pulsing button now
1620
+ // opens a confirm dialog ("Cancel Summaries" / "OK") instead of
1621
+ // toggling visibility. See _handleSummaryToggleClick.
1622
+ label = 'Generating summaries… (click to cancel)';
1623
+ } else if (!this._summariesAvailable) {
1624
+ // Pre-generated state: nothing generated yet (mounted or queued).
1625
+ // Colorless button; a click kicks off generation. Gated on
1626
+ // `_summariesAvailable` (not `_summariesGenerated`) so a review whose
1627
+ // summaries exist but haven't mounted shows Show/Hide, matching the
1628
+ // click behavior in _handleSummaryToggleClick.
1629
+ label = 'Generate hunk summaries';
1630
+ } else {
1631
+ label = this._summariesHidden ? 'Show hunk summaries' : 'Hide hunk summaries';
1632
+ }
1633
+ btn.title = label;
1634
+ btn.setAttribute('aria-label', label);
1635
+ btn.dataset.label = label;
1636
+ btn.setAttribute(
1637
+ 'aria-pressed',
1638
+ (this._summariesGenerated && !this._summariesHidden) ? 'true' : 'false'
1639
+ );
1640
+ }
1641
+
1642
+ // ===== Tour (Phase 8) ===================================================
1643
+
1644
+ /**
1645
+ * Whether a tour is currently mounted in the UI.
1646
+ * @returns {boolean}
1647
+ */
1648
+ _tourIsActive() {
1649
+ return this._tourActiveIndex >= 0 && !!this._tourRenderer;
1650
+ }
1651
+
1652
+ /**
1653
+ * Reflect tour state on the toolbar toggle button. Mirrors the structure
1654
+ * of `_syncSummaryToolbarButton` so future tweaks stay in lockstep.
1655
+ */
1656
+ _syncTourToolbarButton() {
1657
+ const btn = document.getElementById('tour-toggle-btn');
1658
+ if (!btn) return;
1659
+ const active = this._tourIsActive();
1660
+ const hasPending = active && Array.isArray(this._tourStopsPendingRestart);
1661
+ btn.classList.toggle('active', active);
1662
+ btn.classList.toggle('generating', this._tourGenerating === true);
1663
+ btn.classList.toggle('tour-updated-pending', hasPending);
1664
+
1665
+ let label;
1666
+ if (this._tourGenerating) {
1667
+ // Hint at the cancel affordance — see _handleTourToggleClick.
1668
+ label = active
1669
+ ? 'Generating tour… (click to cancel)'
1670
+ : 'Generating guided tour… (click to cancel)';
1671
+ } else if (hasPending) {
1672
+ label = 'Tour updated — restart to apply new stops';
1673
+ } else if (active) {
1674
+ label = 'Exit guided tour';
1675
+ } else if (this._tourStops && this._tourStops.length > 0) {
1676
+ label = 'Start guided tour';
1677
+ } else {
1678
+ label = 'Start guided tour (none available yet)';
1679
+ }
1680
+ btn.title = label;
1681
+ btn.setAttribute('aria-label', label);
1682
+ btn.dataset.label = label;
1683
+ btn.setAttribute('aria-pressed', active ? 'true' : 'false');
1684
+ }
1685
+
1686
+ // ===== Cancel flow (shared) =============================================
1687
+
1688
+ /**
1689
+ * Toolbar click handler for the summaries toggle. If a summary job is
1690
+ * in flight (`.generating` pulse), intercept and open the cancel-confirm
1691
+ * dialog instead of toggling visibility. Toggle visibility otherwise.
1692
+ *
1693
+ * Kept thin so `addEventListener` callers don't need to know about the
1694
+ * cancel flow — that lives in `_promptCancelJob`.
1695
+ * @returns {void}
1696
+ */
1697
+ async _handleSummaryToggleClick() {
1698
+ if (this._summariesGenerating) {
1699
+ await this._promptCancelJob({
1700
+ kind: 'summaries',
1701
+ onCleared: () => {
1702
+ this._summariesGenerating = false;
1703
+ this._syncSummaryToolbarButton();
1704
+ },
1705
+ });
1706
+ return;
1707
+ }
1708
+ if (!this._summariesAvailable) {
1709
+ // Nothing generated yet (mounted OR queued): a click triggers generation
1710
+ // rather than toggling visibility. `_startGenerationJob` sets the pulsing
1711
+ // `.generating` state optimistically (there is no
1712
+ // `review:background_job_started` event). We gate on `_summariesAvailable`
1713
+ // rather than `_summariesGenerated` so summaries that exist server-side
1714
+ // but haven't mounted (their lazy file body isn't rendered yet) toggle
1715
+ // visibility instead of kicking off a duplicate generation job.
1716
+ await this._startGenerationJob('summary');
1717
+ return;
1718
+ }
1719
+ this.toggleSummariesVisibility();
1720
+ }
1721
+
1722
+ /**
1723
+ * Toolbar click handler for the tour toggle. If a tour job is in flight
1724
+ * (`.generating` pulse), intercept and open the cancel-confirm dialog
1725
+ * instead of opening/exiting the tour. Defer to `startOrToggleTour`
1726
+ * otherwise.
1727
+ * @returns {Promise<void>}
1728
+ */
1729
+ async _handleTourToggleClick() {
1730
+ if (this._tourGenerating) {
1731
+ await this._promptCancelJob({
1732
+ kind: 'tour',
1733
+ onCleared: () => {
1734
+ this._tourGenerating = false;
1735
+ this._syncTourToolbarButton();
1736
+ },
1737
+ });
1738
+ return;
1739
+ }
1740
+ await this.startOrToggleTour();
1741
+ }
1742
+
1743
+ /**
1744
+ * Shared cancel-flow entrypoint: opens the right confirm dialog for the
1745
+ * given job kind, POSTs the cancel on confirm, and runs `onCleared` so
1746
+ * the caller can reset the pulse state. The corresponding broadcast
1747
+ * (`review:background_job_finished` with `cancelled: true`) will arrive
1748
+ * shortly after; that handler also clears the flag, so a double-clear
1749
+ * is harmless.
1750
+ *
1751
+ * @param {Object} opts
1752
+ * @param {'tour'|'summaries'} opts.kind
1753
+ * @param {Function} opts.onCleared - Called after the user confirms.
1754
+ * @returns {Promise<void>}
1755
+ */
1756
+ async _promptCancelJob({ kind, onCleared }) {
1757
+ // Re-entry guard: the pulsing toolbar button stays clickable while the
1758
+ // confirm dialog is up. ConfirmDialog is a singleton — a second call to
1759
+ // .show() overwrites the first invocation's callbacks and orphans its
1760
+ // Promise. Drop the second click instead.
1761
+ if (this._cancelPromptOpen) return;
1762
+ const helper = typeof window !== 'undefined' ? window.CancelBackgroundJob : null;
1763
+ if (!helper) return;
1764
+ const reviewId = this.currentPR && this.currentPR.id;
1765
+ if (!reviewId) return;
1766
+ const show = kind === 'tour'
1767
+ ? helper.showCancelTourDialog
1768
+ : helper.showCancelSummariesDialog;
1769
+ this._cancelPromptOpen = true;
1770
+ try {
1771
+ await show({ reviewId, onCancelled: onCleared });
1772
+ } finally {
1773
+ this._cancelPromptOpen = false;
1774
+ }
1775
+ }
1776
+
1777
+ /**
1778
+ * Manually trigger a summary or tour generation job for the current review.
1779
+ * Used when `auto_generate` is off so generation does not kick off on load;
1780
+ * the user clicks the toolbar button to start it.
1781
+ *
1782
+ * Mode-aware: PR reviews POST to `/api/pr/...`, local reviews to
1783
+ * `/api/local/...`. The server enqueues the job with `trigger: 'manual'`
1784
+ * (bypassing the `auto_generate` gate) and responds with `{ started }` /
1785
+ * `{ alreadyRunning }`. There is no `review:background_job_started`
1786
+ * broadcast, so this method optimistically sets the matching `*Generating`
1787
+ * flag and pulses the button itself; `review:background_job_finished`
1788
+ * clears the flag when the job ends.
1789
+ *
1790
+ * @param {'summary'|'tour'} jobKey
1791
+ * @returns {Promise<void>}
1792
+ */
1793
+ async _startGenerationJob(jobKey) {
1794
+ const pr = this.currentPR;
1795
+ if (!pr || pr.id == null) return;
1796
+ const isLocal = pr.reviewType === 'local'
1797
+ || (typeof window !== 'undefined' && window.PAIR_REVIEW_LOCAL_MODE === true);
1798
+ const url = isLocal
1799
+ ? `/api/local/${encodeURIComponent(pr.id)}/jobs/${encodeURIComponent(jobKey)}/start`
1800
+ : `/api/pr/${encodeURIComponent(pr.owner)}/${encodeURIComponent(pr.repo)}/${encodeURIComponent(pr.number)}/jobs/${encodeURIComponent(jobKey)}/start`;
1801
+ // Phrasing varies by job kind. `featureLabel` goes into the HTTP/decline
1802
+ // error messages; `noDiffMessage` is the dedicated "nothing to do" line.
1803
+ // NOTE: the toast singleton is lowercase `window.toast` (see
1804
+ // cancel-background-job.js); `window.Toast` does not exist.
1805
+ const featureLabel = jobKey === 'tour' ? 'tour' : 'summary';
1806
+ const noDiffMessage = jobKey === 'tour' ? 'No tour to generate.' : 'No summaries to generate.';
1807
+ try {
1808
+ const resp = await fetch(url, { method: 'POST' });
1809
+ if (resp.status === 409) {
1810
+ // Feature disabled in config — shouldn't happen (the button is hidden
1811
+ // when disabled) but surface it rather than failing silently.
1812
+ window.toast?.showError?.('This feature is disabled in config.');
1813
+ return;
1814
+ }
1815
+ if (!resp.ok) {
1816
+ console.warn(`[StartJob] ${jobKey} start POST failed: ${resp.status}`);
1817
+ window.toast?.showError?.(`Failed to start ${featureLabel} generation (HTTP ${resp.status}).`);
1818
+ return;
1819
+ }
1820
+ // Optimistic UI: there is no `review:background_job_started` broadcast,
1821
+ // so set the generating flag now — when the server enqueued a job
1822
+ // (`started`) or one was already running (`alreadyRunning`) — to start
1823
+ // the pulse immediately. Results arrive via `review:hunk_summaries_ready`
1824
+ // / `review:tour_ready`; `review:background_job_finished` clears the flag
1825
+ // when the job ends.
1826
+ const payload = await resp.json().catch(() => ({}));
1827
+ if (payload.started || payload.alreadyRunning) {
1828
+ if (jobKey === 'summary') {
1829
+ this._summariesGenerating = true;
1830
+ this._syncSummaryToolbarButton();
1831
+ } else if (jobKey === 'tour') {
1832
+ this._tourGenerating = true;
1833
+ this._syncTourToolbarButton();
1834
+ }
1835
+ return;
1836
+ }
1837
+ // Server accepted the request but declined to enqueue. The known
1838
+ // reason today is `'no-diff'` — review has no changes to act on. Tell
1839
+ // the user so the button doesn't appear inert. Unknown decline
1840
+ // reasons fall through to a generic message rather than silence.
1841
+ if (payload.reason === 'no-diff') {
1842
+ window.toast?.showInfo?.(noDiffMessage);
1843
+ } else {
1844
+ window.toast?.showError?.(`Could not start ${featureLabel} generation.`);
1845
+ }
1846
+ } catch (err) {
1847
+ console.warn(`[StartJob] ${jobKey} start POST error:`, err.message);
1848
+ window.toast?.showError?.(`Failed to start ${featureLabel} generation: ${err.message}`);
1849
+ }
1850
+ }
1851
+
1852
+ /**
1853
+ * Fetch /api/reviews/:reviewId/tour and stash the result in `_tourStops`
1854
+ * / `_tourGenerating`. Does NOT open the tour.
1855
+ *
1856
+ * If `deferIfActive` is true and a tour is currently mounted, the fetched
1857
+ * stops are stashed on `_tourStopsPendingRestart` instead of replacing
1858
+ * the active tour's stops. The pending stops apply on the next exit or
1859
+ * restart. This is the v1 simple approach — replacing the running tour
1860
+ * mid-flight is doable but adds complexity (mounted refs keyed by old
1861
+ * indices, current-stop drift, etc.) without a clear UX win.
1862
+ *
1863
+ * @param {Object} [opts]
1864
+ * @param {boolean} [opts.deferIfActive=false]
1865
+ * @param {boolean} [opts.cancelOnRender=true] - When true (default),
1866
+ * the probe captures `_renderGen` and aborts before mutating state
1867
+ * if a later render bumps the generation. Render-triggered probes
1868
+ * want this so a stale fetch can't clobber a fresh reset. One-shot
1869
+ * recovery callers (e.g. the deferred config-probe) pass `false`
1870
+ * so they don't self-cancel.
1871
+ * @returns {Promise<Array<Object>|null>} resolved stops, or null on miss.
1872
+ */
1873
+ async _loadAndStashTour({ deferIfActive = false, cancelOnRender = true } = {}) {
1874
+ if (!this.currentPR?.id) return null;
1875
+ if (this._toursEnabled === false) return null;
1876
+ // Capture the current render generation; if a later renderDiff bumps
1877
+ // _renderGen between our awaits, bail before mutating state. Only
1878
+ // applied when cancelOnRender is true — the deferred config probe
1879
+ // and other one-shot recovery callers pass `cancelOnRender: false`.
1880
+ const gen = this._renderGen;
1881
+ const guardStale = () => cancelOnRender && gen !== this._renderGen;
1882
+ try {
1883
+ const resp = await fetch(`/api/reviews/${this.currentPR.id}/tour`);
1884
+ if (guardStale()) return null;
1885
+ if (!resp.ok) return null;
1886
+ const data = await resp.json();
1887
+ if (guardStale()) return null;
1888
+ this._tourGenerating = data.generating === true;
1889
+ const stops = Array.isArray(data.tour?.stops) ? data.tour.stops : null;
1890
+ if (deferIfActive && this._tourIsActive()) {
1891
+ this._tourStopsPendingRestart = stops;
1892
+ } else {
1893
+ this._tourStops = stops;
1894
+ this._tourStopsPendingRestart = null;
1895
+ }
1896
+ this._syncTourToolbarButton();
1897
+ return stops;
1898
+ } catch (err) {
1899
+ console.warn('[Tour] failed to load tour:', err);
1900
+ return null;
1901
+ }
1902
+ }
1903
+
1904
+ /**
1905
+ * Toolbar click entrypoint. If a tour is active, exit. Otherwise fetch
1906
+ * stops if needed, then open from the first stop. No-ops when no stops
1907
+ * exist (toolbar button stays inert with the "none available yet" label).
1908
+ * @returns {Promise<void>}
1909
+ */
1910
+ async startOrToggleTour() {
1911
+ if (this._tourIsActive()) {
1912
+ this._exitTour();
1913
+ return;
1914
+ }
1915
+ if (!this._tourStops || this._tourStops.length === 0) {
1916
+ await this._loadAndStashTour();
1917
+ }
1918
+ if (!this._tourStops || this._tourStops.length === 0) {
1919
+ // No tour stops available. When auto-generation is off and nothing is
1920
+ // already in flight, a click triggers manual generation (mirrors the
1921
+ // summaries button). `_startGenerationJob` sets the pulsing state
1922
+ // optimistically (there is no `review:background_job_started` event).
1923
+ // When `review:tour_ready` arrives the stops load and the button becomes
1924
+ // "Start guided tour" — the user clicks again to open it (no auto-open).
1925
+ if (this._toursAutoGenerate === false && !this._tourGenerating) {
1926
+ await this._startGenerationJob('tour');
1927
+ }
1928
+ return;
1929
+ }
1930
+ await this._openTourAtStart();
1931
+ }
1932
+
1933
+ /**
1934
+ * Open the tour UI starting at stop 0. Lazy-creates the TourBar and
1935
+ * TourRenderer on first call so we pay zero cost for users who never
1936
+ * trigger a tour.
1937
+ */
1938
+ async _openTourAtStart() {
1939
+ if (!this._tourStops || this._tourStops.length === 0) return;
1940
+
1941
+ // Drain any pending teardown from the previous tour BEFORE we read
1942
+ // wrappers below. Otherwise a fire-and-forget DELETE + its
1943
+ // loadContextFiles reload landing mid-open can rip the wrapper the
1944
+ // first stop is about to mount against. allSettled-wrapped so it
1945
+ // never rejects.
1946
+ if (this._tourCleanupPending) {
1947
+ const pending = this._tourCleanupPending;
1948
+ this._tourCleanupPending = null;
1949
+ await pending;
1950
+ }
1951
+
1952
+ if (!this._tourRenderer && typeof window !== 'undefined' && window.TourRenderer) {
1953
+ this._tourRenderer = new window.TourRenderer(this);
1954
+ }
1955
+ if (!this._tourBar && typeof window !== 'undefined' && window.TourBar) {
1956
+ this._tourBar = new window.TourBar({
1957
+ onPrev: () => this._advanceTour(-1),
1958
+ onNext: () => this._advanceTour(1),
1959
+ onExit: () => this._exitTour(),
1960
+ onRestart: () => this._restartTour(),
1961
+ });
1962
+ }
1963
+ if (!this._tourRenderer || !this._tourBar) {
1964
+ console.warn('[Tour] TourRenderer/TourBar not available; cannot open tour');
1965
+ return;
1966
+ }
1967
+
1968
+ this._tourRenderer.setStops(this._tourStops);
1969
+ this._tourRenderer.setActive(true);
1970
+ // Mount inside the diff-view scroll container so the bar (position:
1971
+ // sticky) spans only the diff width — the file-tree sidebar and its
1972
+ // controls stay visible.
1973
+ const diffView = document.querySelector('.main-layout .diff-view');
1974
+ this._tourBar.mount(diffView || undefined);
1975
+ this._tourBar.setStops(this._tourStops);
1976
+ this._tourBar.setCompleted(false);
1977
+
1978
+ this._tourActiveIndex = -1;
1979
+ // Bump the generation BEFORE the first _advanceTour call so it sees
1980
+ // the fresh value as its baseline. Subsequent exits bump it again,
1981
+ // making in-flight probes from this open detect the mismatch and bail.
1982
+ this._tourGen += 1;
1983
+ this._registerTourKeyboardHandlers();
1984
+ this._advanceTour(1);
1985
+ this._syncTourToolbarButton();
1986
+ }
1987
+
1988
+ /**
1989
+ * Advance (or rewind) the active stop by `delta`. Going past the end of
1990
+ * the tour flips the bar into completion state; going before the start
1991
+ * clamps at 0.
1992
+ *
1993
+ * Async because each probe candidate is run through
1994
+ * `TourRenderer.prepareStop` first, which may need to await a file
1995
+ * fetch (adding a non-diff file as a context file) and/or a gap-expand
1996
+ * to surface folded rows the stop anchors on. Re-entrant calls (rapid
1997
+ * Next presses, keyboard mashing) are dropped via `_advanceInFlight`
1998
+ * so we never have two probe loops mutating tour state concurrently.
1999
+ *
2000
+ * @param {number} delta - Typically +1 (next) or -1 (prev).
2001
+ * @returns {Promise<void>}
2002
+ */
2003
+ async _advanceTour(delta) {
2004
+ if (!this._tourRenderer || !this._tourBar || !this._tourStops) return;
2005
+ const total = this._tourStops.length;
2006
+ if (total === 0) return;
2007
+
2008
+ // Drop overlapping nav requests. The keyboard / button callbacks all
2009
+ // fire-and-forget, so a fast Next-Next-Next while a file fetch is in
2010
+ // flight would otherwise interleave probe loops on shared mutable
2011
+ // state (`_tourActiveIndex`, the renderer's `_mounted` map).
2012
+ //
2013
+ // Latch is generation-scoped: an in-flight call from a torn-down
2014
+ // generation no longer matches `_tourGen`, so a fresh reopen passes
2015
+ // the check without any teardown path having to remember to clear
2016
+ // the slot. Fixes the exit-then-reopen wedge where the boolean
2017
+ // latch survived `_exitTour` and silently dropped the next open's
2018
+ // first `_advanceTour`.
2019
+ if (this._advanceInFlightGen === this._tourGen) return;
2020
+ this._advanceInFlightGen = this._tourGen;
2021
+ // Capture the open-generation so we can detect a teardown (exit /
2022
+ // reopen) that happened while we were sitting on an await below.
2023
+ const startGen = this._tourGen;
2024
+ const isStale = () => this._tourGen !== startGen;
2025
+ try {
2026
+ const startIndex = this._tourActiveIndex + delta;
2027
+ const dir = delta >= 0 ? 1 : -1;
2028
+
2029
+ // Forward past the end (initial open uses delta=1 from -1, so this only
2030
+ // fires once we've actually reached the last stop and pressed Next again).
2031
+ if (startIndex >= total) {
2032
+ this._tourBar.setCompleted(true);
2033
+ this._tourBar.setActiveIndex(total - 1);
2034
+ this._syncTourToolbarButton();
2035
+ return;
2036
+ }
2037
+
2038
+ // Probe-then-mount: locate the next mountable index WITHOUT unmounting
2039
+ // the current one. Only swap once we have a confirmed replacement. This
2040
+ // avoids the wedge where the current stop is torn down and no successor
2041
+ // mounts (file filtered out, scope change, etc.).
2042
+ let probe = Math.max(0, startIndex);
2043
+ let nextRow = null;
2044
+ let nextIndex = -1;
2045
+ while (probe >= 0 && probe < total) {
2046
+ // Skip re-probing the index that's already mounted — `mountStop` is
2047
+ // idempotent and returns the existing row, but we want to keep going
2048
+ // past the current active when delta moves us off it.
2049
+ if (probe !== this._tourActiveIndex) {
2050
+ // Prepare the stop first: add the file as a context file if it
2051
+ // isn't in the diff, and unfold any gap covering its line range.
2052
+ // prepareStop returning true is no guarantee mountStop will
2053
+ // succeed — genuinely missing data still falls through.
2054
+ await this._tourRenderer.prepareStop(probe);
2055
+ // Tour could have been exited (or re-opened) while prepareStop
2056
+ // was awaiting a file fetch / loadContextFiles. Bail before
2057
+ // mounting against a torn-down tour.
2058
+ if (isStale()) return;
2059
+ // mountStop is async (it awaits toggleFileCollapse so a collapsed
2060
+ // file is rendered + visibly expanded before we scroll to it).
2061
+ const row = await this._tourRenderer.mountStop(probe);
2062
+ // Re-check staleness: the await above is a suspension window.
2063
+ if (isStale()) return;
2064
+ if (row) {
2065
+ nextRow = row;
2066
+ nextIndex = probe;
2067
+ break;
2068
+ }
2069
+ } else if (dir > 0) {
2070
+ // Already-active probe under forward motion shouldn't count as a hit;
2071
+ // we want to advance past it.
2072
+ } else {
2073
+ // Backward delta landing on the current stop: nothing earlier mounted.
2074
+ break;
2075
+ }
2076
+ probe += dir;
2077
+ }
2078
+
2079
+ if (!nextRow) {
2080
+ if (dir > 0) {
2081
+ // Forward exhaustion: flip to completion using the last successfully
2082
+ // mounted index. If we never mounted anything (initial open found no
2083
+ // mountable stops), bail out cleanly so the toolbar resets.
2084
+ if (this._tourActiveIndex < 0) {
2085
+ console.warn('[Tour] no mountable stops found; exiting');
2086
+ this._exitTour();
2087
+ return;
2088
+ }
2089
+ this._tourBar.setCompleted(true);
2090
+ this._tourBar.setActiveIndex(this._tourActiveIndex);
2091
+ this._syncTourToolbarButton();
2092
+ return;
2093
+ }
2094
+ // Backward exhaustion: leave the current stop mounted/active untouched.
2095
+ console.debug('[Tour] no earlier mountable stop; staying put');
2096
+ return;
2097
+ }
2098
+
2099
+ // Successful candidate — only now unmount the previous stop.
2100
+ if (this._tourActiveIndex >= 0 && this._tourActiveIndex !== nextIndex) {
2101
+ this._tourRenderer.unmountStop(this._tourActiveIndex);
2102
+ }
2103
+
2104
+ this._tourActiveIndex = nextIndex;
2105
+ this._tourRenderer.highlightActive(nextIndex);
2106
+ this._tourRenderer.scrollToStop(nextIndex);
2107
+ this._tourBar.setCompleted(false);
2108
+ this._tourBar.setActiveIndex(nextIndex);
2109
+ this._syncTourToolbarButton();
2110
+ // Suppress unused-var lints; nextRow exists for symmetry with future
2111
+ // post-mount work (focus management, telemetry).
2112
+ void nextRow;
2113
+ } finally {
2114
+ // Only release the latch if we still own the slot. A teardown that
2115
+ // bumped `_tourGen` between entry and now has already invalidated
2116
+ // our holder — and a fresh generation may have taken the slot for
2117
+ // its own call. Clobbering it with `-1` would let two _advanceTour
2118
+ // calls run concurrently on the new generation.
2119
+ if (this._advanceInFlightGen === startGen) {
2120
+ this._advanceInFlightGen = -1;
2121
+ }
2122
+ }
2123
+ }
2124
+
2125
+ /**
2126
+ * Tear down the tour: unmount every annotation, unmount the bar, drop the
2127
+ * body class, and unregister keyboard handlers.
2128
+ */
2129
+ _exitTour() {
2130
+ // Bump generation FIRST so any in-flight `_advanceTour` (sitting on an
2131
+ // ensureContextFile / ensureLinesVisible await) sees the mismatch on
2132
+ // resume and bails instead of mutating state for a torn-down tour.
2133
+ this._tourGen += 1;
2134
+ let drain = Promise.resolve();
2135
+ if (this._tourRenderer) {
2136
+ // unmountAll fires-and-forgets context-file DELETEs but returns a
2137
+ // drain promise. Stash it on `_tourCleanupPending` so the next open
2138
+ // can await it before reading wrappers — otherwise the DELETE's
2139
+ // loadContextFiles reload can rip the new tour's wrapper out from
2140
+ // under an active stop.
2141
+ drain = this._tourRenderer.unmountAll();
2142
+ this._tourRenderer.setActive(false);
2143
+ }
2144
+ this._tourCleanupPending = drain;
2145
+ if (this._tourBar) {
2146
+ this._tourBar.unmount();
2147
+ }
2148
+ this._unregisterTourKeyboardHandlers();
2149
+ this._tourActiveIndex = -1;
2150
+ // Consume any pending tour stashed by `review:tour_ready` while we
2151
+ // were running. Next open uses the fresh stops.
2152
+ if (Array.isArray(this._tourStopsPendingRestart)) {
2153
+ this._tourStops = this._tourStopsPendingRestart;
2154
+ this._tourStopsPendingRestart = null;
2155
+ }
2156
+ this._syncTourToolbarButton();
2157
+ }
2158
+
2159
+ /**
2160
+ * Exit, then re-open from stop 0. Async because `_openTourAtStart`
2161
+ * drains the prior tour's pending teardown (context-file DELETEs +
2162
+ * their loadContextFiles reloads) before reading wrappers, so the
2163
+ * fresh tour can't mount against a wrapper the old DELETE is about to
2164
+ * tear down. If a newer tour was stashed via `review:tour_ready`,
2165
+ * `_exitTour` swaps it in before we reopen.
2166
+ *
2167
+ * Caller (`onRestart` toolbar callback) is fire-and-forget; the
2168
+ * returned promise is for symmetry / testability.
2169
+ *
2170
+ * @returns {Promise<void>}
2171
+ */
2172
+ async _restartTour() {
2173
+ this._exitTour();
2174
+ await this._openTourAtStart();
2175
+ }
2176
+
2177
+ /**
2178
+ * Install the keyboard shortcut handler. Bound to `document` so it fires
2179
+ * regardless of focus, with a guard that skips when the user is typing
2180
+ * in a text field.
2181
+ */
2182
+ _registerTourKeyboardHandlers() {
2183
+ if (this._tourKeydownHandler) return;
2184
+ const handler = (e) => {
2185
+ if (!this._tourIsActive()) return;
2186
+
2187
+ // Skip when the user is typing — text fields, contenteditable, etc.
2188
+ // Arrow keys move the caret; Escape is owned by the surrounding form.
2189
+ const target = e.target;
2190
+ const tag = target && target.tagName;
2191
+ const isEditable = tag === 'TEXTAREA' || tag === 'INPUT' || tag === 'SELECT' ||
2192
+ (target && (target.isContentEditable || target.contentEditable === 'true'));
2193
+ if (isEditable) return;
2194
+
2195
+ // Skip when a modal is open. Modals own their own Escape ladder
2196
+ // (close dropdown, blur, dismiss); we don't want to compete. Defer
2197
+ // to the shared ModalDetection utility so the selector list stays
2198
+ // in sync with KeyboardShortcuts.
2199
+ if (window.ModalDetection?.isModalOpen()) return;
2200
+
2201
+ // Skip ALL tour shortcuts when the chat panel is open. ChatPanel
2202
+ // binds its own document-level Escape handler with a ladder of
2203
+ // states (provider/session dropdown, streaming stop, blur input,
2204
+ // close panel). Arrow keys may also be in use by chat surfaces.
2205
+ // Yanking the tour out from under the user — by advancing OR
2206
+ // exiting — when they have the chat panel open would be surprising.
2207
+ const chatPanel = document.querySelector('.chat-panel.chat-panel--open');
2208
+ if (chatPanel) return;
2209
+
2210
+ if (e.key === 'ArrowRight') {
2211
+ e.preventDefault();
2212
+ this._advanceTour(1);
2213
+ } else if (e.key === 'ArrowLeft') {
2214
+ e.preventDefault();
2215
+ this._advanceTour(-1);
2216
+ } else if (e.key === 'Escape') {
2217
+ e.preventDefault();
2218
+ // Stop propagation so other Escape-bound listeners (chat panel
2219
+ // when it's closed-but-bound, future keyboard shortcuts) don't
2220
+ // also fire for the same key event.
2221
+ e.stopImmediatePropagation();
2222
+ this._exitTour();
2223
+ }
2224
+ };
2225
+ document.addEventListener('keydown', handler);
2226
+ this._tourKeydownHandler = handler;
2227
+ }
2228
+
2229
+ _unregisterTourKeyboardHandlers() {
2230
+ if (!this._tourKeydownHandler) return;
2231
+ document.removeEventListener('keydown', this._tourKeydownHandler);
2232
+ this._tourKeydownHandler = null;
2233
+ }
2234
+
906
2235
  /**
907
2236
  * Listen for review-scoped CustomEvents dispatched by ChatPanel's
908
2237
  * WebSocket pub/sub connection.
@@ -984,6 +2313,50 @@ class PRManager {
984
2313
  await this.ensureLinesVisible([{ file, line_start, line_end, side: side || 'right' }]);
985
2314
  });
986
2315
 
2316
+ document.addEventListener('review:hunk_summaries_ready', (e) => {
2317
+ if (e.detail?.reviewId !== reviewId()) return;
2318
+ // Per-file completion implies the review-level job is still working,
2319
+ // so reflect "generating" until `background_job_finished` clears it.
2320
+ // (No-op when the toolbar button is already pulsing.)
2321
+ if (!this._summariesGenerating) {
2322
+ this._summariesGenerating = true;
2323
+ this._syncSummaryToolbarButton();
2324
+ }
2325
+ this._applyHunkSummaries(e.detail.filePath, e.detail.summaries || []);
2326
+ });
2327
+
2328
+ // Tour-ready broadcasts arrive after the tour-generation job persists a
2329
+ // new tour. We refresh the cached stops in the background but do NOT
2330
+ // auto-open the tour — user must click the toolbar button. If a tour is
2331
+ // already mounted, the new stops are stashed for restart so we don't
2332
+ // yank the active tour out from under the user (v1 simple approach).
2333
+ document.addEventListener('review:tour_ready', (e) => {
2334
+ if (e.detail?.reviewId !== reviewId()) return;
2335
+ this._loadAndStashTour({ deferIfActive: true }).catch(() => {});
2336
+ });
2337
+
2338
+ document.addEventListener('review:background_job_finished', (e) => {
2339
+ if (e.detail?.reviewId !== reviewId()) return;
2340
+ const jobType = e.detail?.jobType || '';
2341
+ const isSummaries = jobType === 'summaries' || jobType.startsWith('summaries:');
2342
+ const isTour = jobType === 'tour' || jobType.startsWith('tour:');
2343
+ if (!isSummaries && !isTour) return;
2344
+ // The queue can host multiple `${type}:${digest}` jobs back-to-back
2345
+ // (refresh, scope change, whitespace toggle). The broadcast payload
2346
+ // carries `hasActiveForType` from the queue's view AFTER this job's
2347
+ // key was deleted, so a sibling job still in flight keeps the pulse
2348
+ // visible.
2349
+ if (e.detail?.hasActiveForType === true) return;
2350
+ if (isSummaries) {
2351
+ this._summariesGenerating = false;
2352
+ this._syncSummaryToolbarButton();
2353
+ }
2354
+ if (isTour) {
2355
+ this._tourGenerating = false;
2356
+ this._syncTourToolbarButton();
2357
+ }
2358
+ });
2359
+
987
2360
  document.addEventListener('visibilitychange', () => {
988
2361
  if (document.hidden) return;
989
2362
  if (this._dirtyComments) { this._dirtyComments = false; this.loadUserComments(); }
@@ -1288,13 +2661,32 @@ class PRManager {
1288
2661
 
1289
2662
  // Update Graphite link (gated on enable_graphite config)
1290
2663
  const graphiteLink = document.getElementById('graphite-link');
1291
- if (graphiteLink && pr.html_url && window.__pairReview?.enableGraphite) {
2664
+ if (graphiteLink && pr.html_url && window.__pairReview?.enableGraphite
2665
+ && graphiteLink.dataset.suppressed !== 'true') {
1292
2666
  // Derive from html_url to preserve GitHub's original casing (Graphite URLs are case-sensitive)
1293
2667
  const graphiteUrl = window.__pairReview.toGraphiteUrl(pr.html_url);
1294
2668
  graphiteLink.href = graphiteUrl;
1295
2669
  graphiteLink.style.display = '';
1296
2670
  }
1297
2671
 
2672
+ if (window.RepoLinks && pr.owner && pr.repo) {
2673
+ const linksApplied = window.RepoLinks.fetchAndApplyRepoLinks(pr.owner, pr.repo, {
2674
+ owner: pr.owner,
2675
+ repo: pr.repo,
2676
+ number: pr.number,
2677
+ branch: pr.head_branch,
2678
+ base_branch: pr.base_branch,
2679
+ head_sha: pr.head_sha,
2680
+ });
2681
+ // The repo links (incl. the configured host name) resolve asynchronously
2682
+ // after this synchronous render. Re-render the pending-draft indicator
2683
+ // once they land so it shows the configured host name (e.g. "Meteorite")
2684
+ // instead of the "GitHub" fallback it would otherwise bake in below.
2685
+ if (linksApplied && typeof linksApplied.then === 'function') {
2686
+ linksApplied.then(() => this.updatePendingDraftIndicator(pr.pendingDraft));
2687
+ }
2688
+ }
2689
+
1298
2690
  // Update settings link
1299
2691
  const settingsLink = document.getElementById('settings-link');
1300
2692
  if (settingsLink && pr.owner && pr.repo) {
@@ -1595,13 +2987,20 @@ class PRManager {
1595
2987
  }
1596
2988
 
1597
2989
  // Fetch settings in parallel
1598
- const [repoSettings, reviewSettings] = await Promise.all([
2990
+ const [repoSettings, reviewSettings, appConfig] = await Promise.all([
1599
2991
  this.fetchRepoSettings().catch(() => null),
1600
- this.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null }))
2992
+ this.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null })),
2993
+ this._getAppConfig()
1601
2994
  ]);
1602
2995
 
1603
- const currentModel = repoSettings?.default_model || 'opus';
1604
- const currentProvider = repoSettings?.default_provider || 'claude';
2996
+ // Resolve provider and model as a MATCHED pair so the council/advanced tabs
2997
+ // are never seeded with a cross-provider model (e.g. gemini + opus), which
2998
+ // would blank the model <select> and be rejected by the backend.
2999
+ const providersInfo = await this._getProvidersInfo();
3000
+ const { provider: currentProvider, model: currentModel } = window.resolveProviderModelPair([
3001
+ { provider: repoSettings?.default_provider, model: repoSettings?.default_model },
3002
+ { provider: appConfig.default_provider, model: appConfig.default_model }
3003
+ ], providersInfo);
1605
3004
  const tabStorageKey = PRManager.getRepoStorageKey('pair-review-tab', owner, repo);
1606
3005
  const rememberedTab = localStorage.getItem(tabStorageKey);
1607
3006
  const defaultTab = rememberedTab || repoSettings?.default_tab || 'single';
@@ -1808,14 +3207,22 @@ class PRManager {
1808
3207
  // Don't show if no pending draft
1809
3208
  if (!pendingDraft) return;
1810
3209
 
3210
+ // Resolve the configured host name + URL (alt-host aware). Prefer the
3211
+ // URL built from the repo's url_template over the server-reported
3212
+ // github_url, which some alt-hosts return as a wrong-host github.com URL.
3213
+ const hostName = (window.RepoLinks && typeof window.RepoLinks.hostName === 'function')
3214
+ ? window.RepoLinks.hostName() : 'GitHub';
3215
+ const externalUrl = (window.RepoLinks && typeof window.RepoLinks.externalUrl === 'function')
3216
+ ? window.RepoLinks.externalUrl() : null;
3217
+
1811
3218
  // Create the indicator
1812
3219
  const indicator = document.createElement('a');
1813
3220
  indicator.id = 'pending-draft-indicator';
1814
3221
  indicator.className = 'pending-draft-indicator';
1815
- indicator.href = pendingDraft.github_url || '#';
3222
+ indicator.href = externalUrl || pendingDraft.github_url || '#';
1816
3223
  indicator.target = '_blank';
1817
3224
  indicator.rel = 'noopener noreferrer';
1818
- indicator.title = 'View your pending draft review on GitHub';
3225
+ indicator.title = `View your pending draft review on ${hostName}`;
1819
3226
 
1820
3227
  const commentCount = pendingDraft.comments_count || 0;
1821
3228
  const commentText = commentCount === 1 ? '1 comment' : `${commentCount} comments`;
@@ -1824,7 +3231,7 @@ class PRManager {
1824
3231
  <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
1825
3232
  <path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm5.03 2.22a.75.75 0 0 1 0 1.06L5.31 6.25l1.47 1.47a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-2-2a.75.75 0 0 1 0-1.06l2-2a.75.75 0 0 1 1.06 0Zm2.44 0a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l1.47-1.47-1.47-1.47a.75.75 0 0 1 0-1.06Z"/>
1826
3233
  </svg>
1827
- <span class="pending-draft-text">Draft on GitHub (${commentText})</span>
3234
+ <span class="pending-draft-text">Draft on ${this.escapeHtml(hostName)} (${commentText})</span>
1828
3235
  `;
1829
3236
 
1830
3237
  // Insert after the commit element (or at the end of toolbar-meta)
@@ -1844,8 +3251,52 @@ class PRManager {
1844
3251
  const diffContainer = document.getElementById('diff-container');
1845
3252
  if (!diffContainer) return;
1846
3253
 
3254
+ // Tear down any active tour BEFORE wiping the diff DOM: unmountAll()
3255
+ // re-collapses files the tour auto-expanded by looking them up via
3256
+ // `.d2h-file-wrapper[data-file-name=...]`. If we cleared innerHTML
3257
+ // first those lookups would all miss, and the user's pre-tour
3258
+ // collapse state would be silently lost. (Mirrors the rationale for
3259
+ // hunkSummaryRenderer.reset below — anchor-based DOM state cannot
3260
+ // survive a re-render.)
3261
+ if (this._tourIsActive && this._tourIsActive()) {
3262
+ this._exitTour();
3263
+ }
3264
+
1847
3265
  diffContainer.innerHTML = '';
1848
3266
 
3267
+ // Reset hunk-summary tracking — `renderPatch` will populate this as it
3268
+ // walks each block, and we hash the records once render finishes.
3269
+ this._pendingHunkRecords = [];
3270
+ if (this.hunkSummaryRenderer) {
3271
+ this.hunkSummaryRenderer.reset();
3272
+ }
3273
+ this._tourStops = null;
3274
+ this._summaryAnchorsByHash = new Map();
3275
+ this._summaryHashesByFile = new Map();
3276
+ this._pendingSummariesByHash = new Map();
3277
+ // Reset alongside the other per-render summary state. Set true again only
3278
+ // when a summary actually mounts (see _applyHunkSummaries / the existing-
3279
+ // summary fetch). Without this, a re-render whose subsequent fetch returns
3280
+ // no matching rows keeps the stale `true`, leaving the toolbar stuck in
3281
+ // Hide/Show mode with nothing in the DOM and blocking click-to-generate.
3282
+ this._summariesGenerated = false;
3283
+ // Reset alongside `_summariesGenerated`. Set true again only when a fetch
3284
+ // (or WS event) accepts/queues a non-trivial summary for this render.
3285
+ this._summariesAvailable = false;
3286
+ // Bump generation so any in-flight `_fetchHunkSummaryMap` from the
3287
+ // previous render bails out instead of mutating maps we just reset.
3288
+ this._renderGen = (this._renderGen || 0) + 1;
3289
+
3290
+ // Reset lazy-body state and (re)create the IntersectionObserver. The
3291
+ // `innerHTML = ''` above detached every previously-observed body, so a
3292
+ // stale observer would hold dead references and never fire — tear it down
3293
+ // and start fresh for this render generation. This runs for every render
3294
+ // path (initial load, whitespace toggle, scope change) since they all
3295
+ // funnel through renderDiff().
3296
+ this._teardownFileBodyObserver();
3297
+ this._lazyFileBodies = new Map();
3298
+ this._fileBodyObserver = this._createFileBodyObserver();
3299
+
1849
3300
  // Use changed_files array from API
1850
3301
  const files = pr.changed_files || pr.files || [];
1851
3302
 
@@ -1870,9 +3321,8 @@ class PRManager {
1870
3321
  }
1871
3322
  });
1872
3323
 
1873
- // Async validate end-of-file gaps - removes any that have no trailing lines
1874
- // This runs after render to avoid blocking initial display
1875
- this.validatePendingEofGaps();
3324
+ // NOTE: end-of-file gap validation runs per-file inside _renderFileBodyNow
3325
+ // now (bodies render lazily), not once globally here.
1876
3326
  } else {
1877
3327
  diffContainer.innerHTML = '<div class="no-diff">No files changed</div>';
1878
3328
  }
@@ -1880,6 +3330,23 @@ class PRManager {
1880
3330
  // Load context files after diff is rendered
1881
3331
  this.contextFiles = [];
1882
3332
  this.loadContextFiles();
3333
+
3334
+ // Fetch hunk summaries (Phase 5). Fire-and-forget — the diff is fully
3335
+ // usable while summaries arrive asynchronously. Anchors are wired lazily
3336
+ // as each file body renders (_registerHunkAnchorsForFile); this just loads
3337
+ // the server's summary map and applies it to whatever has rendered so far.
3338
+ if (this.hunkSummaryRenderer) {
3339
+ this._fetchHunkSummaryMap().catch((err) => {
3340
+ console.warn('[HunkSummary] summary fetch failed:', err);
3341
+ });
3342
+ }
3343
+
3344
+ // Probe tour endpoint after diff is rendered. `currentPR.id` is now
3345
+ // set (init()/LocalManager populates it before calling renderDiff),
3346
+ // so the toolbar button can reflect the right state.
3347
+ if (this._toursEnabled === true) {
3348
+ this._loadAndStashTour().catch(() => {});
3349
+ }
1883
3350
  }
1884
3351
 
1885
3352
  /**
@@ -1976,23 +3443,52 @@ class PRManager {
1976
3443
  }
1977
3444
  });
1978
3445
  header.appendChild(fileChatBtn);
1979
- }
1980
3446
 
1981
- // Create diff table
1982
- const table = document.createElement('table');
1983
- table.className = 'd2h-diff-table';
3447
+ // Per-file hunk-summary toggle. Mirrors the toolbar toggle but scoped to
3448
+ // one file. Disabled (greyed) when no summaries exist yet for this file;
3449
+ // _applyHunkSummaries / _registerHunkAnchorsForFile re-enable it as soon
3450
+ // as hashes for this file are recorded (so a summary that arrives later
3451
+ // doesn't get hidden behind a permanently-disabled button).
3452
+ // Gated on `_summariesEnabled`: skipped entirely when /api/config has
3453
+ // already reported the feature is off; created hidden + revealed later
3454
+ // when config has not yet resolved.
3455
+ if (this._summariesEnabled !== false) {
3456
+ const summaryToggleBtn = document.createElement('button');
3457
+ summaryToggleBtn.className = 'file-header-summary-toggle';
3458
+ summaryToggleBtn.dataset.file = file.file;
3459
+ summaryToggleBtn.innerHTML = `
3460
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
3461
+ <path d="M0 3.75C0 2.784.784 2 1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25Zm1.75-.25a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25ZM3.5 6.25a.75.75 0 0 1 .75-.75h7a.75.75 0 0 1 0 1.5h-7a.75.75 0 0 1-.75-.75Zm.75 2.25h4a.75.75 0 0 1 0 1.5h-4a.75.75 0 0 1 0-1.5Z"/>
3462
+ </svg>
3463
+ `;
3464
+
3465
+ if (this._summariesEnabled !== true) {
3466
+ // Config still pending; hide until the gate resolves.
3467
+ summaryToggleBtn.classList.add('summary-toggle-pending');
3468
+ summaryToggleBtn.style.display = 'none';
3469
+ }
1984
3470
 
1985
- const tbody = document.createElement('tbody');
3471
+ const fileIsHidden = this.summariesHiddenFiles?.has(file.file) || false;
3472
+ if (fileIsHidden) {
3473
+ wrapper.classList.add('summaries-hidden-file');
3474
+ }
3475
+ this._syncFileSummaryToggleButton(summaryToggleBtn, file.file);
1986
3476
 
1987
- // Parse the diff content
1988
- if (file.patch) {
1989
- this.renderPatch(tbody, file.patch, file.file);
1990
- } else if (file.binary) {
1991
- const row = document.createElement('tr');
1992
- row.innerHTML = '<td colspan="2" class="binary-file">Binary file</td>';
1993
- tbody.appendChild(row);
3477
+ summaryToggleBtn.addEventListener('click', (e) => {
3478
+ e.stopPropagation();
3479
+ this.toggleFileSummaries(file.file, wrapper);
3480
+ });
3481
+ header.appendChild(summaryToggleBtn);
3482
+ }
1994
3483
  }
1995
3484
 
3485
+ // Create diff table with an EMPTY tbody. The rows are NOT rendered here —
3486
+ // see the lazy-body machinery below. This is the core large-PR perf fix:
3487
+ // building + syntax-highlighting every line of every (often collapsed)
3488
+ // file up front froze the browser on big diffs.
3489
+ const table = document.createElement('table');
3490
+ table.className = 'd2h-diff-table';
3491
+ const tbody = document.createElement('tbody');
1996
3492
  table.appendChild(tbody);
1997
3493
 
1998
3494
  // Wrap table in a scrollable container for horizontal scroll of long code lines
@@ -2002,22 +3498,235 @@ class PRManager {
2002
3498
  fileBody.appendChild(table);
2003
3499
  wrapper.appendChild(fileBody);
2004
3500
 
3501
+ // Reserve approximate height for EXPANDED-but-not-yet-rendered bodies so
3502
+ // the scrollbar stays roughly stable as the user scrolls and bodies fill
3503
+ // in. Collapsed bodies are `display:none`, so they contribute no height
3504
+ // and need no placeholder. Cleared once the body actually renders.
3505
+ if (!isCollapsed && file.patch) {
3506
+ const approxLines = file.patch.split('\n').length;
3507
+ fileBody.style.minHeight = (approxLines * PRManager.APPROX_DIFF_LINE_PX) + 'px';
3508
+ }
3509
+
3510
+ // Register the lazy entry. `gen` lets _renderFileBodyNow detect a body
3511
+ // left over from a superseded render and skip anchor registration.
3512
+ this._lazyFileBodies.set(file.file, {
3513
+ fileName: file.file,
3514
+ patch: file.patch || null,
3515
+ binary: !!file.binary,
3516
+ hunkHashes: file.hunk_hashes || null,
3517
+ fileBody,
3518
+ wrapper,
3519
+ rendered: false,
3520
+ renderPromise: null,
3521
+ gen: this._renderGen
3522
+ });
3523
+
3524
+ // Observe the body so it renders as it nears the viewport. Collapsed
3525
+ // bodies (display:none) never intersect → stay unrendered until expanded.
3526
+ if ((file.patch || file.binary) && this._fileBodyObserver) {
3527
+ this._fileBodyObserver.observe(fileBody);
3528
+ }
3529
+
2005
3530
  return wrapper;
2006
3531
  }
2007
3532
 
3533
+ /**
3534
+ * Approximate rendered height (px) of one diff line row. Used only to
3535
+ * reserve placeholder height for expanded-but-unrendered file bodies so the
3536
+ * scrollbar doesn't jump as lazy bodies fill in. Errs slightly high.
3537
+ */
3538
+ static get APPROX_DIFF_LINE_PX() { return 20; }
3539
+
3540
+ /**
3541
+ * Create the IntersectionObserver that renders file bodies as they approach
3542
+ * the viewport. One instance per render generation; recreated in renderDiff.
3543
+ * Returns null where IntersectionObserver is unavailable (e.g. jsdom unit
3544
+ * tests) — bodies then render only on demand via ensureFileBodyRendered().
3545
+ * @returns {IntersectionObserver|null}
3546
+ */
3547
+ _createFileBodyObserver() {
3548
+ if (typeof IntersectionObserver === 'undefined') return null;
3549
+ // `.diff-view` is the vertical scroll container (see pr.css). Fall back to
3550
+ // the viewport when it can't be resolved.
3551
+ const root = document.querySelector('.diff-view') || null;
3552
+ return new IntersectionObserver((entries, observer) => {
3553
+ for (const ioEntry of entries) {
3554
+ if (!ioEntry.isIntersecting) continue;
3555
+ const lazyEntry = this._lazyFileBodyForElement(ioEntry.target);
3556
+ observer.unobserve(ioEntry.target);
3557
+ if (lazyEntry) this._renderFileBodyNow(lazyEntry);
3558
+ }
3559
+ }, { root, rootMargin: '800px 0px', threshold: 0 });
3560
+ }
3561
+
3562
+ /**
3563
+ * Disconnect and drop the current file-body observer, if any.
3564
+ */
3565
+ _teardownFileBodyObserver() {
3566
+ if (this._fileBodyObserver) {
3567
+ this._fileBodyObserver.disconnect();
3568
+ this._fileBodyObserver = null;
3569
+ }
3570
+ }
3571
+
3572
+ /**
3573
+ * Resolve the lazy entry for a `.d2h-file-body` element via its wrapper's
3574
+ * data-file-name. Returns null if not a known lazy body.
3575
+ * @param {Element} bodyEl
3576
+ * @returns {object|null}
3577
+ */
3578
+ _lazyFileBodyForElement(bodyEl) {
3579
+ if (!this._lazyFileBodies) return null;
3580
+ const wrapper = bodyEl?.closest?.('.d2h-file-wrapper');
3581
+ const filePath = wrapper?.dataset?.fileName;
3582
+ if (!filePath) return null;
3583
+ return this._lazyFileBodies.get(filePath) || null;
3584
+ }
3585
+
3586
+ /**
3587
+ * Ensure a file's diff-line body is rendered into the DOM, rendering it now
3588
+ * if it hasn't been. Idempotent and cheap when already rendered. Every code
3589
+ * path that scans a file's `<tr>` rows (comment/suggestion anchoring, gap
3590
+ * expansion, scroll-to-file, expand) must await this first, because a lazy
3591
+ * body has zero rows until rendered.
3592
+ * @param {string|Element} fileOrWrapper - file path, file body, or wrapper
3593
+ * @returns {Promise<HTMLElement|null>} the file body, or null if unknown
3594
+ */
3595
+ async ensureFileBodyRendered(fileOrWrapper) {
3596
+ // No lazy map (e.g. called before renderDiff, or a non-lazy render path) →
3597
+ // treat the body as already present and let callers scan as before.
3598
+ if (!this._lazyFileBodies) return null;
3599
+ let entry = null;
3600
+ if (typeof fileOrWrapper === 'string') {
3601
+ entry = this._lazyFileBodies.get(fileOrWrapper) || null;
3602
+ if (!entry) {
3603
+ // The map is keyed by the canonical `file.file` value, but callers
3604
+ // (AI suggestions, external comments, tour stops) may pass a
3605
+ // non-canonical path form. findFileElement normalizes './', '/', and
3606
+ // git rename syntax ('{old => new}') against data-file-name, so
3607
+ // resolve the wrapper first and retry with its canonical name. This
3608
+ // keeps the strict Map.get fast path while tolerating the same path
3609
+ // variants the rest of the diff UI accepts — without it the body
3610
+ // stays unrendered and the downstream row scan sees zero <tr> rows.
3611
+ const wrapper = this.findFileElement?.(fileOrWrapper);
3612
+ const canonicalFile = wrapper?.dataset?.fileName;
3613
+ if (canonicalFile) entry = this._lazyFileBodies.get(canonicalFile) || null;
3614
+ }
3615
+ } else if (fileOrWrapper && fileOrWrapper.nodeType === 1) {
3616
+ entry = this._lazyFileBodyForElement(fileOrWrapper)
3617
+ || (this._lazyFileBodies.get(fileOrWrapper.dataset?.fileName) || null);
3618
+ }
3619
+ if (!entry) return null;
3620
+ if (entry.rendered) return entry.fileBody;
3621
+ if (entry.renderPromise) return entry.renderPromise;
3622
+ // _renderFileBodyNow is synchronous; wrapping in a resolved promise keeps
3623
+ // the signature async and lets concurrent callers (observer + on-demand)
3624
+ // share one promise so renderPatch runs exactly once.
3625
+ entry.renderPromise = Promise.resolve().then(() => this._renderFileBodyNow(entry));
3626
+ return entry.renderPromise;
3627
+ }
3628
+
3629
+ /**
3630
+ * Synchronously build a file's diff-line body: run renderPatch (or the
3631
+ * binary placeholder), clear the height placeholder, wire hunk-summary
3632
+ * anchors, and validate this file's EOF gap. Idempotent.
3633
+ *
3634
+ * Invariant: there must be NO `await` between the `_pendingHunkRecords`
3635
+ * save and restore below — renderPatch pushes per-hunk anchor records into
3636
+ * the shared `_pendingHunkRecords`, and JS single-threading only guarantees
3637
+ * non-interleaving with other file renders while this stays synchronous.
3638
+ * @param {object} entry - a _lazyFileBodies value
3639
+ * @returns {HTMLElement} the file body element
3640
+ */
3641
+ _renderFileBodyNow(entry) {
3642
+ if (entry.rendered) return entry.fileBody;
3643
+ if (this._fileBodyObserver) this._fileBodyObserver.unobserve(entry.fileBody);
3644
+
3645
+ const tbody = entry.fileBody.querySelector('tbody');
3646
+
3647
+ // Capture only THIS file's hunk anchor records (renderPatch appends to
3648
+ // this._pendingHunkRecords; see invariant above). The swap is wrapped in
3649
+ // try/finally so a renderPatch throw can't leak the temporary buffer: were
3650
+ // the restore skipped, every subsequent file render would append its
3651
+ // anchor records into the stale array, silently cross-wiring
3652
+ // _summaryAnchorsByHash / _summaryHashesByFile for the rest of the session.
3653
+ const prevPending = this._pendingHunkRecords;
3654
+ this._pendingHunkRecords = [];
3655
+ let records;
3656
+ try {
3657
+ if (entry.patch && tbody) {
3658
+ this.renderPatch(tbody, entry.patch, entry.fileName, entry.hunkHashes);
3659
+ } else if (entry.binary && tbody) {
3660
+ const row = document.createElement('tr');
3661
+ row.innerHTML = '<td colspan="2" class="binary-file">Binary file</td>';
3662
+ tbody.appendChild(row);
3663
+ }
3664
+ } finally {
3665
+ records = this._pendingHunkRecords;
3666
+ this._pendingHunkRecords = prevPending;
3667
+ }
3668
+
3669
+ entry.fileBody.style.minHeight = '';
3670
+ entry.rendered = true;
3671
+ entry.renderPromise = null;
3672
+
3673
+ // Skip post-render wiring for a body left over from a superseded render
3674
+ // (its maps were reset and the body is detached). Both anchor registration
3675
+ // and EOF-gap validation are pointless/wasteful for a stale body.
3676
+ if (entry.gen === this._renderGen) {
3677
+ this._registerHunkAnchorsForFile(records);
3678
+ // Validate this file's pending EOF gaps. Pre-lazy-render this was a
3679
+ // single global pass at the end of renderDiff; now it runs per-file as
3680
+ // bodies render. Cheap pre-check first: most files have no pending EOF
3681
+ // gap, so skip the async work (Array.from + Promise.all + per-gap
3682
+ // /file-content fetches) entirely when this body has none. The selector
3683
+ // mirrors the one validatePendingEofGaps scans for.
3684
+ if (entry.fileBody.querySelector('tr.context-expand-row[data-pending-eof-validation="true"]')) {
3685
+ this.validatePendingEofGaps(entry.fileBody);
3686
+ }
3687
+ }
3688
+
3689
+ return entry.fileBody;
3690
+ }
3691
+
2008
3692
  /**
2009
3693
  * Parse and render a unified diff patch
2010
3694
  * @param {HTMLElement} tbody - Table body element
2011
3695
  * @param {string} patch - Unified diff patch string
2012
3696
  * @param {string} fileName - File name
3697
+ * @param {string[]|null} [hunkHashes] - Per-hunk content hashes parallel
3698
+ * to the order `parseDiffIntoBlocks` returns hunks. When supplied, these
3699
+ * are used instead of computing client-side hashes. Computed by the
3700
+ * backend from the canonical (non-whitespace-filtered) diff so they
3701
+ * stay aligned with persisted summary keys.
2013
3702
  */
2014
- renderPatch(tbody, patch, fileName) {
3703
+ renderPatch(tbody, patch, fileName, hunkHashes = null) {
2015
3704
  let diffPosition = 0; // GitHub diff_position (1-indexed, consecutive)
2016
3705
  let prevBlockEnd = { old: 0, new: 0 };
2017
3706
  let isFirstHunk = true;
2018
3707
 
2019
3708
  const blocks = window.HunkParser.parseDiffIntoBlocks(patch);
2020
3709
 
3710
+ // Defend against length drift between server-supplied (canonical) hashes
3711
+ // and the rendered (possibly whitespace-filtered) blocks: under `?w=1`,
3712
+ // `git diff -w` can drop or merge whitespace-only hunks so the canonical
3713
+ // and rendered hunk counts diverge. Misaligned hashes would write the
3714
+ // wrong canonical hash onto every block after the first dropped hunk,
3715
+ // anchoring summaries to the wrong rendered hunk. Fail closed: drop the
3716
+ // hashes for this file. Summaries then simply won't anchor — visibly
3717
+ // missing rather than visibly wrong.
3718
+ if (Array.isArray(hunkHashes) && hunkHashes.length !== blocks.length) {
3719
+ if (!this._warnedHunkHashLengthMismatch) {
3720
+ this._warnedHunkHashLengthMismatch = true;
3721
+ console.warn(
3722
+ `[HunkSummary] hunk_hashes length mismatch for ${fileName}: ` +
3723
+ `${hunkHashes.length} canonical hashes, ${blocks.length} rendered ` +
3724
+ 'blocks. Dropping hashes for this file.'
3725
+ );
3726
+ }
3727
+ hunkHashes = null;
3728
+ }
3729
+
2021
3730
  // Render blocks with gap sections
2022
3731
  blocks.forEach((block, blockIndex) => {
2023
3732
  diffPosition++; // Hunk header counts as a position
@@ -2097,6 +3806,7 @@ class PRManager {
2097
3806
  let oldLineNum = block.oldStart;
2098
3807
  let newLineNum = block.newStart;
2099
3808
 
3809
+ let firstLineRow = null;
2100
3810
  block.lines.forEach(line => {
2101
3811
  if (!line && line !== '') return; // Skip undefined
2102
3812
 
@@ -2126,8 +3836,24 @@ class PRManager {
2126
3836
  };
2127
3837
 
2128
3838
  this.renderDiffLine(tbody, lineData, fileName, diffPosition);
3839
+ if (!firstLineRow) firstLineRow = tbody.lastElementChild;
2129
3840
  });
2130
3841
 
3842
+ // Record this hunk's first rendered code row as the anchor for any
3843
+ // inline summary annotation. The canonical hash comes from the
3844
+ // backend (`hunkHashes[blockIndex]`); _registerHunkAnchorsForFile mounts
3845
+ // it as `data-hunk-start` when this file's body renders so the summary
3846
+ // renderer can find the anchor and insert the annotation above it.
3847
+ if (this._pendingHunkRecords && firstLineRow) {
3848
+ const serverHash = Array.isArray(hunkHashes) ? hunkHashes[blockIndex] || null : null;
3849
+ this._pendingHunkRecords.push({
3850
+ file: fileName,
3851
+ header: block.header,
3852
+ anchorRow: firstLineRow,
3853
+ contentHash: serverHash
3854
+ });
3855
+ }
3856
+
2131
3857
  // Update previous block end coordinates
2132
3858
  const endBounds = window.HunkParser.getBlockCoordinateBounds(
2133
3859
  { lines: this.parseBlockLines(block) },
@@ -2300,7 +4026,7 @@ class PRManager {
2300
4026
  * Toggle collapse state of a file diff
2301
4027
  * @param {string} filePath - Path of the file
2302
4028
  */
2303
- toggleFileCollapse(filePath) {
4029
+ async toggleFileCollapse(filePath) {
2304
4030
  const wrapper = this.findFileElement(filePath);
2305
4031
  if (!wrapper) return;
2306
4032
 
@@ -2308,6 +4034,8 @@ class PRManager {
2308
4034
  const header = wrapper.querySelector('.d2h-file-header');
2309
4035
 
2310
4036
  if (isCollapsed) {
4037
+ // Render the body before revealing it (lazy bodies are empty until now).
4038
+ await this.ensureFileBodyRendered(filePath);
2311
4039
  wrapper.classList.remove('collapsed');
2312
4040
  this.collapsedFiles.delete(filePath);
2313
4041
  } else {
@@ -2326,7 +4054,7 @@ class PRManager {
2326
4054
  * @param {string} filePath - Path of the file
2327
4055
  * @param {boolean} isViewed - Whether the file is now viewed
2328
4056
  */
2329
- toggleFileViewed(filePath, isViewed) {
4057
+ async toggleFileViewed(filePath, isViewed) {
2330
4058
  const wrapper = this.findFileElement(filePath);
2331
4059
 
2332
4060
  if (isViewed) {
@@ -2344,6 +4072,8 @@ class PRManager {
2344
4072
  this.viewedFiles.delete(filePath);
2345
4073
  // Auto-expand when unchecking viewed (match GitHub behavior)
2346
4074
  if (wrapper && wrapper.classList.contains('collapsed')) {
4075
+ // Render the body before revealing it (lazy bodies are empty until now).
4076
+ await this.ensureFileBodyRendered(filePath);
2347
4077
  wrapper.classList.remove('collapsed');
2348
4078
  this.collapsedFiles.delete(filePath);
2349
4079
  const header = wrapper.querySelector('.d2h-file-header');
@@ -2469,7 +4199,7 @@ class PRManager {
2469
4199
  * @deprecated Use toggleFileCollapse instead - kept for backward compatibility
2470
4200
  */
2471
4201
  toggleGeneratedFile(filePath) {
2472
- this.toggleFileCollapse(filePath);
4202
+ return this.toggleFileCollapse(filePath);
2473
4203
  }
2474
4204
 
2475
4205
  /**
@@ -2498,9 +4228,12 @@ class PRManager {
2498
4228
  * Validate pending end-of-file gaps asynchronously
2499
4229
  * Removes gap rows where there are no trailing lines to expand
2500
4230
  * This ensures users don't see expand buttons that do nothing
4231
+ * @param {ParentNode} [root=document] - Scope the search. With lazy bodies,
4232
+ * _renderFileBodyNow passes the just-rendered file body so each file's EOF
4233
+ * gap is validated as it renders (rather than one global post-render pass).
2501
4234
  */
2502
- async validatePendingEofGaps() {
2503
- const pendingGaps = document.querySelectorAll('tr.context-expand-row[data-pending-eof-validation="true"]');
4235
+ async validatePendingEofGaps(root = document) {
4236
+ const pendingGaps = root.querySelectorAll('tr.context-expand-row[data-pending-eof-validation="true"]');
2504
4237
 
2505
4238
  // Process all pending gaps in parallel for efficiency
2506
4239
  const validationPromises = Array.from(pendingGaps).map(async (gapRow) => {
@@ -2865,11 +4598,14 @@ class PRManager {
2865
4598
  return false;
2866
4599
  }
2867
4600
 
4601
+ // Render the body first — gap rows (and code rows) only exist once the
4602
+ // lazy body has rendered. Without this the gap query below returns nothing.
4603
+ await this.ensureFileBodyRendered(file);
4604
+
2868
4605
  // Check if file is collapsed (generated files)
2869
4606
  if (fileElement.classList.contains('collapsed')) {
2870
4607
  debugLog?.('expandForSuggestion', 'File is collapsed, expanding first');
2871
- this.toggleGeneratedFile(file);
2872
- await new Promise(resolve => setTimeout(resolve, 50));
4608
+ await this.toggleGeneratedFile(file);
2873
4609
  }
2874
4610
 
2875
4611
  // Find the gap section containing the target lines using the shared module
@@ -2953,6 +4689,12 @@ class PRManager {
2953
4689
  const fileElement = this.findFileElement(file);
2954
4690
  if (!fileElement) continue;
2955
4691
 
4692
+ // Render the file body first — with lazy rendering an unrendered file
4693
+ // has zero rows, so the visibility scan below would always miss and the
4694
+ // line would be treated as "hidden in a gap" (then gap-expanded against
4695
+ // zero gap rows → silent anchor failure).
4696
+ await this.ensureFileBodyRendered(file);
4697
+
2956
4698
  // Check if any line in the range is already visible
2957
4699
  let anyLineVisible = false;
2958
4700
  const lineRows = fileElement.querySelectorAll('tr');
@@ -4627,9 +6369,20 @@ class PRManager {
4627
6369
  collapsedBtn.addEventListener('click', () => toggleSidebar(false));
4628
6370
  }
4629
6371
 
4630
- scrollToFile(filePath) {
6372
+ async scrollToFile(filePath) {
4631
6373
  const fileWrapper = this.findFileElement(filePath);
4632
6374
  if (fileWrapper) {
6375
+ // Render the body so the scroll target has its real height (an empty
6376
+ // lazy body would land the scroll at the wrong offset for expanded
6377
+ // files). Skip it for collapsed files: their body is display:none
6378
+ // (zero height) and `block: 'start'` aligns the header regardless, so
6379
+ // force-rendering would only pay the full renderPatch + per-line
6380
+ // highlight cost for content that stays hidden. scrollToFile does no
6381
+ // post-render row scan, so gating on `!collapsed` is safe; expanding
6382
+ // later still renders on demand via toggleFileCollapse.
6383
+ if (!fileWrapper.classList.contains('collapsed')) {
6384
+ await this.ensureFileBodyRendered(filePath);
6385
+ }
4633
6386
  fileWrapper.scrollIntoView({ behavior: 'smooth', block: 'start' });
4634
6387
  }
4635
6388
  }
@@ -5054,11 +6807,15 @@ class PRManager {
5054
6807
  : this._fetchStaleness(owner, repo, number);
5055
6808
  this._stalenessPromise = null; // consume it
5056
6809
 
6810
+ // Pass owner+repo to /api/config so has_github_token reflects the
6811
+ // repo's actual auth (covers repo-scoped tokens, token_command, and
6812
+ // alt-host bindings — not just the global github_token).
6813
+ const configUrl = `/api/config?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`;
5057
6814
  const [staleResult, repoSettings, reviewSettings, appConfig] = await Promise.all([
5058
6815
  staleCheckWithTimeout,
5059
6816
  this.fetchRepoSettings(),
5060
6817
  this.fetchLastReviewSettings(),
5061
- fetch('/api/config').then(r => r.ok ? r.json() : {}).catch(() => ({}))
6818
+ fetch(configUrl).then(r => r.ok ? r.json() : {}).catch(() => ({}))
5062
6819
  ]);
5063
6820
  console.debug(`[Analyze] parallel-fetch (stale+settings): ${Math.round(performance.now() - _tParallel0)}ms`);
5064
6821
 
@@ -5104,9 +6861,14 @@ class PRManager {
5104
6861
 
5105
6862
  const lastCouncilId = reviewSettings.last_council_id;
5106
6863
 
5107
- // Determine the model and provider to use (priority: repo default > defaults)
5108
- const currentModel = repoSettings?.default_model || 'opus';
5109
- const currentProvider = repoSettings?.default_provider || 'claude';
6864
+ // Resolve provider and model as a MATCHED pair so the council/advanced tabs
6865
+ // are never seeded with a cross-provider model (e.g. gemini + opus), which
6866
+ // would blank the model <select> and be rejected by the backend.
6867
+ const providersInfo = await this._getProvidersInfo();
6868
+ const { provider: currentProvider, model: currentModel } = window.resolveProviderModelPair([
6869
+ { provider: repoSettings?.default_provider, model: repoSettings?.default_model },
6870
+ { provider: appConfig.default_provider, model: appConfig.default_model }
6871
+ ], providersInfo);
5110
6872
 
5111
6873
  // Determine default tab (priority: localStorage > repo settings > 'single')
5112
6874
  const tabStorageKey = PRManager.getRepoStorageKey('pair-review-tab', owner, repo);
@@ -5134,7 +6896,12 @@ class PRManager {
5134
6896
  lastCouncilId,
5135
6897
  defaultCouncilId: repoSettings?.default_council_id || null,
5136
6898
  hasPr: true,
5137
- hasGithubToken: Boolean(appConfig.has_github_token)
6899
+ // Use the repo-aware field (we passed owner+repo to /api/config).
6900
+ // Fall back to the global field only if the response was malformed
6901
+ // or the params were rejected — defensive, should not happen.
6902
+ hasGithubToken: Boolean(
6903
+ appConfig.has_github_token ?? appConfig.has_global_github_token
6904
+ )
5138
6905
  });
5139
6906
 
5140
6907
  // If user cancelled, do nothing
@@ -5280,7 +7047,10 @@ class PRManager {
5280
7047
  showWorktreeNotFoundError(owner, repo, number) {
5281
7048
  let setupUrl = `/pr/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(number)}`;
5282
7049
  if (this._autoAnalyzeRequested) {
5283
- setupUrl += '?analyze=true';
7050
+ const params = new URLSearchParams({ analyze: 'true' });
7051
+ const analysisConfigId = new URLSearchParams(window.location.search).get('analysisConfigId');
7052
+ if (analysisConfigId) params.set('analysisConfigId', analysisConfigId);
7053
+ setupUrl += `?${params.toString()}`;
5284
7054
  }
5285
7055
  const container = document.getElementById('pr-container');
5286
7056
  if (container) {