@in-the-loop-labs/pair-review 3.6.0 → 3.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) 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 +17 -1737
  8. package/public/index.html +11 -0
  9. package/public/js/components/AIPanel.js +89 -44
  10. package/public/js/components/AdvancedConfigTab.js +56 -4
  11. package/public/js/components/AnalysisConfigModal.js +41 -25
  12. package/public/js/components/ChatPanel.js +11 -1
  13. package/public/js/components/ReviewModal.js +135 -13
  14. package/public/js/components/SuggestionNavigator.js +55 -10
  15. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  16. package/public/js/index.js +175 -16
  17. package/public/js/local.js +58 -8
  18. package/public/js/modules/suggestion-manager.js +25 -1
  19. package/public/js/modules/tour-renderer.js +45 -5
  20. package/public/js/pr.js +703 -171
  21. package/public/js/repo-links.js +328 -0
  22. package/public/js/utils/provider-model.js +88 -0
  23. package/public/js/utils/scroll-into-view.js +164 -0
  24. package/public/js/utils/storage-keys.js +50 -0
  25. package/public/local.html +10 -0
  26. package/public/pr.html +10 -0
  27. package/public/repo-settings.html +1 -0
  28. package/public/setup.html +2 -0
  29. package/src/ai/analyzer.js +125 -18
  30. package/src/ai/claude-provider.js +31 -3
  31. package/src/config.js +664 -10
  32. package/src/external/github-adapter.js +114 -25
  33. package/src/git/base-branch.js +11 -4
  34. package/src/github/client.js +482 -588
  35. package/src/github/errors.js +55 -0
  36. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  37. package/src/github/impl/graphql/pending-review.js +153 -0
  38. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  39. package/src/github/impl/graphql/stack-walker.js +210 -0
  40. package/src/github/impl/host/pending-review-comments.js +338 -0
  41. package/src/github/impl/rest/pending-review.js +251 -0
  42. package/src/github/impl/rest/review-lifecycle.js +226 -0
  43. package/src/github/impl/rest/stack-walker.js +309 -0
  44. package/src/github/operations/pending-review-comments.js +79 -0
  45. package/src/github/operations/pending-review.js +89 -0
  46. package/src/github/operations/review-lifecycle.js +126 -0
  47. package/src/github/operations/stack-walker.js +87 -0
  48. package/src/github/parser.js +230 -4
  49. package/src/github/stack-walker.js +14 -189
  50. package/src/links/repo-links.js +230 -0
  51. package/src/local-review.js +13 -4
  52. package/src/main.js +136 -32
  53. package/src/routes/analyses.js +30 -7
  54. package/src/routes/bulk-analysis-configs.js +295 -0
  55. package/src/routes/config.js +102 -2
  56. package/src/routes/external-comments.js +20 -10
  57. package/src/routes/github-collections.js +3 -1
  58. package/src/routes/local.js +101 -11
  59. package/src/routes/mcp.js +47 -4
  60. package/src/routes/pr.js +298 -68
  61. package/src/routes/setup.js +8 -3
  62. package/src/routes/stack-analysis.js +33 -9
  63. package/src/routes/worktrees.js +3 -2
  64. package/src/server.js +2 -0
  65. package/src/setup/pr-setup.js +37 -11
  66. package/src/setup/stack-setup.js +13 -3
  67. 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
@@ -372,6 +402,22 @@ class PRManager {
372
402
  }
373
403
  }
374
404
 
405
+ /**
406
+ * Keep --diff-file-header-height in sync with the rendered sticky file
407
+ * header so navigation (block:'start' + scroll-margin-top in pr.css) lands
408
+ * targets just below the header rather than hidden behind it. Headers are
409
+ * single-line and uniform, so measuring the first one is representative.
410
+ * Call after renderDiff appends the headers.
411
+ */
412
+ _measureFileHeaderHeight() {
413
+ const header = document.querySelector('.d2h-file-wrapper .d2h-file-header');
414
+ if (header && header.offsetHeight) {
415
+ document.documentElement.style.setProperty(
416
+ '--diff-file-header-height', header.offsetHeight + 'px'
417
+ );
418
+ }
419
+ }
420
+
375
421
  /**
376
422
  * Set up event handlers
377
423
  */
