@in-the-loop-labs/pair-review 3.6.0 → 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 (63) hide show
  1. package/README.md +4 -0
  2. package/package.json +20 -15
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +0 -0
  6. package/public/css/analysis-config.css +1807 -0
  7. package/public/css/pr.css +0 -1737
  8. package/public/index.html +11 -0
  9. package/public/js/components/AIPanel.js +39 -23
  10. package/public/js/components/AdvancedConfigTab.js +56 -4
  11. package/public/js/components/AnalysisConfigModal.js +41 -25
  12. package/public/js/components/ReviewModal.js +135 -13
  13. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  14. package/public/js/index.js +175 -16
  15. package/public/js/local.js +58 -8
  16. package/public/js/modules/suggestion-manager.js +25 -1
  17. package/public/js/modules/tour-renderer.js +33 -3
  18. package/public/js/pr.js +653 -157
  19. package/public/js/repo-links.js +328 -0
  20. package/public/js/utils/provider-model.js +88 -0
  21. package/public/js/utils/storage-keys.js +50 -0
  22. package/public/local.html +7 -0
  23. package/public/pr.html +7 -0
  24. package/public/repo-settings.html +1 -0
  25. package/public/setup.html +2 -0
  26. package/src/ai/analyzer.js +125 -18
  27. package/src/config.js +664 -10
  28. package/src/external/github-adapter.js +114 -25
  29. package/src/git/base-branch.js +11 -4
  30. package/src/github/client.js +482 -588
  31. package/src/github/errors.js +55 -0
  32. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  33. package/src/github/impl/graphql/pending-review.js +153 -0
  34. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  35. package/src/github/impl/graphql/stack-walker.js +210 -0
  36. package/src/github/impl/host/pending-review-comments.js +338 -0
  37. package/src/github/impl/rest/pending-review.js +251 -0
  38. package/src/github/impl/rest/review-lifecycle.js +226 -0
  39. package/src/github/impl/rest/stack-walker.js +309 -0
  40. package/src/github/operations/pending-review-comments.js +79 -0
  41. package/src/github/operations/pending-review.js +89 -0
  42. package/src/github/operations/review-lifecycle.js +126 -0
  43. package/src/github/operations/stack-walker.js +87 -0
  44. package/src/github/parser.js +230 -4
  45. package/src/github/stack-walker.js +14 -189
  46. package/src/links/repo-links.js +230 -0
  47. package/src/local-review.js +13 -4
  48. package/src/main.js +133 -30
  49. package/src/routes/analyses.js +30 -7
  50. package/src/routes/bulk-analysis-configs.js +295 -0
  51. package/src/routes/config.js +102 -2
  52. package/src/routes/external-comments.js +20 -10
  53. package/src/routes/github-collections.js +3 -1
  54. package/src/routes/local.js +101 -11
  55. package/src/routes/mcp.js +47 -4
  56. package/src/routes/pr.js +298 -68
  57. package/src/routes/setup.js +8 -3
  58. package/src/routes/stack-analysis.js +33 -9
  59. package/src/routes/worktrees.js +3 -2
  60. package/src/server.js +2 -0
  61. package/src/setup/pr-setup.js +37 -11
  62. package/src/setup/stack-setup.js +13 -3
  63. package/src/single-port.js +6 -3
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() {
@@ -140,18 +139,40 @@ class PRManager {
140
139
  this.commentMinimizer = window.CommentMinimizer ? new window.CommentMinimizer() : null;
141
140
  // Hunk summary renderer (Phase 5) — inline natural-language summaries
142
141
  this.hunkSummaryRenderer = window.HunkSummaryRenderer ? new window.HunkSummaryRenderer(this) : null;
143
- // Per-render anchor map: contentHash -> first code-line <tr> of the hunk
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).
144
149
  this._summaryAnchorsByHash = new Map();
145
150
  // Per-render file map: filePath -> Set<contentHash>
146
151
  this._summaryHashesByFile = new Map();
147
- // Summaries that arrived (via WS or fetch) before their hunk had been hashed
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).
148
158
  this._pendingSummariesByHash = new Map();
149
159
  // Per-file summary visibility (persisted in localStorage per-review)
150
160
  this.summariesHiddenFiles = new Set();
151
161
  // Render-generation token — incremented at the top of renderDiff() so any
152
- // fire-and-forget _kickOffHunkSummaries() from a prior render can detect
162
+ // fire-and-forget _fetchHunkSummaryMap() from a prior render can detect
153
163
  // it's stale and bail (refresh / whitespace toggle / scope change race).
154
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;
155
176
  // Cached /api/config response (lazy-loaded)
156
177
  this._appConfigPromise = null;
157
178
  // Review-level summary visibility (persisted in localStorage per-review)
@@ -165,6 +186,15 @@ class PRManager {
165
186
  // user can tell nothing has been generated yet. Set true by
166
187
  // `_applyHunkSummaries` and the initial existing-summaries fetch.
167
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;
168
198
  // Tri-state: true when /api/config reports summaries.enabled, false when
169
199
  // disabled, null until /api/config resolves. Per-file toggle buttons are
170
200
  // gated on this so users on disabled deployments don't see them flicker
@@ -588,7 +618,7 @@ class PRManager {
588
618
  * @param {Object} reviewSettings - Review settings from fetchLastReviewSettings()
589
619
  * @returns {Promise<Object>} Config object suitable for startAnalysis / startLocalAnalysis
590
620
  */
591
- async _buildDefaultAnalysisConfig(repoSettings, reviewSettings) {
621
+ async _buildDefaultAnalysisConfig(repoSettings, reviewSettings, appConfig = {}, providersInfo = null) {
592
622
  const defaultTab = repoSettings?.default_tab || 'single';
593
623
  const councilId = repoSettings?.default_council_id || reviewSettings?.last_council_id || null;
594
624
 
@@ -617,13 +647,44 @@ class PRManager {
617
647
  };
618
648
  }
619
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
+
620
660
  return {
621
- provider: repoSettings?.default_provider || 'claude',
622
- model: repoSettings?.default_model || 'opus',
661
+ provider,
662
+ model,
623
663
  customInstructions: null
624
664
  };
625
665
  }
626
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
+
627
688
  /**
628
689
  * Auto-trigger analysis if ?analyze=true is present in the URL.
629
690
  * Skips refresh if data was just loaded fresh by loadPR (to avoid redundant fetches).
@@ -638,6 +699,7 @@ class PRManager {
638
699
  const autoAnalyze = new URLSearchParams(window.location.search).get('analyze');
639
700
  if (autoAnalyze === 'true' && !this.isAnalyzing) {
640
701
  this._autoAnalyzeRequested = true;
702
+ let shouldCleanUrl = true;
641
703
  try {
642
704
  // Skip refresh if we just loaded fresh data (loadPR sets _justLoaded = true).
643
705
  // Otherwise, refresh to ensure we have the latest PR data in case the worktree
@@ -654,19 +716,39 @@ class PRManager {
654
716
  }
655
717
  }
656
718
 
657
- // Fetch repo settings so we honour the repository's default provider/council
658
- const [repoSettings, reviewSettings] = await Promise.all([
659
- this.fetchRepoSettings().catch(() => null),
660
- this.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null }))
661
- ]);
662
- 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
+ }
663
742
 
664
743
  await this.startAnalysis(owner, repo, prNumber, null, config);
665
744
  } finally {
666
745
  this._autoAnalyzeRequested = false;
667
- const cleanUrl = new URL(window.location);
668
- cleanUrl.searchParams.delete('analyze');
669
- 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
+ }
670
752
  }
671
753
  }
672
754
  }
@@ -1067,31 +1149,111 @@ class PRManager {
1067
1149
  }
1068
1150
 
1069
1151
  /**
1070
- * Attach `data-hunk-start` to each anchor row using the server-supplied
1071
- * `content_hash`, then kick off the initial summary fetch (gated by
1072
- * `summaries.enabled` in `/api/config`). Drains any summaries that
1073
- * arrived before mounting finished.
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`).
1074
1238
  *
1075
- * Order matters:
1076
- * 1. Config gate first bail before paying any setup cost when the
1077
- * feature is off.
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.
1078
1247
  * 2. Restore localStorage visibility state.
1079
- * 3. Walk records and wire each anchor row to its server-supplied
1080
- * content hash. The server computes hashes from the canonical
1081
- * (non-whitespace-filtered) diff so they stay aligned with persisted
1082
- * summary keys; records lacking a `contentHash` are logged-and-skipped.
1083
- * 4. Drain pending summaries against the freshly-built anchor map.
1084
- * 5. Fetch existing summaries from the server.
1248
+ * 3. Fetch existing summaries and apply/queue them.
1085
1249
  *
1086
- * Race-safety: `_renderGen` is captured at entry and rechecked after
1087
- * every `await`. If `renderDiff()` ran again mid-flight, we stop touching
1088
- * the (now stale) maps and DOM.
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.
1089
1253
  * @returns {Promise<void>}
1090
1254
  */
1091
- async _kickOffHunkSummaries() {
1255
+ async _fetchHunkSummaryMap() {
1092
1256
  const gen = this._renderGen;
1093
- const records = this._pendingHunkRecords || [];
1094
- this._pendingHunkRecords = null; // single-use; renderDiff resets
1095
1257
 
1096
1258
  // 1. Config gate — bail before doing any work when the feature is off.
1097
1259
  const cfg = await this._getAppConfig();
@@ -1119,54 +1281,7 @@ class PRManager {
1119
1281
  }
1120
1282
  }
1121
1283
 
1122
- // 3. Wire each anchor row to its server-supplied content hash. The
1123
- // backend computes these from the canonical (unfiltered) diff so they
1124
- // stay aligned with persisted summary keys regardless of `?w=1`.
1125
- // Records missing `contentHash` indicate a server bug (or a hash array
1126
- // length mismatch the renderPatch guard already dropped) — log + skip.
1127
- for (const rec of records) {
1128
- if (!rec.anchorRow || !rec.anchorRow.isConnected) continue;
1129
- const hex = rec.contentHash;
1130
- if (!hex) {
1131
- console.warn(
1132
- `[HunkSummary] no server contentHash for ${rec.file} ` +
1133
- `hunk ${rec.header}; skipping (summaries will not anchor here).`
1134
- );
1135
- continue;
1136
- }
1137
- rec.anchorRow.dataset.hunkStart = hex;
1138
- this._summaryAnchorsByHash.set(hex, rec.anchorRow);
1139
- let bucket = this._summaryHashesByFile.get(rec.file);
1140
- if (!bucket) {
1141
- bucket = new Set();
1142
- this._summaryHashesByFile.set(rec.file, bucket);
1143
- }
1144
- bucket.add(hex);
1145
- }
1146
-
1147
- // 4. Drain summaries that arrived (via WS) before hashing finished.
1148
- if (this._pendingSummariesByHash.size > 0) {
1149
- const filesWithMounts = new Set();
1150
- for (const [hash, summary] of this._pendingSummariesByHash.entries()) {
1151
- if (this._summaryAnchorsByHash.has(hash)) {
1152
- const row = this._renderOneSummary(summary);
1153
- if (row) {
1154
- // Find the file this hash belongs to, so we can re-enable its
1155
- // toggle button.
1156
- for (const [filePath, bucket] of this._summaryHashesByFile.entries()) {
1157
- if (bucket.has(hash)) {
1158
- filesWithMounts.add(filePath);
1159
- break;
1160
- }
1161
- }
1162
- }
1163
- this._pendingSummariesByHash.delete(hash);
1164
- }
1165
- }
1166
- for (const filePath of filesWithMounts) this._refreshFileSummaryToggle(filePath);
1167
- }
1168
-
1169
- // 5. Load existing summaries from the server.
1284
+ // 3. Load existing summaries from the server.
1170
1285
  if (!this.currentPR?.id) return;
1171
1286
 
1172
1287
  try {
@@ -1192,13 +1307,14 @@ class PRManager {
1192
1307
  // `_applyHunkSummaries`, which sets the flag on a successful mount.
1193
1308
  if (byFile.size === 0 && summaries.length > 0) {
1194
1309
  let mountedAny = false;
1310
+ let availableAny = false;
1195
1311
  for (const summary of summaries) {
1312
+ if (summary.summary_text) availableAny = true;
1196
1313
  if (this._renderOneSummary(summary)) mountedAny = true;
1197
1314
  }
1198
- if (mountedAny) {
1199
- this._summariesGenerated = true;
1200
- this._syncSummaryToolbarButton();
1201
- }
1315
+ if (availableAny) this._summariesAvailable = true;
1316
+ if (mountedAny) this._summariesGenerated = true;
1317
+ if (mountedAny || availableAny) this._syncSummaryToolbarButton();
1202
1318
  } else {
1203
1319
  for (const [filePath, fileSummaries] of byFile.entries()) {
1204
1320
  this._applyHunkSummaries(filePath, fileSummaries);
@@ -1228,6 +1344,7 @@ class PRManager {
1228
1344
  if (!Array.isArray(summaries)) return;
1229
1345
  const allowedHashes = this._summaryHashesByFile.get(filePath) || new Set();
1230
1346
  let mountedAny = false;
1347
+ let availableAny = false;
1231
1348
  for (const summary of summaries) {
1232
1349
  if (!summary?.content_hash) continue;
1233
1350
  if (allowedHashes.size > 0 && !allowedHashes.has(summary.content_hash)) {
@@ -1240,15 +1357,26 @@ class PRManager {
1240
1357
  }
1241
1358
  continue;
1242
1359
  }
1243
- const row = this._renderOneSummary(summary);
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);
1244
1366
  if (row) mountedAny = true;
1245
1367
  }
1368
+ if (availableAny) this._summariesAvailable = true;
1246
1369
  if (mountedAny) {
1247
- // At least one summary mounted — the feature now has data, so the
1248
- // toolbar button can show its `.active` (blue) state.
1370
+ // At least one summary mounted — the feature now has visible data, so
1371
+ // the toolbar button can show its `.active` (blue) state.
1249
1372
  this._summariesGenerated = true;
1250
- this._syncSummaryToolbarButton();
1251
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();
1252
1380
  if (mountedAny && filePath) this._refreshFileSummaryToggle(filePath);
1253
1381
  }
1254
1382
 
@@ -1276,7 +1404,7 @@ class PRManager {
1276
1404
  *
1277
1405
  * Used by three call sites that must agree on the button's visible state:
1278
1406
  * - createFileHeader (initial render)
1279
- * - _kickOffHunkSummaries (rehydrate after localStorage restore)
1407
+ * - _fetchHunkSummaryMap (rehydrate after localStorage restore)
1280
1408
  * - _refreshFileSummaryToggle (when summaries arrive late)
1281
1409
  * - toggleFileSummaries (user click)
1282
1410
  *
@@ -1298,22 +1426,91 @@ class PRManager {
1298
1426
  }
1299
1427
  }
1300
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
+
1301
1495
  /**
1302
1496
  * Render a single summary row, or queue it if the matching hunk hasn't
1303
1497
  * been hashed yet (race between WS broadcast and post-render hashing).
1304
1498
  * Trivial / model-skipped / model-malformed rows are ignored.
1305
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.
1306
1503
  * @returns {HTMLTableRowElement|null} The mounted row, or null if queued/skipped.
1307
1504
  */
1308
- _renderOneSummary(summary) {
1505
+ _renderOneSummary(summary, filePath) {
1309
1506
  if (!summary || !summary.content_hash) return null;
1310
1507
  if (!summary.summary_text) return null; // trivial / opt-out — nothing to show
1311
1508
  const hash = summary.content_hash;
1312
- const anchor = this._summaryAnchorsByHash.get(hash);
1509
+ const anchor = this._findSummaryAnchor(filePath, hash);
1313
1510
  if (!anchor || !anchor.isConnected) {
1314
1511
  // Anchor missing or detached (stale render) → defer; the next render
1315
1512
  // pass that re-establishes the hash will drain this map.
1316
- this._pendingSummariesByHash.set(hash, summary);
1513
+ this._queuePendingSummary(filePath, summary);
1317
1514
  return null;
1318
1515
  }
1319
1516
  if (!this.hunkSummaryRenderer) return null;
@@ -1423,9 +1620,12 @@ class PRManager {
1423
1620
  // opens a confirm dialog ("Cancel Summaries" / "OK") instead of
1424
1621
  // toggling visibility. See _handleSummaryToggleClick.
1425
1622
  label = 'Generating summaries… (click to cancel)';
1426
- } else if (!this._summariesGenerated) {
1427
- // Pre-generated state: nothing generated yet. Colorless button; a click
1428
- // kicks off generation. See _handleSummaryToggleClick.
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.
1429
1629
  label = 'Generate hunk summaries';
1430
1630
  } else {
1431
1631
  label = this._summariesHidden ? 'Show hunk summaries' : 'Hide hunk summaries';
@@ -1505,10 +1705,14 @@ class PRManager {
1505
1705
  });
1506
1706
  return;
1507
1707
  }
1508
- if (!this._summariesGenerated) {
1509
- // Pre-generated state: a click triggers generation rather than toggling
1510
- // visibility. `_startGenerationJob` sets the pulsing `.generating` state
1511
- // optimistically (there is no `review:background_job_started` event).
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.
1512
1716
  await this._startGenerationJob('summary');
1513
1717
  return;
1514
1718
  }
@@ -1594,18 +1798,23 @@ class PRManager {
1594
1798
  const url = isLocal
1595
1799
  ? `/api/local/${encodeURIComponent(pr.id)}/jobs/${encodeURIComponent(jobKey)}/start`
1596
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.';
1597
1807
  try {
1598
1808
  const resp = await fetch(url, { method: 'POST' });
1599
1809
  if (resp.status === 409) {
1600
1810
  // Feature disabled in config — shouldn't happen (the button is hidden
1601
1811
  // when disabled) but surface it rather than failing silently.
1602
- // NOTE: the toast singleton is lowercase `window.toast` (see
1603
- // cancel-background-job.js); `window.Toast` does not exist.
1604
- if (window.toast?.error) window.toast.error('This feature is disabled in config.');
1812
+ window.toast?.showError?.('This feature is disabled in config.');
1605
1813
  return;
1606
1814
  }
1607
1815
  if (!resp.ok) {
1608
1816
  console.warn(`[StartJob] ${jobKey} start POST failed: ${resp.status}`);
1817
+ window.toast?.showError?.(`Failed to start ${featureLabel} generation (HTTP ${resp.status}).`);
1609
1818
  return;
1610
1819
  }
1611
1820
  // Optimistic UI: there is no `review:background_job_started` broadcast,
@@ -1623,9 +1832,20 @@ class PRManager {
1623
1832
  this._tourGenerating = true;
1624
1833
  this._syncTourToolbarButton();
1625
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.`);
1626
1845
  }
1627
1846
  } catch (err) {
1628
1847
  console.warn(`[StartJob] ${jobKey} start POST error:`, err.message);
1848
+ window.toast?.showError?.(`Failed to start ${featureLabel} generation: ${err.message}`);
1629
1849
  }
1630
1850
  }
1631
1851
 
@@ -1836,7 +2056,11 @@ class PRManager {
1836
2056
  // was awaiting a file fetch / loadContextFiles. Bail before
1837
2057
  // mounting against a torn-down tour.
1838
2058
  if (isStale()) return;
1839
- const row = this._tourRenderer.mountStop(probe);
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;
1840
2064
  if (row) {
1841
2065
  nextRow = row;
1842
2066
  nextIndex = probe;
@@ -2437,13 +2661,32 @@ class PRManager {
2437
2661
 
2438
2662
  // Update Graphite link (gated on enable_graphite config)
2439
2663
  const graphiteLink = document.getElementById('graphite-link');
2440
- if (graphiteLink && pr.html_url && window.__pairReview?.enableGraphite) {
2664
+ if (graphiteLink && pr.html_url && window.__pairReview?.enableGraphite
2665
+ && graphiteLink.dataset.suppressed !== 'true') {
2441
2666
  // Derive from html_url to preserve GitHub's original casing (Graphite URLs are case-sensitive)
2442
2667
  const graphiteUrl = window.__pairReview.toGraphiteUrl(pr.html_url);
2443
2668
  graphiteLink.href = graphiteUrl;
2444
2669
  graphiteLink.style.display = '';
2445
2670
  }
2446
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
+
2447
2690
  // Update settings link
2448
2691
  const settingsLink = document.getElementById('settings-link');
2449
2692
  if (settingsLink && pr.owner && pr.repo) {
@@ -2744,13 +2987,20 @@ class PRManager {
2744
2987
  }
2745
2988
 
2746
2989
  // Fetch settings in parallel
2747
- const [repoSettings, reviewSettings] = await Promise.all([
2990
+ const [repoSettings, reviewSettings, appConfig] = await Promise.all([
2748
2991
  this.fetchRepoSettings().catch(() => null),
2749
- this.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null }))
2992
+ this.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null })),
2993
+ this._getAppConfig()
2750
2994
  ]);
2751
2995
 
2752
- const currentModel = repoSettings?.default_model || 'opus';
2753
- 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);
2754
3004
  const tabStorageKey = PRManager.getRepoStorageKey('pair-review-tab', owner, repo);
2755
3005
  const rememberedTab = localStorage.getItem(tabStorageKey);
2756
3006
  const defaultTab = rememberedTab || repoSettings?.default_tab || 'single';
@@ -2957,14 +3207,22 @@ class PRManager {
2957
3207
  // Don't show if no pending draft
2958
3208
  if (!pendingDraft) return;
2959
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
+
2960
3218
  // Create the indicator
2961
3219
  const indicator = document.createElement('a');
2962
3220
  indicator.id = 'pending-draft-indicator';
2963
3221
  indicator.className = 'pending-draft-indicator';
2964
- indicator.href = pendingDraft.github_url || '#';
3222
+ indicator.href = externalUrl || pendingDraft.github_url || '#';
2965
3223
  indicator.target = '_blank';
2966
3224
  indicator.rel = 'noopener noreferrer';
2967
- indicator.title = 'View your pending draft review on GitHub';
3225
+ indicator.title = `View your pending draft review on ${hostName}`;
2968
3226
 
2969
3227
  const commentCount = pendingDraft.comments_count || 0;
2970
3228
  const commentText = commentCount === 1 ? '1 comment' : `${commentCount} comments`;
@@ -2973,7 +3231,7 @@ class PRManager {
2973
3231
  <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
2974
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"/>
2975
3233
  </svg>
2976
- <span class="pending-draft-text">Draft on GitHub (${commentText})</span>
3234
+ <span class="pending-draft-text">Draft on ${this.escapeHtml(hostName)} (${commentText})</span>
2977
3235
  `;
2978
3236
 
2979
3237
  // Insert after the commit element (or at the end of toolbar-meta)
@@ -3022,10 +3280,23 @@ class PRManager {
3022
3280
  // no matching rows keeps the stale `true`, leaving the toolbar stuck in
3023
3281
  // Hide/Show mode with nothing in the DOM and blocking click-to-generate.
3024
3282
  this._summariesGenerated = false;
3025
- // Bump generation so any in-flight `_kickOffHunkSummaries` from the
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
3026
3287
  // previous render bails out instead of mutating maps we just reset.
3027
3288
  this._renderGen = (this._renderGen || 0) + 1;
3028
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
+
3029
3300
  // Use changed_files array from API
3030
3301
  const files = pr.changed_files || pr.files || [];
3031
3302
 
@@ -3050,9 +3321,8 @@ class PRManager {
3050
3321
  }
3051
3322
  });
3052
3323
 
3053
- // Async validate end-of-file gaps - removes any that have no trailing lines
3054
- // This runs after render to avoid blocking initial display
3055
- this.validatePendingEofGaps();
3324
+ // NOTE: end-of-file gap validation runs per-file inside _renderFileBodyNow
3325
+ // now (bodies render lazily), not once globally here.
3056
3326
  } else {
3057
3327
  diffContainer.innerHTML = '<div class="no-diff">No files changed</div>';
3058
3328
  }
@@ -3061,11 +3331,13 @@ class PRManager {
3061
3331
  this.contextFiles = [];
3062
3332
  this.loadContextFiles();
3063
3333
 
3064
- // Kick off hunk-summary hashing + load (Phase 5). Fire-and-forget — the
3065
- // diff is fully usable while summaries arrive asynchronously.
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.
3066
3338
  if (this.hunkSummaryRenderer) {
3067
- this._kickOffHunkSummaries().catch((err) => {
3068
- console.warn('[HunkSummary] kickoff failed:', err);
3339
+ this._fetchHunkSummaryMap().catch((err) => {
3340
+ console.warn('[HunkSummary] summary fetch failed:', err);
3069
3341
  });
3070
3342
  }
3071
3343
 
@@ -3174,8 +3446,8 @@ class PRManager {
3174
3446
 
3175
3447
  // Per-file hunk-summary toggle. Mirrors the toolbar toggle but scoped to
3176
3448
  // one file. Disabled (greyed) when no summaries exist yet for this file;
3177
- // _applyHunkSummaries / _kickOffHunkSummaries re-enable it as soon as
3178
- // hashes for this file are recorded (so a summary that arrives later
3449
+ // _applyHunkSummaries / _registerHunkAnchorsForFile re-enable it as soon
3450
+ // as hashes for this file are recorded (so a summary that arrives later
3179
3451
  // doesn't get hidden behind a permanently-disabled button).
3180
3452
  // Gated on `_summariesEnabled`: skipped entirely when /api/config has
3181
3453
  // already reported the feature is off; created hidden + revealed later
@@ -3210,21 +3482,13 @@ class PRManager {
3210
3482
  }
3211
3483
  }
3212
3484
 
3213
- // Create diff table
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.
3214
3489
  const table = document.createElement('table');
3215
3490
  table.className = 'd2h-diff-table';
3216
-
3217
3491
  const tbody = document.createElement('tbody');
3218
-
3219
- // Parse the diff content
3220
- if (file.patch) {
3221
- this.renderPatch(tbody, file.patch, file.file, file.hunk_hashes || null);
3222
- } else if (file.binary) {
3223
- const row = document.createElement('tr');
3224
- row.innerHTML = '<td colspan="2" class="binary-file">Binary file</td>';
3225
- tbody.appendChild(row);
3226
- }
3227
-
3228
3492
  table.appendChild(tbody);
3229
3493
 
3230
3494
  // Wrap table in a scrollable container for horizontal scroll of long code lines
@@ -3234,9 +3498,197 @@ class PRManager {
3234
3498
  fileBody.appendChild(table);
3235
3499
  wrapper.appendChild(fileBody);
3236
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
+
3237
3530
  return wrapper;
3238
3531
  }
3239
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
+
3240
3692
  /**
3241
3693
  * Parse and render a unified diff patch
3242
3694
  * @param {HTMLElement} tbody - Table body element
@@ -3389,8 +3841,8 @@ class PRManager {
3389
3841
 
3390
3842
  // Record this hunk's first rendered code row as the anchor for any
3391
3843
  // inline summary annotation. The canonical hash comes from the
3392
- // backend (`hunkHashes[blockIndex]`); _kickOffHunkSummaries mounts
3393
- // it as `data-hunk-start` after render finishes so the summary
3844
+ // backend (`hunkHashes[blockIndex]`); _registerHunkAnchorsForFile mounts
3845
+ // it as `data-hunk-start` when this file's body renders so the summary
3394
3846
  // renderer can find the anchor and insert the annotation above it.
3395
3847
  if (this._pendingHunkRecords && firstLineRow) {
3396
3848
  const serverHash = Array.isArray(hunkHashes) ? hunkHashes[blockIndex] || null : null;
@@ -3574,7 +4026,7 @@ class PRManager {
3574
4026
  * Toggle collapse state of a file diff
3575
4027
  * @param {string} filePath - Path of the file
3576
4028
  */
3577
- toggleFileCollapse(filePath) {
4029
+ async toggleFileCollapse(filePath) {
3578
4030
  const wrapper = this.findFileElement(filePath);
3579
4031
  if (!wrapper) return;
3580
4032
 
@@ -3582,6 +4034,8 @@ class PRManager {
3582
4034
  const header = wrapper.querySelector('.d2h-file-header');
3583
4035
 
3584
4036
  if (isCollapsed) {
4037
+ // Render the body before revealing it (lazy bodies are empty until now).
4038
+ await this.ensureFileBodyRendered(filePath);
3585
4039
  wrapper.classList.remove('collapsed');
3586
4040
  this.collapsedFiles.delete(filePath);
3587
4041
  } else {
@@ -3600,7 +4054,7 @@ class PRManager {
3600
4054
  * @param {string} filePath - Path of the file
3601
4055
  * @param {boolean} isViewed - Whether the file is now viewed
3602
4056
  */
3603
- toggleFileViewed(filePath, isViewed) {
4057
+ async toggleFileViewed(filePath, isViewed) {
3604
4058
  const wrapper = this.findFileElement(filePath);
3605
4059
 
3606
4060
  if (isViewed) {
@@ -3618,6 +4072,8 @@ class PRManager {
3618
4072
  this.viewedFiles.delete(filePath);
3619
4073
  // Auto-expand when unchecking viewed (match GitHub behavior)
3620
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);
3621
4077
  wrapper.classList.remove('collapsed');
3622
4078
  this.collapsedFiles.delete(filePath);
3623
4079
  const header = wrapper.querySelector('.d2h-file-header');
@@ -3743,7 +4199,7 @@ class PRManager {
3743
4199
  * @deprecated Use toggleFileCollapse instead - kept for backward compatibility
3744
4200
  */
3745
4201
  toggleGeneratedFile(filePath) {
3746
- this.toggleFileCollapse(filePath);
4202
+ return this.toggleFileCollapse(filePath);
3747
4203
  }
3748
4204
 
3749
4205
  /**
@@ -3772,9 +4228,12 @@ class PRManager {
3772
4228
  * Validate pending end-of-file gaps asynchronously
3773
4229
  * Removes gap rows where there are no trailing lines to expand
3774
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).
3775
4234
  */
3776
- async validatePendingEofGaps() {
3777
- 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"]');
3778
4237
 
3779
4238
  // Process all pending gaps in parallel for efficiency
3780
4239
  const validationPromises = Array.from(pendingGaps).map(async (gapRow) => {
@@ -4139,11 +4598,14 @@ class PRManager {
4139
4598
  return false;
4140
4599
  }
4141
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
+
4142
4605
  // Check if file is collapsed (generated files)
4143
4606
  if (fileElement.classList.contains('collapsed')) {
4144
4607
  debugLog?.('expandForSuggestion', 'File is collapsed, expanding first');
4145
- this.toggleGeneratedFile(file);
4146
- await new Promise(resolve => setTimeout(resolve, 50));
4608
+ await this.toggleGeneratedFile(file);
4147
4609
  }
4148
4610
 
4149
4611
  // Find the gap section containing the target lines using the shared module
@@ -4227,6 +4689,12 @@ class PRManager {
4227
4689
  const fileElement = this.findFileElement(file);
4228
4690
  if (!fileElement) continue;
4229
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
+
4230
4698
  // Check if any line in the range is already visible
4231
4699
  let anyLineVisible = false;
4232
4700
  const lineRows = fileElement.querySelectorAll('tr');
@@ -5901,9 +6369,20 @@ class PRManager {
5901
6369
  collapsedBtn.addEventListener('click', () => toggleSidebar(false));
5902
6370
  }
5903
6371
 
5904
- scrollToFile(filePath) {
6372
+ async scrollToFile(filePath) {
5905
6373
  const fileWrapper = this.findFileElement(filePath);
5906
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
+ }
5907
6386
  fileWrapper.scrollIntoView({ behavior: 'smooth', block: 'start' });
5908
6387
  }
5909
6388
  }
@@ -6328,11 +6807,15 @@ class PRManager {
6328
6807
  : this._fetchStaleness(owner, repo, number);
6329
6808
  this._stalenessPromise = null; // consume it
6330
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)}`;
6331
6814
  const [staleResult, repoSettings, reviewSettings, appConfig] = await Promise.all([
6332
6815
  staleCheckWithTimeout,
6333
6816
  this.fetchRepoSettings(),
6334
6817
  this.fetchLastReviewSettings(),
6335
- fetch('/api/config').then(r => r.ok ? r.json() : {}).catch(() => ({}))
6818
+ fetch(configUrl).then(r => r.ok ? r.json() : {}).catch(() => ({}))
6336
6819
  ]);
6337
6820
  console.debug(`[Analyze] parallel-fetch (stale+settings): ${Math.round(performance.now() - _tParallel0)}ms`);
6338
6821
 
@@ -6378,9 +6861,14 @@ class PRManager {
6378
6861
 
6379
6862
  const lastCouncilId = reviewSettings.last_council_id;
6380
6863
 
6381
- // Determine the model and provider to use (priority: repo default > defaults)
6382
- const currentModel = repoSettings?.default_model || 'opus';
6383
- 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);
6384
6872
 
6385
6873
  // Determine default tab (priority: localStorage > repo settings > 'single')
6386
6874
  const tabStorageKey = PRManager.getRepoStorageKey('pair-review-tab', owner, repo);
@@ -6408,7 +6896,12 @@ class PRManager {
6408
6896
  lastCouncilId,
6409
6897
  defaultCouncilId: repoSettings?.default_council_id || null,
6410
6898
  hasPr: true,
6411
- 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
+ )
6412
6905
  });
6413
6906
 
6414
6907
  // If user cancelled, do nothing
@@ -6554,7 +7047,10 @@ class PRManager {
6554
7047
  showWorktreeNotFoundError(owner, repo, number) {
6555
7048
  let setupUrl = `/pr/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(number)}`;
6556
7049
  if (this._autoAnalyzeRequested) {
6557
- 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()}`;
6558
7054
  }
6559
7055
  const container = document.getElementById('pr-container');
6560
7056
  if (container) {