@@ -588,7 +634,7 @@ class PRManager {
588
634
  * @param {Object} reviewSettings - Review settings from fetchLastReviewSettings()
589
635
  * @returns {Promise<Object>} Config object suitable for startAnalysis / startLocalAnalysis
590
636
  */
591
- async _buildDefaultAnalysisConfig(repoSettings, reviewSettings) {
637
+ async _buildDefaultAnalysisConfig(repoSettings, reviewSettings, appConfig = {}, providersInfo = null) {
592
638
  const defaultTab = repoSettings?.default_tab || 'single';
593
639
  const councilId = repoSettings?.default_council_id || reviewSettings?.last_council_id || null;
594
640
 
@@ -617,13 +663,44 @@ class PRManager {
617
663
  };
618
664
  }
619
665
 
666
+ // Resolve provider and model as a MATCHED pair. Resolving each half
667
+ // independently (repo || app || hardcoded) can mix a provider from one
668
+ // scope with a model from another, yielding an invalid pair (e.g.
669
+ // gemini/opus) that startAnalysis would forward to the backend as-is.
670
+ const providers = providersInfo || await this._getProvidersInfo();
671
+ const { provider, model } = window.resolveProviderModelPair([
672
+ { provider: repoSettings?.default_provider, model: repoSettings?.default_model },
673
+ { provider: appConfig.default_provider, model: appConfig.default_model }
674
+ ], providers);
675
+
620
676
  return {
621
- provider: repoSettings?.default_provider || 'claude',
622
- model: repoSettings?.default_model || 'opus',
677
+ provider,
678
+ model,
623
679
  customInstructions: null
624
680
  };
625
681
  }
626
682
 
683
+ async _fetchAutoAnalysisConfigFromUrl() {
684
+ const params = new URLSearchParams(window.location.search);
685
+ const configId = params.get('analysisConfigId');
686
+ if (!configId) return { requested: false, config: null, error: null };
687
+
688
+ try {
689
+ const response = await fetch(`/api/bulk-analysis-configs/${encodeURIComponent(configId)}`);
690
+ if (!response.ok) {
691
+ throw new Error('Stored analysis settings were not found');
692
+ }
693
+ const data = await response.json();
694
+ if (!data.analysisConfig) {
695
+ throw new Error('Stored analysis settings response was empty');
696
+ }
697
+ return { requested: true, config: data.analysisConfig, error: null };
698
+ } catch (error) {
699
+ console.warn('Failed to fetch bulk analysis config:', error);
700
+ return { requested: true, config: null, error };
701
+ }
702
+ }
703
+
627
704
  /**
628
705
  * Auto-trigger analysis if ?analyze=true is present in the URL.
629
706
  * Skips refresh if data was just loaded fresh by loadPR (to avoid redundant fetches).
@@ -638,6 +715,7 @@ class PRManager {
638
715
  const autoAnalyze = new URLSearchParams(window.location.search).get('analyze');
639
716
  if (autoAnalyze === 'true' && !this.isAnalyzing) {
640
717
  this._autoAnalyzeRequested = true;
718
+ let shouldCleanUrl = true;
641
719
  try {
642
720
  // Skip refresh if we just loaded fresh data (loadPR sets _justLoaded = true).
643
721
  // Otherwise, refresh to ensure we have the latest PR data in case the worktree
@@ -654,19 +732,39 @@ class PRManager {
654
732
  }
655
733
  }
656
734
 
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);
735
+ const storedConfig = await this._fetchAutoAnalysisConfigFromUrl();
736
+ let config;
737
+ if (storedConfig.requested) {
738
+ if (!storedConfig.config) {
739
+ // The stored bulk-analysis config expired (TTL/eviction/restart).
740
+ // The PR diff has already rendered, so don't replace it with a
741
+ // full-screen error whose Retry button would just re-trigger the
742
+ // same failed lookup. Warn, strip the stale params (so a refresh
743
+ // won't re-trigger), and leave the PR usable for manual analysis.
744
+ const message = 'Could not load the selected bulk analysis settings. Start analysis manually to choose new settings.';
745
+ if (window.toast) window.toast.showWarning(message);
746
+ return;
747
+ }
748
+ config = storedConfig.config;
749
+ } else {
750
+ // Fetch repo settings so we honour the repository's default provider/council
751
+ const [repoSettings, reviewSettings, appConfig] = await Promise.all([
752
+ this.fetchRepoSettings().catch(() => null),
753
+ this.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null })),
754
+ this._getAppConfig()
755
+ ]);
756
+ config = await this._buildDefaultAnalysisConfig(repoSettings, reviewSettings, appConfig);
757
+ }
663
758
 
664
759
  await this.startAnalysis(owner, repo, prNumber, null, config);
665
760
  } finally {
666
761
  this._autoAnalyzeRequested = false;
667
- const cleanUrl = new URL(window.location);
668
- cleanUrl.searchParams.delete('analyze');
669
- history.replaceState(null, '', cleanUrl);
762
+ if (shouldCleanUrl) {
763
+ const cleanUrl = new URL(window.location);
764
+ cleanUrl.searchParams.delete('analyze');
765
+ cleanUrl.searchParams.delete('analysisConfigId');
766
+ history.replaceState(null, '', cleanUrl);
767
+ }
670
768
  }
671
769
  }
672
770
  }
@@ -1067,31 +1165,111 @@ class PRManager {
1067
1165
  }
1068
1166
 
1069
1167
  /**
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.
1168
+ * Fetch and cache the provider/model metadata from /api/providers. Used to
1169
+ * resolve a coherent provider/model pair for the non-modal auto-analyze path
1170
+ * (the modal loads its own copy). Resolves to the `providers` array, or [] on
1171
+ * failure so callers can fall back to provider-agnostic defaults.
1172
+ */
1173
+ _getProvidersInfo() {
1174
+ if (!this._providersInfoPromise) {
1175
+ this._providersInfoPromise = fetch('/api/providers')
1176
+ .then((r) => (r.ok ? r.json() : {}))
1177
+ .then((data) => (Array.isArray(data.providers) ? data.providers : []))
1178
+ .catch(() => []);
1179
+ }
1180
+ return this._providersInfoPromise;
1181
+ }
1182
+
1183
+ /**
1184
+ * Wire one file's hunk anchor rows to their server-supplied content hashes
1185
+ * as that file's body renders (called from _renderFileBodyNow), then mount
1186
+ * any summary that already arrived for those hunks.
1187
+ *
1188
+ * Pre-lazy-render this happened once globally in _kickOffHunkSummaries;
1189
+ * with lazy bodies a file's anchor rows don't exist until it renders, so
1190
+ * anchoring is now incremental. The server computes hashes from the
1191
+ * canonical (non-whitespace-filtered) diff so they stay aligned with
1192
+ * persisted summary keys regardless of `?w=1`; records lacking a
1193
+ * `contentHash` are logged-and-skipped.
1194
+ *
1195
+ * The bridge for "summary arrived before its anchor existed" is the existing
1196
+ * `_pendingSummariesByHash` map: _renderOneSummary / _applyHunkSummaries
1197
+ * queue there when no anchor is found, and we drain matching entries here.
1198
+ * @param {Array<{file,header,anchorRow,contentHash}>} records
1199
+ */
1200
+ _registerHunkAnchorsForFile(records) {
1201
+ if (!Array.isArray(records) || records.length === 0) return;
1202
+ const filesWithMounts = new Set();
1203
+ for (const rec of records) {
1204
+ if (!rec.anchorRow || !rec.anchorRow.isConnected) continue;
1205
+ const hex = rec.contentHash;
1206
+ if (!hex) {
1207
+ console.warn(
1208
+ `[HunkSummary] no server contentHash for ${rec.file} ` +
1209
+ `hunk ${rec.header}; skipping (summaries will not anchor here).`
1210
+ );
1211
+ continue;
1212
+ }
1213
+ rec.anchorRow.dataset.hunkStart = hex;
1214
+ // Scope the anchor by file so a hash collision across files can't let
1215
+ // the later file's anchor clobber the earlier file's (which would mount
1216
+ // a summary against the wrong hunk). Symmetric with the file-scoped
1217
+ // pending-summary queue.
1218
+ let anchors = this._summaryAnchorsByHash.get(rec.file);
1219
+ if (!anchors) {
1220
+ anchors = new Map();
1221
+ this._summaryAnchorsByHash.set(rec.file, anchors);
1222
+ }
1223
+ anchors.set(hex, rec.anchorRow);
1224
+ let bucket = this._summaryHashesByFile.get(rec.file);
1225
+ if (!bucket) {
1226
+ bucket = new Set();
1227
+ this._summaryHashesByFile.set(rec.file, bucket);
1228
+ }
1229
+ bucket.add(hex);
1230
+
1231
+ // Mount a summary that arrived before this anchor existed. Look it up by
1232
+ // THIS file + hash so a content-hash that also appears in another file
1233
+ // (renamed-with-tiny-edits, copy-pasted boilerplate, identical stubs)
1234
+ // can't let this file's anchor consume the other file's queued summary.
1235
+ const pending = this._takePendingSummary(rec.file, hex);
1236
+ if (pending) {
1237
+ const row = this._renderOneSummary(pending, rec.file);
1238
+ if (row) {
1239
+ this._summariesGenerated = true;
1240
+ this._summariesAvailable = true;
1241
+ filesWithMounts.add(rec.file);
1242
+ }
1243
+ // If it couldn't mount (anchor not connected after all),
1244
+ // _renderOneSummary re-queued it scoped to rec.file for a later pass.
1245
+ }
1246
+ }
1247
+ if (this._summariesGenerated) this._syncSummaryToolbarButton();
1248
+ for (const filePath of filesWithMounts) this._refreshFileSummaryToggle(filePath);
1249
+ }
1250
+
1251
+ /**
1252
+ * Load the review's hunk summaries from the server and apply them to the
1253
+ * anchors that exist so far (gated by `summaries.enabled` in `/api/config`).
1254
+ *
1255
+ * With lazy bodies, anchor wiring is incremental (_registerHunkAnchorsForFile
1256
+ * runs as each body renders), so this method no longer walks render records.
1257
+ * Summaries whose file hasn't rendered yet are queued in
1258
+ * `_pendingSummariesByHash` (via _applyHunkSummaries / _renderOneSummary) and
1259
+ * drained when that body renders.
1074
1260
  *
1075
- * Order matters:
1076
- * 1. Config gate first — bail before paying any setup cost when the
1077
- * feature is off.
1261
+ * Order:
1262
+ * 1. Config gate first — bail before paying any cost when the feature is off.
1078
1263
  * 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.
1264
+ * 3. Fetch existing summaries and apply/queue them.
1085
1265
  *
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.
1266
+ * Race-safety: `_renderGen` is captured at entry and rechecked after every
1267
+ * `await`. If `renderDiff()` ran again mid-flight, we stop touching the (now
1268
+ * stale) maps and DOM.
1089
1269
  * @returns {Promise<void>}
1090
1270
  */
1091
- async _kickOffHunkSummaries() {
1271
+ async _fetchHunkSummaryMap() {
1092
1272
  const gen = this._renderGen;
1093
- const records = this._pendingHunkRecords || [];
1094
- this._pendingHunkRecords = null; // single-use; renderDiff resets
1095
1273
 
1096
1274
  // 1. Config gate — bail before doing any work when the feature is off.
1097
1275
  const cfg = await this._getAppConfig();
@@ -1119,54 +1297,7 @@ class PRManager {
1119
1297
  }
1120
1298
  }
1121
1299
 
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.
1300
+ // 3. Load existing summaries from the server.
1170
1301
  if (!this.currentPR?.id) return;
1171
1302
 
1172
1303
  try {
@@ -1192,13 +1323,14 @@ class PRManager {
1192
1323
  // `_applyHunkSummaries`, which sets the flag on a successful mount.
1193
1324
  if (byFile.size === 0 && summaries.length > 0) {
1194
1325
  let mountedAny = false;
1326
+ let availableAny = false;
1195
1327
  for (const summary of summaries) {
1328
+ if (summary.summary_text) availableAny = true;
1196
1329
  if (this._renderOneSummary(summary)) mountedAny = true;
1197
1330
  }
1198
- if (mountedAny) {
1199
- this._summariesGenerated = true;
1200
- this._syncSummaryToolbarButton();
1201
- }
1331
+ if (availableAny) this._summariesAvailable = true;
1332
+ if (mountedAny) this._summariesGenerated = true;
1333
+ if (mountedAny || availableAny) this._syncSummaryToolbarButton();
1202
1334
  } else {
1203
1335
  for (const [filePath, fileSummaries] of byFile.entries()) {
1204
1336
  this._applyHunkSummaries(filePath, fileSummaries);
@@ -1228,6 +1360,7 @@ class PRManager {
1228
1360
  if (!Array.isArray(summaries)) return;
1229
1361
  const allowedHashes = this._summaryHashesByFile.get(filePath) || new Set();
1230
1362
  let mountedAny = false;
1363
+ let availableAny = false;
1231
1364
  for (const summary of summaries) {
1232
1365
  if (!summary?.content_hash) continue;
1233
1366
  if (allowedHashes.size > 0 && !allowedHashes.has(summary.content_hash)) {
@@ -1240,15 +1373,26 @@ class PRManager {
1240
1373
  }
1241
1374
  continue;
1242
1375
  }
1243
- const row = this._renderOneSummary(summary);
1376
+ // A non-trivial summary that belongs to this render (it passed the hash
1377
+ // filter) means the feature has data even if its body hasn't rendered
1378
+ // yet — _renderOneSummary will queue it rather than mount it in that
1379
+ // case. Track availability separately from mounted rows.
1380
+ if (summary.summary_text) availableAny = true;
1381
+ const row = this._renderOneSummary(summary, filePath);
1244
1382
  if (row) mountedAny = true;
1245
1383
  }
1384
+ if (availableAny) this._summariesAvailable = true;
1246
1385
  if (mountedAny) {
1247
- // At least one summary mounted — the feature now has data, so the
1248
- // toolbar button can show its `.active` (blue) state.
1386
+ // At least one summary mounted — the feature now has visible data, so
1387
+ // the toolbar button can show its `.active` (blue) state.
1249
1388
  this._summariesGenerated = true;
1250
- this._syncSummaryToolbarButton();
1251
1389
  }
1390
+ // Refresh the toolbar when either flag changed: `.active` tracks
1391
+ // `_summariesGenerated` (rows in the DOM) while the Generate-vs-Show/Hide
1392
+ // decision tracks `_summariesAvailable` (mounted OR queued). Refreshing on
1393
+ // availableAny prevents a not-yet-rendered file from leaving the toolbar
1394
+ // in "Generate" mode, where a click would start a duplicate job.
1395
+ if (mountedAny || availableAny) this._syncSummaryToolbarButton();
1252
1396
  if (mountedAny && filePath) this._refreshFileSummaryToggle(filePath);
1253
1397
  }
1254
1398
 
@@ -1276,7 +1420,7 @@ class PRManager {
1276
1420
  *
1277
1421
  * Used by three call sites that must agree on the button's visible state:
1278
1422
  * - createFileHeader (initial render)
1279
- * - _kickOffHunkSummaries (rehydrate after localStorage restore)
1423
+ * - _fetchHunkSummaryMap (rehydrate after localStorage restore)
1280
1424
  * - _refreshFileSummaryToggle (when summaries arrive late)
1281
1425
  * - toggleFileSummaries (user click)
1282
1426
  *
@@ -1298,22 +1442,91 @@ class PRManager {
1298
1442
  }
1299
1443
  }
1300
1444
 
1445
+ /**
1446
+ * Queue a summary that arrived before its hunk anchor existed, scoped by
1447
+ * file path so a content-hash collision across files can't let one file's
1448
+ * anchor consume another file's queued summary. Summaries arriving without
1449
+ * a file path (legacy ungrouped fetch) land in the '' bucket.
1450
+ * @param {string|undefined} filePath
1451
+ * @param {Object} summary - must have `content_hash`
1452
+ */
1453
+ _queuePendingSummary(filePath, summary) {
1454
+ const key = filePath || '';
1455
+ let bucket = this._pendingSummariesByHash.get(key);
1456
+ if (!bucket) {
1457
+ bucket = new Map();
1458
+ this._pendingSummariesByHash.set(key, bucket);
1459
+ }
1460
+ bucket.set(summary.content_hash, summary);
1461
+ }
1462
+
1463
+ /**
1464
+ * Pull (and remove) a queued summary for (filePath, hash). Checks the
1465
+ * file-scoped bucket first, then the '' bucket that holds summaries queued
1466
+ * without a file path (legacy ungrouped fetch). Returns null when nothing
1467
+ * is queued for that pair.
1468
+ * @param {string|undefined} filePath
1469
+ * @param {string} hash
1470
+ * @returns {Object|null}
1471
+ */
1472
+ _takePendingSummary(filePath, hash) {
1473
+ for (const key of [filePath || '', '']) {
1474
+ const bucket = this._pendingSummariesByHash.get(key);
1475
+ if (bucket && bucket.has(hash)) {
1476
+ const summary = bucket.get(hash);
1477
+ bucket.delete(hash);
1478
+ if (bucket.size === 0) this._pendingSummariesByHash.delete(key);
1479
+ return summary;
1480
+ }
1481
+ }
1482
+ return null;
1483
+ }
1484
+
1485
+ /**
1486
+ * Resolve the anchor row for (filePath, hash) against the file-scoped
1487
+ * `_summaryAnchorsByHash` map.
1488
+ *
1489
+ * When `filePath` is known (the grouped path), the lookup is strictly
1490
+ * scoped to that file so a content-hash shared across files can't pull a
1491
+ * summary onto the wrong file's anchor. When it's absent — the legacy
1492
+ * ungrouped-fetch fallback where summaries arrive without a `file_path` —
1493
+ * there's no file to scope by, so we accept the first file whose bucket
1494
+ * carries the hash. This mirrors `_takePendingSummary`'s `''` fallback
1495
+ * bucket: file-scoped first, best-effort otherwise.
1496
+ * @param {string|undefined} filePath
1497
+ * @param {string} hash
1498
+ * @returns {HTMLTableRowElement|null}
1499
+ */
1500
+ _findSummaryAnchor(filePath, hash) {
1501
+ if (filePath) {
1502
+ return this._summaryAnchorsByHash.get(filePath)?.get(hash) || null;
1503
+ }
1504
+ for (const anchors of this._summaryAnchorsByHash.values()) {
1505
+ const anchor = anchors.get(hash);
1506
+ if (anchor) return anchor;
1507
+ }
1508
+ return null;
1509
+ }
1510
+
1301
1511
  /**
1302
1512
  * Render a single summary row, or queue it if the matching hunk hasn't
1303
1513
  * been hashed yet (race between WS broadcast and post-render hashing).
1304
1514
  * Trivial / model-skipped / model-malformed rows are ignored.
1305
1515
  * @param {Object} summary - { content_hash, summary_text, trivial_reason }
1516
+ * @param {string} [filePath] - File the summary belongs to. Used to scope
1517
+ * both the anchor lookup and the pending-queue key so a cross-file hash
1518
+ * collision can't misroute it to the wrong file's hunk.
1306
1519
  * @returns {HTMLTableRowElement|null} The mounted row, or null if queued/skipped.
1307
1520
  */
1308
- _renderOneSummary(summary) {
1521
+ _renderOneSummary(summary, filePath) {
1309
1522
  if (!summary || !summary.content_hash) return null;
1310
1523
  if (!summary.summary_text) return null; // trivial / opt-out — nothing to show
1311
1524
  const hash = summary.content_hash;
1312
- const anchor = this._summaryAnchorsByHash.get(hash);
1525
+ const anchor = this._findSummaryAnchor(filePath, hash);
1313
1526
  if (!anchor || !anchor.isConnected) {
1314
1527
  // Anchor missing or detached (stale render) → defer; the next render
1315
1528
  // pass that re-establishes the hash will drain this map.
1316
- this._pendingSummariesByHash.set(hash, summary);
1529
+ this._queuePendingSummary(filePath, summary);
1317
1530
  return null;
1318
1531
  }
1319
1532
  if (!this.hunkSummaryRenderer) return null;
@@ -1423,9 +1636,12 @@ class PRManager {
1423
1636
  // opens a confirm dialog ("Cancel Summaries" / "OK") instead of
1424
1637
  // toggling visibility. See _handleSummaryToggleClick.
1425
1638
  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.
1639
+ } else if (!this._summariesAvailable) {
1640
+ // Pre-generated state: nothing generated yet (mounted or queued).
1641
+ // Colorless button; a click kicks off generation. Gated on
1642
+ // `_summariesAvailable` (not `_summariesGenerated`) so a review whose
1643
+ // summaries exist but haven't mounted shows Show/Hide, matching the
1644
+ // click behavior in _handleSummaryToggleClick.
1429
1645
  label = 'Generate hunk summaries';
1430
1646
  } else {
1431
1647
  label = this._summariesHidden ? 'Show hunk summaries' : 'Hide hunk summaries';
@@ -1505,10 +1721,14 @@ class PRManager {
1505
1721
  });
1506
1722
  return;
1507
1723
  }
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).
1724
+ if (!this._summariesAvailable) {
1725
+ // Nothing generated yet (mounted OR queued): a click triggers generation
1726
+ // rather than toggling visibility. `_startGenerationJob` sets the pulsing
1727
+ // `.generating` state optimistically (there is no
1728
+ // `review:background_job_started` event). We gate on `_summariesAvailable`
1729
+ // rather than `_summariesGenerated` so summaries that exist server-side
1730
+ // but haven't mounted (their lazy file body isn't rendered yet) toggle
1731
+ // visibility instead of kicking off a duplicate generation job.
1512
1732
  await this._startGenerationJob('summary');
1513
1733
  return;
1514
1734
  }
@@ -1594,18 +1814,23 @@ class PRManager {
1594
1814
  const url = isLocal
1595
1815
  ? `/api/local/${encodeURIComponent(pr.id)}/jobs/${encodeURIComponent(jobKey)}/start`
1596
1816
  : `/api/pr/${encodeURIComponent(pr.owner)}/${encodeURIComponent(pr.repo)}/${encodeURIComponent(pr.number)}/jobs/${encodeURIComponent(jobKey)}/start`;
1817
+ // Phrasing varies by job kind. `featureLabel` goes into the HTTP/decline
1818
+ // error messages; `noDiffMessage` is the dedicated "nothing to do" line.
1819
+ // NOTE: the toast singleton is lowercase `window.toast` (see
1820
+ // cancel-background-job.js); `window.Toast` does not exist.
1821
+ const featureLabel = jobKey === 'tour' ? 'tour' : 'summary';
1822
+ const noDiffMessage = jobKey === 'tour' ? 'No tour to generate.' : 'No summaries to generate.';
1597
1823
  try {
1598
1824
  const resp = await fetch(url, { method: 'POST' });
1599
1825
  if (resp.status === 409) {
1600
1826
  // Feature disabled in config — shouldn't happen (the button is hidden
1601
1827
  // 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.');
1828
+ window.toast?.showError?.('This feature is disabled in config.');
1605
1829
  return;
1606
1830
  }
1607
1831
  if (!resp.ok) {
1608
1832
  console.warn(`[StartJob] ${jobKey} start POST failed: ${resp.status}`);
1833
+ window.toast?.showError?.(`Failed to start ${featureLabel} generation (HTTP ${resp.status}).`);
1609
1834
  return;
1610
1835
  }
1611
1836
  // Optimistic UI: there is no `review:background_job_started` broadcast,
@@ -1623,9 +1848,20 @@ class PRManager {
1623
1848
  this._tourGenerating = true;
1624
1849
  this._syncTourToolbarButton();
1625
1850
  }
1851
+ return;
1852
+ }
1853
+ // Server accepted the request but declined to enqueue. The known
1854
+ // reason today is `'no-diff'` — review has no changes to act on. Tell
1855
+ // the user so the button doesn't appear inert. Unknown decline
1856
+ // reasons fall through to a generic message rather than silence.
1857
+ if (payload.reason === 'no-diff') {
1858
+ window.toast?.showInfo?.(noDiffMessage);
1859
+ } else {
1860
+ window.toast?.showError?.(`Could not start ${featureLabel} generation.`);
1626
1861
  }
1627
1862
  } catch (err) {
1628
1863
  console.warn(`[StartJob] ${jobKey} start POST error:`, err.message);
1864
+ window.toast?.showError?.(`Failed to start ${featureLabel} generation: ${err.message}`);
1629
1865
  }
1630
1866
  }
1631
1867
 
@@ -1836,7 +2072,11 @@ class PRManager {
1836
2072
  // was awaiting a file fetch / loadContextFiles. Bail before
1837
2073
  // mounting against a torn-down tour.
1838
2074
  if (isStale()) return;
1839
- const row = this._tourRenderer.mountStop(probe);
2075
+ // mountStop is async (it awaits toggleFileCollapse so a collapsed
2076
+ // file is rendered + visibly expanded before we scroll to it).
2077
+ const row = await this._tourRenderer.mountStop(probe);
2078
+ // Re-check staleness: the await above is a suspension window.
2079
+ if (isStale()) return;
1840
2080
  if (row) {
1841
2081
  nextRow = row;
1842
2082
  nextIndex = probe;
@@ -2437,13 +2677,32 @@ class PRManager {
2437
2677
 
2438
2678
  // Update Graphite link (gated on enable_graphite config)
2439
2679
  const graphiteLink = document.getElementById('graphite-link');
2440
- if (graphiteLink && pr.html_url && window.__pairReview?.enableGraphite) {
2680
+ if (graphiteLink && pr.html_url && window.__pairReview?.enableGraphite
2681
+ && graphiteLink.dataset.suppressed !== 'true') {
2441
2682
  // Derive from html_url to preserve GitHub's original casing (Graphite URLs are case-sensitive)
2442
2683
  const graphiteUrl = window.__pairReview.toGraphiteUrl(pr.html_url);
2443
2684
  graphiteLink.href = graphiteUrl;
2444
2685
  graphiteLink.style.display = '';
2445
2686
  }
2446
2687
 
2688
+ if (window.RepoLinks && pr.owner && pr.repo) {
2689
+ const linksApplied = window.RepoLinks.fetchAndApplyRepoLinks(pr.owner, pr.repo, {
2690
+ owner: pr.owner,
2691
+ repo: pr.repo,
2692
+ number: pr.number,
2693
+ branch: pr.head_branch,
2694
+ base_branch: pr.base_branch,
2695
+ head_sha: pr.head_sha,
2696
+ });
2697
+ // The repo links (incl. the configured host name) resolve asynchronously
2698
+ // after this synchronous render. Re-render the pending-draft indicator
2699
+ // once they land so it shows the configured host name (e.g. "Meteorite")
2700
+ // instead of the "GitHub" fallback it would otherwise bake in below.
2701
+ if (linksApplied && typeof linksApplied.then === 'function') {
2702
+ linksApplied.then(() => this.updatePendingDraftIndicator(pr.pendingDraft));
2703
+ }
2704
+ }
2705
+
2447
2706
  // Update settings link
2448
2707
  const settingsLink = document.getElementById('settings-link');
2449
2708
  if (settingsLink && pr.owner && pr.repo) {
@@ -2744,13 +3003,20 @@ class PRManager {
2744
3003
  }
2745
3004
 
2746
3005
  // Fetch settings in parallel
2747
- const [repoSettings, reviewSettings] = await Promise.all([
3006
+ const [repoSettings, reviewSettings, appConfig] = await Promise.all([
2748
3007
  this.fetchRepoSettings().catch(() => null),
2749
- this.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null }))
3008
+ this.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null })),
3009
+ this._getAppConfig()
2750
3010
  ]);
2751
3011
 
2752
- const currentModel = repoSettings?.default_model || 'opus';
2753
- const currentProvider = repoSettings?.default_provider || 'claude';
3012
+ // Resolve provider and model as a MATCHED pair so the council/advanced tabs
3013
+ // are never seeded with a cross-provider model (e.g. gemini + opus), which
3014
+ // would blank the model <select> and be rejected by the backend.
3015
+ const providersInfo = await this._getProvidersInfo();
3016
+ const { provider: currentProvider, model: currentModel } = window.resolveProviderModelPair([
3017
+ { provider: repoSettings?.default_provider, model: repoSettings?.default_model },
3018
+ { provider: appConfig.default_provider, model: appConfig.default_model }
3019
+ ], providersInfo);
2754
3020
  const tabStorageKey = PRManager.getRepoStorageKey('pair-review-tab', owner, repo);
2755
3021
  const rememberedTab = localStorage.getItem(tabStorageKey);
2756
3022
  const defaultTab = rememberedTab || repoSettings?.default_tab || 'single';
@@ -2957,14 +3223,22 @@ class PRManager {
2957
3223
  // Don't show if no pending draft
2958
3224
  if (!pendingDraft) return;
2959
3225
 
3226
+ // Resolve the configured host name + URL (alt-host aware). Prefer the
3227
+ // URL built from the repo's url_template over the server-reported
3228
+ // github_url, which some alt-hosts return as a wrong-host github.com URL.
3229
+ const hostName = (window.RepoLinks && typeof window.RepoLinks.hostName === 'function')
3230
+ ? window.RepoLinks.hostName() : 'GitHub';
3231
+ const externalUrl = (window.RepoLinks && typeof window.RepoLinks.externalUrl === 'function')
3232
+ ? window.RepoLinks.externalUrl() : null;
3233
+
2960
3234
  // Create the indicator
2961
3235
  const indicator = document.createElement('a');
2962
3236
  indicator.id = 'pending-draft-indicator';
2963
3237
  indicator.className = 'pending-draft-indicator';
2964
- indicator.href = pendingDraft.github_url || '#';
3238
+ indicator.href = externalUrl || pendingDraft.github_url || '#';
2965
3239
  indicator.target = '_blank';
2966
3240
  indicator.rel = 'noopener noreferrer';
2967
- indicator.title = 'View your pending draft review on GitHub';
3241
+ indicator.title = `View your pending draft review on ${hostName}`;
2968
3242
 
2969
3243
  const commentCount = pendingDraft.comments_count || 0;
2970
3244
  const commentText = commentCount === 1 ? '1 comment' : `${commentCount} comments`;
@@ -2973,7 +3247,7 @@ class PRManager {
2973
3247
  <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
2974
3248
  <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
3249
  </svg>
2976
- <span class="pending-draft-text">Draft on GitHub (${commentText})</span>
3250
+ <span class="pending-draft-text">Draft on ${this.escapeHtml(hostName)} (${commentText})</span>
2977
3251
  `;
2978
3252
 
2979
3253
  // Insert after the commit element (or at the end of toolbar-meta)
@@ -3022,10 +3296,23 @@ class PRManager {
3022
3296
  // no matching rows keeps the stale `true`, leaving the toolbar stuck in
3023
3297
  // Hide/Show mode with nothing in the DOM and blocking click-to-generate.
3024
3298
  this._summariesGenerated = false;
3025
- // Bump generation so any in-flight `_kickOffHunkSummaries` from the
3299
+ // Reset alongside `_summariesGenerated`. Set true again only when a fetch
3300
+ // (or WS event) accepts/queues a non-trivial summary for this render.
3301
+ this._summariesAvailable = false;
3302
+ // Bump generation so any in-flight `_fetchHunkSummaryMap` from the
3026
3303
  // previous render bails out instead of mutating maps we just reset.
3027
3304
  this._renderGen = (this._renderGen || 0) + 1;
3028
3305
 
3306
+ // Reset lazy-body state and (re)create the IntersectionObserver. The
3307
+ // `innerHTML = ''` above detached every previously-observed body, so a
3308
+ // stale observer would hold dead references and never fire — tear it down
3309
+ // and start fresh for this render generation. This runs for every render
3310
+ // path (initial load, whitespace toggle, scope change) since they all
3311
+ // funnel through renderDiff().
3312
+ this._teardownFileBodyObserver();
3313
+ this._lazyFileBodies = new Map();
3314
+ this._fileBodyObserver = this._createFileBodyObserver();
3315
+
3029
3316
  // Use changed_files array from API
3030
3317
  const files = pr.changed_files || pr.files || [];
3031
3318
 
@@ -3050,9 +3337,12 @@ class PRManager {
3050
3337
  }
3051
3338
  });
3052
3339
 
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();
3340
+ // NOTE: end-of-file gap validation runs per-file inside _renderFileBodyNow
3341
+ // now (bodies render lazily), not once globally here.
3342
+
3343
+ // Measure the now-rendered sticky file header so navigation can offset
3344
+ // targets below it (scroll-margin-top in pr.css).
3345
+ this._measureFileHeaderHeight();
3056
3346
  } else {
3057
3347
  diffContainer.innerHTML = '<div class="no-diff">No files changed</div>';
3058
3348
  }
@@ -3061,11 +3351,13 @@ class PRManager {
3061
3351
  this.contextFiles = [];
3062
3352
  this.loadContextFiles();
3063
3353
 
3064
- // Kick off hunk-summary hashing + load (Phase 5). Fire-and-forget — the
3065
- // diff is fully usable while summaries arrive asynchronously.
3354
+ // Fetch hunk summaries (Phase 5). Fire-and-forget — the diff is fully
3355
+ // usable while summaries arrive asynchronously. Anchors are wired lazily
3356
+ // as each file body renders (_registerHunkAnchorsForFile); this just loads
3357
+ // the server's summary map and applies it to whatever has rendered so far.
3066
3358
  if (this.hunkSummaryRenderer) {
3067
- this._kickOffHunkSummaries().catch((err) => {
3068
- console.warn('[HunkSummary] kickoff failed:', err);
3359
+ this._fetchHunkSummaryMap().catch((err) => {
3360
+ console.warn('[HunkSummary] summary fetch failed:', err);
3069
3361
  });
3070
3362
  }
3071
3363
 
@@ -3174,8 +3466,8 @@ class PRManager {
3174
3466
 
3175
3467
  // Per-file hunk-summary toggle. Mirrors the toolbar toggle but scoped to
3176
3468
  // 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
3469
+ // _applyHunkSummaries / _registerHunkAnchorsForFile re-enable it as soon
3470
+ // as hashes for this file are recorded (so a summary that arrives later
3179
3471
  // doesn't get hidden behind a permanently-disabled button).
3180
3472
  // Gated on `_summariesEnabled`: skipped entirely when /api/config has
3181
3473
  // already reported the feature is off; created hidden + revealed later
@@ -3210,21 +3502,13 @@ class PRManager {
3210
3502
  }
3211
3503
  }
3212
3504
 
3213
- // Create diff table
3505
+ // Create diff table with an EMPTY tbody. The rows are NOT rendered here —
3506
+ // see the lazy-body machinery below. This is the core large-PR perf fix:
3507
+ // building + syntax-highlighting every line of every (often collapsed)
3508
+ // file up front froze the browser on big diffs.
3214
3509
  const table = document.createElement('table');
3215
3510
  table.className = 'd2h-diff-table';
3216
-
3217
3511
  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
3512
  table.appendChild(tbody);
3229
3513
 
3230
3514
  // Wrap table in a scrollable container for horizontal scroll of long code lines
@@ -3234,9 +3518,197 @@ class PRManager {
3234
3518
  fileBody.appendChild(table);
3235
3519
  wrapper.appendChild(fileBody);
3236
3520
 
3521
+ // Reserve approximate height for EXPANDED-but-not-yet-rendered bodies so
3522
+ // the scrollbar stays roughly stable as the user scrolls and bodies fill
3523
+ // in. Collapsed bodies are `display:none`, so they contribute no height
3524
+ // and need no placeholder. Cleared once the body actually renders.
3525
+ if (!isCollapsed && file.patch) {
3526
+ const approxLines = file.patch.split('\n').length;
3527
+ fileBody.style.minHeight = (approxLines * PRManager.APPROX_DIFF_LINE_PX) + 'px';
3528
+ }
3529
+
3530
+ // Register the lazy entry. `gen` lets _renderFileBodyNow detect a body
3531
+ // left over from a superseded render and skip anchor registration.
3532
+ this._lazyFileBodies.set(file.file, {
3533
+ fileName: file.file,
3534
+ patch: file.patch || null,
3535
+ binary: !!file.binary,
3536
+ hunkHashes: file.hunk_hashes || null,
3537
+ fileBody,
3538
+ wrapper,
3539
+ rendered: false,
3540
+ renderPromise: null,
3541
+ gen: this._renderGen
3542
+ });
3543
+
3544
+ // Observe the body so it renders as it nears the viewport. Collapsed
3545
+ // bodies (display:none) never intersect → stay unrendered until expanded.
3546
+ if ((file.patch || file.binary) && this._fileBodyObserver) {
3547
+ this._fileBodyObserver.observe(fileBody);
3548
+ }
3549
+
3237
3550
  return wrapper;
3238
3551
  }
3239
3552
 
3553
+ /**
3554
+ * Approximate rendered height (px) of one diff line row. Used only to
3555
+ * reserve placeholder height for expanded-but-unrendered file bodies so the
3556
+ * scrollbar doesn't jump as lazy bodies fill in. Errs slightly high.
3557
+ */
3558
+ static get APPROX_DIFF_LINE_PX() { return 20; }
3559
+
3560
+ /**
3561
+ * Create the IntersectionObserver that renders file bodies as they approach
3562
+ * the viewport. One instance per render generation; recreated in renderDiff.
3563
+ * Returns null where IntersectionObserver is unavailable (e.g. jsdom unit
3564
+ * tests) — bodies then render only on demand via ensureFileBodyRendered().
3565
+ * @returns {IntersectionObserver|null}
3566
+ */
3567
+ _createFileBodyObserver() {
3568
+ if (typeof IntersectionObserver === 'undefined') return null;
3569
+ // `.diff-view` is the vertical scroll container (see pr.css). Fall back to
3570
+ // the viewport when it can't be resolved.
3571
+ const root = document.querySelector('.diff-view') || null;
3572
+ return new IntersectionObserver((entries, observer) => {
3573
+ for (const ioEntry of entries) {
3574
+ if (!ioEntry.isIntersecting) continue;
3575
+ const lazyEntry = this._lazyFileBodyForElement(ioEntry.target);
3576
+ observer.unobserve(ioEntry.target);
3577
+ if (lazyEntry) this._renderFileBodyNow(lazyEntry);
3578
+ }
3579
+ }, { root, rootMargin: '800px 0px', threshold: 0 });
3580
+ }
3581
+
3582
+ /**
3583
+ * Disconnect and drop the current file-body observer, if any.
3584
+ */
3585
+ _teardownFileBodyObserver() {
3586
+ if (this._fileBodyObserver) {
3587
+ this._fileBodyObserver.disconnect();
3588
+ this._fileBodyObserver = null;
3589
+ }
3590
+ }
3591
+
3592
+ /**
3593
+ * Resolve the lazy entry for a `.d2h-file-body` element via its wrapper's
3594
+ * data-file-name. Returns null if not a known lazy body.
3595
+ * @param {Element} bodyEl
3596
+ * @returns {object|null}
3597
+ */
3598
+ _lazyFileBodyForElement(bodyEl) {
3599
+ if (!this._lazyFileBodies) return null;
3600
+ const wrapper = bodyEl?.closest?.('.d2h-file-wrapper');
3601
+ const filePath = wrapper?.dataset?.fileName;
3602
+ if (!filePath) return null;
3603
+ return this._lazyFileBodies.get(filePath) || null;
3604
+ }
3605
+
3606
+ /**
3607
+ * Ensure a file's diff-line body is rendered into the DOM, rendering it now
3608
+ * if it hasn't been. Idempotent and cheap when already rendered. Every code
3609
+ * path that scans a file's `<tr>` rows (comment/suggestion anchoring, gap
3610
+ * expansion, scroll-to-file, expand) must await this first, because a lazy
3611
+ * body has zero rows until rendered.
3612
+ * @param {string|Element} fileOrWrapper - file path, file body, or wrapper
3613
+ * @returns {Promise<HTMLElement|null>} the file body, or null if unknown
3614
+ */
3615
+ async ensureFileBodyRendered(fileOrWrapper) {
3616
+ // No lazy map (e.g. called before renderDiff, or a non-lazy render path) →
3617
+ // treat the body as already present and let callers scan as before.
3618
+ if (!this._lazyFileBodies) return null;
3619
+ let entry = null;
3620
+ if (typeof fileOrWrapper === 'string') {
3621
+ entry = this._lazyFileBodies.get(fileOrWrapper) || null;
3622
+ if (!entry) {
3623
+ // The map is keyed by the canonical `file.file` value, but callers
3624
+ // (AI suggestions, external comments, tour stops) may pass a
3625
+ // non-canonical path form. findFileElement normalizes './', '/', and
3626
+ // git rename syntax ('{old => new}') against data-file-name, so
3627
+ // resolve the wrapper first and retry with its canonical name. This
3628
+ // keeps the strict Map.get fast path while tolerating the same path
3629
+ // variants the rest of the diff UI accepts — without it the body
3630
+ // stays unrendered and the downstream row scan sees zero <tr> rows.
3631
+ const wrapper = this.findFileElement?.(fileOrWrapper);
3632
+ const canonicalFile = wrapper?.dataset?.fileName;
3633
+ if (canonicalFile) entry = this._lazyFileBodies.get(canonicalFile) || null;
3634
+ }
3635
+ } else if (fileOrWrapper && fileOrWrapper.nodeType === 1) {
3636
+ entry = this._lazyFileBodyForElement(fileOrWrapper)
3637
+ || (this._lazyFileBodies.get(fileOrWrapper.dataset?.fileName) || null);
3638
+ }
3639
+ if (!entry) return null;
3640
+ if (entry.rendered) return entry.fileBody;
3641
+ if (entry.renderPromise) return entry.renderPromise;
3642
+ // _renderFileBodyNow is synchronous; wrapping in a resolved promise keeps
3643
+ // the signature async and lets concurrent callers (observer + on-demand)
3644
+ // share one promise so renderPatch runs exactly once.
3645
+ entry.renderPromise = Promise.resolve().then(() => this._renderFileBodyNow(entry));
3646
+ return entry.renderPromise;
3647
+ }
3648
+
3649
+ /**
3650
+ * Synchronously build a file's diff-line body: run renderPatch (or the
3651
+ * binary placeholder), clear the height placeholder, wire hunk-summary
3652
+ * anchors, and validate this file's EOF gap. Idempotent.
3653
+ *
3654
+ * Invariant: there must be NO `await` between the `_pendingHunkRecords`
3655
+ * save and restore below — renderPatch pushes per-hunk anchor records into
3656
+ * the shared `_pendingHunkRecords`, and JS single-threading only guarantees
3657
+ * non-interleaving with other file renders while this stays synchronous.
3658
+ * @param {object} entry - a _lazyFileBodies value
3659
+ * @returns {HTMLElement} the file body element
3660
+ */
3661
+ _renderFileBodyNow(entry) {
3662
+ if (entry.rendered) return entry.fileBody;
3663
+ if (this._fileBodyObserver) this._fileBodyObserver.unobserve(entry.fileBody);
3664
+
3665
+ const tbody = entry.fileBody.querySelector('tbody');
3666
+
3667
+ // Capture only THIS file's hunk anchor records (renderPatch appends to
3668
+ // this._pendingHunkRecords; see invariant above). The swap is wrapped in
3669
+ // try/finally so a renderPatch throw can't leak the temporary buffer: were
3670
+ // the restore skipped, every subsequent file render would append its
3671
+ // anchor records into the stale array, silently cross-wiring
3672
+ // _summaryAnchorsByHash / _summaryHashesByFile for the rest of the session.
3673
+ const prevPending = this._pendingHunkRecords;
3674
+ this._pendingHunkRecords = [];
3675
+ let records;
3676
+ try {
3677
+ if (entry.patch && tbody) {
3678
+ this.renderPatch(tbody, entry.patch, entry.fileName, entry.hunkHashes);
3679
+ } else if (entry.binary && tbody) {
3680
+ const row = document.createElement('tr');
3681
+ row.innerHTML = '<td colspan="2" class="binary-file">Binary file</td>';
3682
+ tbody.appendChild(row);
3683
+ }
3684
+ } finally {
3685
+ records = this._pendingHunkRecords;
3686
+ this._pendingHunkRecords = prevPending;
3687
+ }
3688
+
3689
+ entry.fileBody.style.minHeight = '';
3690
+ entry.rendered = true;
3691
+ entry.renderPromise = null;
3692
+
3693
+ // Skip post-render wiring for a body left over from a superseded render
3694
+ // (its maps were reset and the body is detached). Both anchor registration
3695
+ // and EOF-gap validation are pointless/wasteful for a stale body.
3696
+ if (entry.gen === this._renderGen) {
3697
+ this._registerHunkAnchorsForFile(records);
3698
+ // Validate this file's pending EOF gaps. Pre-lazy-render this was a
3699
+ // single global pass at the end of renderDiff; now it runs per-file as
3700
+ // bodies render. Cheap pre-check first: most files have no pending EOF
3701
+ // gap, so skip the async work (Array.from + Promise.all + per-gap
3702
+ // /file-content fetches) entirely when this body has none. The selector
3703
+ // mirrors the one validatePendingEofGaps scans for.
3704
+ if (entry.fileBody.querySelector('tr.context-expand-row[data-pending-eof-validation="true"]')) {
3705
+ this.validatePendingEofGaps(entry.fileBody);
3706
+ }
3707
+ }
3708
+
3709
+ return entry.fileBody;
3710
+ }
3711
+
3240
3712
  /**
3241
3713
  * Parse and render a unified diff patch
3242
3714
  * @param {HTMLElement} tbody - Table body element
@@ -3389,8 +3861,8 @@ class PRManager {
3389
3861
 
3390
3862
  // Record this hunk's first rendered code row as the anchor for any
3391
3863
  // 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
3864
+ // backend (`hunkHashes[blockIndex]`); _registerHunkAnchorsForFile mounts
3865
+ // it as `data-hunk-start` when this file's body renders so the summary
3394
3866
  // renderer can find the anchor and insert the annotation above it.
3395
3867
  if (this._pendingHunkRecords && firstLineRow) {
3396
3868
  const serverHash = Array.isArray(hunkHashes) ? hunkHashes[blockIndex] || null : null;
@@ -3574,7 +4046,7 @@ class PRManager {
3574
4046
  * Toggle collapse state of a file diff
3575
4047
  * @param {string} filePath - Path of the file
3576
4048
  */
3577
- toggleFileCollapse(filePath) {
4049
+ async toggleFileCollapse(filePath) {
3578
4050
  const wrapper = this.findFileElement(filePath);
3579
4051
  if (!wrapper) return;
3580
4052
 
@@ -3582,6 +4054,8 @@ class PRManager {
3582
4054
  const header = wrapper.querySelector('.d2h-file-header');
3583
4055
 
3584
4056
  if (isCollapsed) {
4057
+ // Render the body before revealing it (lazy bodies are empty until now).
4058
+ await this.ensureFileBodyRendered(filePath);
3585
4059
  wrapper.classList.remove('collapsed');
3586
4060
  this.collapsedFiles.delete(filePath);
3587
4061
  } else {
@@ -3600,7 +4074,7 @@ class PRManager {
3600
4074
  * @param {string} filePath - Path of the file
3601
4075
  * @param {boolean} isViewed - Whether the file is now viewed
3602
4076
  */
3603
- toggleFileViewed(filePath, isViewed) {
4077
+ async toggleFileViewed(filePath, isViewed) {
3604
4078
  const wrapper = this.findFileElement(filePath);
3605
4079
 
3606
4080
  if (isViewed) {
@@ -3618,6 +4092,8 @@ class PRManager {
3618
4092
  this.viewedFiles.delete(filePath);
3619
4093
  // Auto-expand when unchecking viewed (match GitHub behavior)
3620
4094
  if (wrapper && wrapper.classList.contains('collapsed')) {
4095
+ // Render the body before revealing it (lazy bodies are empty until now).
4096
+ await this.ensureFileBodyRendered(filePath);
3621
4097
  wrapper.classList.remove('collapsed');
3622
4098
  this.collapsedFiles.delete(filePath);
3623
4099
  const header = wrapper.querySelector('.d2h-file-header');
@@ -3743,7 +4219,7 @@ class PRManager {
3743
4219
  * @deprecated Use toggleFileCollapse instead - kept for backward compatibility
3744
4220
  */
3745
4221
  toggleGeneratedFile(filePath) {
3746
- this.toggleFileCollapse(filePath);
4222
+ return this.toggleFileCollapse(filePath);
3747
4223
  }
3748
4224
 
3749
4225
  /**
@@ -3772,9 +4248,12 @@ class PRManager {
3772
4248
  * Validate pending end-of-file gaps asynchronously
3773
4249
  * Removes gap rows where there are no trailing lines to expand
3774
4250
  * This ensures users don't see expand buttons that do nothing
4251
+ * @param {ParentNode} [root=document] - Scope the search. With lazy bodies,
4252
+ * _renderFileBodyNow passes the just-rendered file body so each file's EOF
4253
+ * gap is validated as it renders (rather than one global post-render pass).
3775
4254
  */
3776
- async validatePendingEofGaps() {
3777
- const pendingGaps = document.querySelectorAll('tr.context-expand-row[data-pending-eof-validation="true"]');
4255
+ async validatePendingEofGaps(root = document) {
4256
+ const pendingGaps = root.querySelectorAll('tr.context-expand-row[data-pending-eof-validation="true"]');
3778
4257
 
3779
4258
  // Process all pending gaps in parallel for efficiency
3780
4259
  const validationPromises = Array.from(pendingGaps).map(async (gapRow) => {
@@ -4139,11 +4618,14 @@ class PRManager {
4139
4618
  return false;
4140
4619
  }
4141
4620
 
4621
+ // Render the body first — gap rows (and code rows) only exist once the
4622
+ // lazy body has rendered. Without this the gap query below returns nothing.
4623
+ await this.ensureFileBodyRendered(file);
4624
+
4142
4625
  // Check if file is collapsed (generated files)
4143
4626
  if (fileElement.classList.contains('collapsed')) {
4144
4627
  debugLog?.('expandForSuggestion', 'File is collapsed, expanding first');
4145
- this.toggleGeneratedFile(file);
4146
- await new Promise(resolve => setTimeout(resolve, 50));
4628
+ await this.toggleGeneratedFile(file);
4147
4629
  }
4148
4630
 
4149
4631
  // Find the gap section containing the target lines using the shared module
@@ -4227,6 +4709,12 @@ class PRManager {
4227
4709
  const fileElement = this.findFileElement(file);
4228
4710
  if (!fileElement) continue;
4229
4711
 
4712
+ // Render the file body first — with lazy rendering an unrendered file
4713
+ // has zero rows, so the visibility scan below would always miss and the
4714
+ // line would be treated as "hidden in a gap" (then gap-expanded against
4715
+ // zero gap rows → silent anchor failure).
4716
+ await this.ensureFileBodyRendered(file);
4717
+
4230
4718
  // Check if any line in the range is already visible
4231
4719
  let anyLineVisible = false;
4232
4720
  const lineRows = fileElement.querySelectorAll('tr');
@@ -5901,10 +6389,30 @@ class PRManager {
5901
6389
  collapsedBtn.addEventListener('click', () => toggleSidebar(false));
5902
6390
  }
5903
6391
 
5904
- scrollToFile(filePath) {
6392
+ async scrollToFile(filePath) {
5905
6393
  const fileWrapper = this.findFileElement(filePath);
5906
6394
  if (fileWrapper) {
5907
- fileWrapper.scrollIntoView({ behavior: 'smooth', block: 'start' });
6395
+ // Render the body so the scroll target has its real height (an empty
6396
+ // lazy body would land the scroll at the wrong offset for expanded
6397
+ // files). Skip it for collapsed files: their body is display:none
6398
+ // (zero height) and `block: 'start'` aligns the header regardless, so
6399
+ // force-rendering would only pay the full renderPatch + per-line
6400
+ // highlight cost for content that stays hidden. scrollToFile does no
6401
+ // post-render row scan, so gating on `!collapsed` is safe; expanding
6402
+ // later still renders on demand via toggleFileCollapse.
6403
+ if (!fileWrapper.classList.contains('collapsed')) {
6404
+ await this.ensureFileBodyRendered(filePath);
6405
+ }
6406
+ // Stable variant: lazy bodies between here and the target render as
6407
+ // the smooth scroll passes them, shifting layout mid-flight. The
6408
+ // helper re-corrects after the scroll settles so the first attempt
6409
+ // lands where the second used to.
6410
+ const scrollOptions = { behavior: 'smooth', block: 'start' };
6411
+ if (window.ScrollUtils?.scrollIntoViewStable) {
6412
+ await window.ScrollUtils.scrollIntoViewStable(fileWrapper, scrollOptions);
6413
+ } else {
6414
+ fileWrapper.scrollIntoView(scrollOptions);
6415
+ }
5908
6416
  }
5909
6417
  }
5910
6418
 
@@ -6328,11 +6836,15 @@ class PRManager {
6328
6836
  : this._fetchStaleness(owner, repo, number);
6329
6837
  this._stalenessPromise = null; // consume it
6330
6838
 
6839
+ // Pass owner+repo to /api/config so has_github_token reflects the
6840
+ // repo's actual auth (covers repo-scoped tokens, token_command, and
6841
+ // alt-host bindings — not just the global github_token).
6842
+ const configUrl = `/api/config?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`;
6331
6843
  const [staleResult, repoSettings, reviewSettings, appConfig] = await Promise.all([
6332
6844
  staleCheckWithTimeout,
6333
6845
  this.fetchRepoSettings(),
6334
6846
  this.fetchLastReviewSettings(),
6335
- fetch('/api/config').then(r => r.ok ? r.json() : {}).catch(() => ({}))
6847
+ fetch(configUrl).then(r => r.ok ? r.json() : {}).catch(() => ({}))
6336
6848
  ]);
6337
6849
  console.debug(`[Analyze] parallel-fetch (stale+settings): ${Math.round(performance.now() - _tParallel0)}ms`);
6338
6850
 
@@ -6378,9 +6890,14 @@ class PRManager {
6378
6890
 
6379
6891
  const lastCouncilId = reviewSettings.last_council_id;
6380
6892
 
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';
6893
+ // Resolve provider and model as a MATCHED pair so the council/advanced tabs
6894
+ // are never seeded with a cross-provider model (e.g. gemini + opus), which
6895
+ // would blank the model <select> and be rejected by the backend.
6896
+ const providersInfo = await this._getProvidersInfo();
6897
+ const { provider: currentProvider, model: currentModel } = window.resolveProviderModelPair([
6898
+ { provider: repoSettings?.default_provider, model: repoSettings?.default_model },
6899
+ { provider: appConfig.default_provider, model: appConfig.default_model }
6900
+ ], providersInfo);
6384
6901
 
6385
6902
  // Determine default tab (priority: localStorage > repo settings > 'single')
6386
6903
  const tabStorageKey = PRManager.getRepoStorageKey('pair-review-tab', owner, repo);
@@ -6408,7 +6925,12 @@ class PRManager {
6408
6925
  lastCouncilId,
6409
6926
  defaultCouncilId: repoSettings?.default_council_id || null,
6410
6927
  hasPr: true,
6411
- hasGithubToken: Boolean(appConfig.has_github_token)
6928
+ // Use the repo-aware field (we passed owner+repo to /api/config).
6929
+ // Fall back to the global field only if the response was malformed
6930
+ // or the params were rejected — defensive, should not happen.
6931
+ hasGithubToken: Boolean(
6932
+ appConfig.has_github_token ?? appConfig.has_global_github_token
6933
+ )
6412
6934
  });
6413
6935
 
6414
6936
  // If user cancelled, do nothing
@@ -6554,7 +7076,10 @@ class PRManager {
6554
7076
  showWorktreeNotFoundError(owner, repo, number) {
6555
7077
  let setupUrl = `/pr/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(number)}`;
6556
7078
  if (this._autoAnalyzeRequested) {
6557
- setupUrl += '?analyze=true';
7079
+ const params = new URLSearchParams({ analyze: 'true' });
7080
+ const analysisConfigId = new URLSearchParams(window.location.search).get('analysisConfigId');
7081
+ if (analysisConfigId) params.set('analysisConfigId', analysisConfigId);
7082
+ setupUrl += `?${params.toString()}`;
6558
7083
  }
6559
7084
  const container = document.getElementById('pr-container');
6560
7085
  if (container) {
@@ -7254,7 +7779,7 @@ class PRManager {
7254
7779
  * @param {string} file - File path
7255
7780
  * @param {number} [lineStart] - Optional line number to highlight
7256
7781
  */
7257
- scrollToContextFile(file, lineStart, contextId) {
7782
+ async scrollToContextFile(file, lineStart, contextId) {
7258
7783
  // Use contextId to find a specific chunk tbody within a merged wrapper,
7259
7784
  // or fall back to a standalone wrapper or the file-level wrapper.
7260
7785
  let target;
@@ -7273,23 +7798,30 @@ class PRManager {
7273
7798
  }
7274
7799
  if (!target) return;
7275
7800
 
7276
- target.scrollIntoView({ behavior: 'smooth', block: 'start' });
7801
+ // Stable variant ensures the target's lazy body is rendered and
7802
+ // re-corrects after lazy renders along the scroll path shift layout.
7803
+ const scrollOptions = { behavior: 'smooth', block: 'start' };
7804
+ if (window.ScrollUtils?.scrollIntoViewStable) {
7805
+ await window.ScrollUtils.scrollIntoViewStable(target, scrollOptions);
7806
+ } else {
7807
+ target.scrollIntoView(scrollOptions);
7808
+ }
7277
7809
 
7278
7810
  if (lineStart) {
7279
7811
  // Search for the line row within the wrapper (not just the target chunk)
7280
7812
  const wrapper = target.closest('.d2h-file-wrapper') || target;
7281
- // Brief delay to let scroll settle, then highlight the target line
7282
- setTimeout(() => {
7283
- const row = wrapper.querySelector(`tr[data-line-number="${lineStart}"]`);
7284
- if (row) {
7813
+ // The awaited stable scroll has already settled (and rendered the lazy
7814
+ // body), so the row exists now — highlight it immediately rather than
7815
+ // pulsing on a stale timer that would fire after the scroll completes.
7816
+ const row = wrapper.querySelector(`tr[data-line-number="${lineStart}"]`);
7817
+ if (row) {
7818
+ row.classList.remove('chat-line-highlight');
7819
+ void row.offsetWidth;
7820
+ row.classList.add('chat-line-highlight');
7821
+ row.addEventListener('animationend', () => {
7285
7822
  row.classList.remove('chat-line-highlight');
7286
- void row.offsetWidth;
7287
- row.classList.add('chat-line-highlight');
7288
- row.addEventListener('animationend', () => {
7289
- row.classList.remove('chat-line-highlight');
7290
- }, { once: true });
7291
- }
7292
- }, 400);
7823
+ }, { once: true });
7824
+ }
7293
7825
  }
7294
7826
  }
7295
7827