@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/index.html CHANGED
@@ -25,6 +25,7 @@
25
25
  <link rel="preconnect" href="https://fonts.googleapis.com">
26
26
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
27
27
  <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
28
+ <link rel="stylesheet" href="/css/analysis-config.css">
28
29
 
29
30
  <style>
30
31
  :root {
@@ -1577,8 +1578,18 @@
1577
1578
  </div>
1578
1579
 
1579
1580
  <script src="/js/ws-client.js"></script>
1581
+ <script src="/js/utils/markdown.js"></script>
1582
+ <script src="/js/utils/tier-icons.js"></script>
1583
+ <script src="/js/utils/storage-keys.js"></script>
1584
+ <script src="/js/utils/provider-model.js"></script>
1580
1585
  <script src="/js/components/Toast.js"></script>
1581
1586
  <script src="/js/components/UpdateBanner.js"></script>
1587
+ <script src="/js/components/ConfirmDialog.js"></script>
1588
+ <script src="/js/components/TextInputDialog.js"></script>
1589
+ <script src="/js/components/TimeoutSelect.js"></script>
1590
+ <script src="/js/components/AnalysisConfigModal.js"></script>
1591
+ <script src="/js/components/VoiceCentricConfigTab.js"></script>
1592
+ <script src="/js/components/AdvancedConfigTab.js"></script>
1582
1593
  <script src="/js/index.js"></script>
1583
1594
  </body>
1584
1595
  </html>
@@ -44,6 +44,10 @@ class AIPanel {
44
44
  // Track selected item by stable identifier for restoration
45
45
  this.selectedItemKey = null; // Format: "file:lineNumber:itemType:identity"
46
46
 
47
+ // Monotonic token so a fast move between items that supersedes an
48
+ // in-flight scrollTo* can tell the older call to bail after its await.
49
+ this._navGen = 0;
50
+
47
51
  // Canonical file order for consistent sorting across components
48
52
  this.fileOrder = new Map(); // Map of file path -> index
49
53
 
@@ -1130,9 +1134,19 @@ class AIPanel {
1130
1134
  }
1131
1135
 
1132
1136
  /**
1133
- * Expand a file if it is collapsed
1137
+ * Expand a file if it is collapsed.
1138
+ *
1139
+ * Return contract (callers must handle both):
1140
+ * - `false` when nothing was expanded (no file, no wrapper, or already
1141
+ * expanded), OR a truthy non-thenable for the DOM-only fallback.
1142
+ * - a Promise when it routed through `prManager.toggleFileCollapse`,
1143
+ * which is async (it renders the lazy file body before revealing it).
1144
+ * Scroll callers await the Promise so the row lookup runs against a
1145
+ * rendered, visible body; the synchronous fast path is preserved when no
1146
+ * expansion is needed.
1147
+ *
1134
1148
  * @param {string} file - The file path
1135
- * @returns {boolean} True if the file was expanded
1149
+ * @returns {boolean|Promise<*>} See contract above.
1136
1150
  */
1137
1151
  expandFileIfCollapsed(file) {
1138
1152
  if (!file) return false;
@@ -1145,17 +1159,19 @@ class AIPanel {
1145
1159
 
1146
1160
  // Check if collapsed
1147
1161
  if (fileWrapper.classList.contains('collapsed')) {
1148
- // Use prManager's toggle method if available (keeps state in sync)
1162
+ // Use prManager's toggle method if available (keeps state in sync).
1149
1163
  const filePath = fileWrapper.dataset.fileName;
1150
1164
  if (window.prManager?.toggleFileCollapse) {
1151
- window.prManager.toggleFileCollapse(filePath);
1152
- } else {
1153
- // Fallback: directly manipulate the DOM
1154
- fileWrapper.classList.remove('collapsed');
1155
- const header = fileWrapper.querySelector('.d2h-file-header');
1156
- if (header && window.DiffRenderer) {
1157
- window.DiffRenderer.updateFileHeaderState(header, true);
1158
- }
1165
+ // Async: renders the lazy body + removes `collapsed`. Hand the
1166
+ // Promise back so the caller can await render completion rather
1167
+ // than guessing with a fixed timeout.
1168
+ return window.prManager.toggleFileCollapse(filePath);
1169
+ }
1170
+ // Fallback: directly manipulate the DOM (no lazy render path).
1171
+ fileWrapper.classList.remove('collapsed');
1172
+ const header = fileWrapper.querySelector('.d2h-file-header');
1173
+ if (header && window.DiffRenderer) {
1174
+ window.DiffRenderer.updateFileHeaderState(header, true);
1159
1175
  }
1160
1176
  return true;
1161
1177
  }
@@ -1166,11 +1182,19 @@ class AIPanel {
1166
1182
  /**
1167
1183
  * Scroll to an AI finding/suggestion in the diff view
1168
1184
  */
1169
- scrollToFinding(findingId, file, line) {
1185
+ async scrollToFinding(findingId, file, line) {
1186
+ const myGen = ++this._navGen;
1170
1187
  // Expand the file first if it's collapsed
1171
- const wasExpanded = this.expandFileIfCollapsed(file);
1188
+ const expansion = this.expandFileIfCollapsed(file);
1189
+ if (expansion && typeof expansion.then === 'function') await expansion;
1190
+ // Always render the target's lazy body — an expanded-but-offscreen
1191
+ // body has no suggestion rows until rendered, so the lookup below
1192
+ // would miss on the first attempt (expansion only covers the
1193
+ // collapsed case).
1194
+ if (file && window.prManager?.ensureFileBodyRendered) {
1195
+ try { await window.prManager.ensureFileBodyRendered(file); } catch { /* best effort */ }
1196
+ }
1172
1197
 
1173
- // Small delay if we expanded to allow DOM to update
1174
1198
  const doScroll = () => {
1175
1199
  let targetSuggestion = null;
1176
1200
 
@@ -1205,34 +1229,54 @@ class AIPanel {
1205
1229
 
1206
1230
  if (targetSuggestion) {
1207
1231
  const minimizer = window.prManager?.commentMinimizer;
1232
+ let scrollTarget = targetSuggestion;
1208
1233
  if (minimizer?.active) {
1209
1234
  // Expand file-level comments so the target becomes visible
1210
1235
  minimizer.expandForElement(targetSuggestion);
1211
1236
  // Comments are minimized — scroll to the parent diff line instead
1212
- const diffRow = minimizer.findDiffRowFor(targetSuggestion);
1213
- (diffRow || targetSuggestion).scrollIntoView({ behavior: 'smooth', block: 'center' });
1214
- } else {
1215
- targetSuggestion.scrollIntoView({ behavior: 'smooth', block: 'center' });
1237
+ scrollTarget = minimizer.findDiffRowFor(targetSuggestion) || targetSuggestion;
1216
1238
  }
1239
+ this._scrollDiffTarget(scrollTarget);
1217
1240
  targetSuggestion.classList.add('current-suggestion');
1218
1241
  setTimeout(() => targetSuggestion.classList.remove('current-suggestion'), 2000);
1219
1242
  }
1220
1243
  };
1221
1244
 
1222
- if (wasExpanded) {
1223
- // Give the DOM a moment to update after expanding
1224
- setTimeout(doScroll, 50);
1245
+ // A newer navigation took over while we awaited — let it win.
1246
+ if (myGen !== this._navGen) return;
1247
+ doScroll();
1248
+ }
1249
+
1250
+ /**
1251
+ * Scroll a diff-panel element into view, preferring the stable helper
1252
+ * (re-corrects after lazy file bodies render mid-scroll and shift
1253
+ * layout). Fire-and-forget.
1254
+ * @param {Element} target
1255
+ */
1256
+ _scrollDiffTarget(target) {
1257
+ // Land the target at the top of the diff panel (scroll-margin-top in
1258
+ // pr.css offsets it below the sticky toolbar + file header).
1259
+ const options = { behavior: 'smooth', block: 'start' };
1260
+ if (window.ScrollUtils?.scrollIntoViewStable) {
1261
+ window.ScrollUtils.scrollIntoViewStable(target, options);
1225
1262
  } else {
1226
- doScroll();
1263
+ target.scrollIntoView(options);
1227
1264
  }
1228
1265
  }
1229
1266
 
1230
1267
  /**
1231
1268
  * Scroll to a user comment in the diff view
1232
1269
  */
1233
- scrollToComment(commentId, file, line) {
1270
+ async scrollToComment(commentId, file, line) {
1271
+ const myGen = ++this._navGen;
1234
1272
  // Expand the file first if it's collapsed
1235
- const wasExpanded = this.expandFileIfCollapsed(file);
1273
+ const expansion = this.expandFileIfCollapsed(file);
1274
+ if (expansion && typeof expansion.then === 'function') await expansion;
1275
+ // Always render the target's lazy body — comment rows don't exist
1276
+ // inside an unrendered body, so the lookup below would miss.
1277
+ if (file && window.prManager?.ensureFileBodyRendered) {
1278
+ try { await window.prManager.ensureFileBodyRendered(file); } catch { /* best effort */ }
1279
+ }
1236
1280
 
1237
1281
  const doScroll = () => {
1238
1282
  let targetElement = null;
@@ -1275,13 +1319,13 @@ class AIPanel {
1275
1319
 
1276
1320
  if (targetElement) {
1277
1321
  const minimizer = window.prManager?.commentMinimizer;
1322
+ let scrollTarget = targetElement;
1278
1323
  if (minimizer?.active) {
1279
1324
  minimizer.expandForElement(targetElement);
1280
1325
  const diffRow = isFileLevel ? null : minimizer.findDiffRowFor(targetElement);
1281
- (diffRow || targetElement).scrollIntoView({ behavior: 'smooth', block: 'center' });
1282
- } else {
1283
- targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
1326
+ scrollTarget = diffRow || targetElement;
1284
1327
  }
1328
+ this._scrollDiffTarget(scrollTarget);
1285
1329
  // Add highlight effect
1286
1330
  const commentDiv = isFileLevel ? targetElement : targetElement.querySelector('.user-comment');
1287
1331
  if (commentDiv) {
@@ -1291,12 +1335,9 @@ class AIPanel {
1291
1335
  }
1292
1336
  };
1293
1337
 
1294
- if (wasExpanded) {
1295
- // Give the DOM a moment to update after expanding
1296
- setTimeout(doScroll, 50);
1297
- } else {
1298
- doScroll();
1299
- }
1338
+ // A newer navigation took over while we awaited — let it win.
1339
+ if (myGen !== this._navGen) return;
1340
+ doScroll();
1300
1341
  }
1301
1342
 
1302
1343
  /**
@@ -1311,9 +1352,16 @@ class AIPanel {
1311
1352
  * @param {string} file - File path for collapse-expand fallback
1312
1353
  * @param {string|number} line - Anchor line; used for file/line fallback
1313
1354
  */
1314
- scrollToExternalThread(threadId, source, file, line) {
1355
+ async scrollToExternalThread(threadId, source, file, line) {
1356
+ const myGen = ++this._navGen;
1315
1357
  // Expand the file first if it's collapsed
1316
- const wasExpanded = this.expandFileIfCollapsed(file);
1358
+ const expansion = this.expandFileIfCollapsed(file);
1359
+ if (expansion && typeof expansion.then === 'function') await expansion;
1360
+ // Always render the target's lazy body — external thread rows don't
1361
+ // exist inside an unrendered body, so the lookup below would miss.
1362
+ if (file && window.prManager?.ensureFileBodyRendered) {
1363
+ try { await window.prManager.ensureFileBodyRendered(file); } catch { /* best effort */ }
1364
+ }
1317
1365
 
1318
1366
  const doScroll = () => {
1319
1367
  let target = null;
@@ -1354,13 +1402,12 @@ class AIPanel {
1354
1402
 
1355
1403
  if (target) {
1356
1404
  const minimizer = window.prManager?.commentMinimizer;
1405
+ let scrollTarget = target;
1357
1406
  if (minimizer?.active) {
1358
1407
  minimizer.expandForElement(target);
1359
- const diffRow = minimizer.findDiffRowFor(target);
1360
- (diffRow || target).scrollIntoView({ behavior: 'smooth', block: 'center' });
1361
- } else {
1362
- target.scrollIntoView({ behavior: 'smooth', block: 'center' });
1408
+ scrollTarget = minimizer.findDiffRowFor(target) || target;
1363
1409
  }
1410
+ this._scrollDiffTarget(scrollTarget);
1364
1411
 
1365
1412
  // Transient focus flash. The class is removed after 2s — if
1366
1413
  // the row is rebuilt before then, the class is lost with it,
@@ -1370,11 +1417,9 @@ class AIPanel {
1370
1417
  }
1371
1418
  };
1372
1419
 
1373
- if (wasExpanded) {
1374
- setTimeout(doScroll, 50);
1375
- } else {
1376
- doScroll();
1377
- }
1420
+ // A newer navigation took over while we awaited — let it win.
1421
+ if (myGen !== this._navGen) return;
1422
+ doScroll();
1378
1423
  }
1379
1424
 
1380
1425
  // ========================================
@@ -53,6 +53,12 @@ class AdvancedConfigTab {
53
53
  this._injected = false;
54
54
  this._councilsLoaded = false;
55
55
 
56
+ // Default orchestration provider/model for new councils, updated via
57
+ // setDefaultOrchestration(). Seeded here so _defaultConfig() is coherent
58
+ // even if reset() runs before setDefaultOrchestration().
59
+ this._defaultProvider = 'claude';
60
+ this._defaultModel = 'sonnet';
61
+
56
62
  // Dirty state tracking
57
63
  this._isDirty = false;
58
64
 
@@ -179,6 +185,37 @@ class AdvancedConfigTab {
179
185
  */
180
186
  setDefaultCouncilId(councilId) {
181
187
  this._pendingDefaultCouncilId = councilId;
188
+ // On a cached reopen the councils are already loaded, so loadCouncils() —
189
+ // and the _renderCouncilSelector() call that applies the pending default —
190
+ // will not run again (the modal instance is reused; see AnalysisConfigModal
191
+ // caching on window.analysisConfigModal). Apply it now so the saved/default
192
+ // council is restored instead of being silently dropped onto a blank
193
+ // "+ New Council" selection.
194
+ if (this._councilsLoaded && this._injected) {
195
+ this._renderCouncilSelector();
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Reset selection and editor state for a fresh modal open.
201
+ *
202
+ * The AnalysisConfigModal (and therefore this tab) is reused across runs — and
203
+ * in the index/bulk flow, across different repositories. Without this reset a
204
+ * council selected in a previous run (or its pending default / dirty edits)
205
+ * would carry over and could be displayed or submitted for the next batch.
206
+ */
207
+ reset() {
208
+ this.selectedCouncilId = null;
209
+ this._pendingDefaultCouncilId = null;
210
+ this._isDirty = false;
211
+ if (!this._injected) return;
212
+ const selector = this.modal.querySelector('#council-selector');
213
+ if (selector) {
214
+ selector.value = '';
215
+ selector.classList.add('new-council-selected');
216
+ }
217
+ this._applyConfigToUI(this._defaultConfig());
218
+ this._markClean();
182
219
  }
183
220
 
184
221
  /**
@@ -283,13 +320,23 @@ class AdvancedConfigTab {
283
320
  }
284
321
 
285
322
  _defaultConfig() {
323
+ const provider = this._defaultProvider || 'claude';
324
+ const model = this._defaultModel || 'sonnet';
325
+ const timeout = this._getProviderDefaultTimeout(provider);
326
+ // Seed one reviewer voice per enabled level. The server validator rejects an
327
+ // enabled level with an empty voices array (councils.js: "voices must be a
328
+ // non-empty array when enabled"), and _applyConfigToUI() wipes a level's row
329
+ // list when voices is empty — so an empty default would leave every level
330
+ // enabled with zero reviewer rows and fail at analysis kickoff. A fresh voice
331
+ // object per level avoids shared-reference aliasing.
332
+ const seedVoice = () => ({ provider, model, tier: 'balanced', timeout });
286
333
  return {
287
334
  levels: {
288
- '1': { enabled: true, voices: [] },
289
- '2': { enabled: true, voices: [] },
290
- '3': { enabled: true, voices: [] }
335
+ '1': { enabled: true, voices: [seedVoice()] },
336
+ '2': { enabled: true, voices: [seedVoice()] },
337
+ '3': { enabled: true, voices: [seedVoice()] }
291
338
  },
292
- consolidation: { provider: this._defaultProvider || 'claude', model: this._defaultModel || 'sonnet', tier: 'balanced', timeout: this._getProviderDefaultTimeout(this._defaultProvider || 'claude') }
339
+ consolidation: { provider, model, tier: 'balanced', timeout }
293
340
  };
294
341
  }
295
342
 
@@ -1432,3 +1479,8 @@ class AdvancedConfigTab {
1432
1479
  if (typeof window !== 'undefined') {
1433
1480
  window.AdvancedConfigTab = AdvancedConfigTab;
1434
1481
  }
1482
+
1483
+ // Export for unit testing (Node/CommonJS environment)
1484
+ if (typeof module !== 'undefined' && module.exports) {
1485
+ module.exports = { AdvancedConfigTab };
1486
+ }
@@ -625,6 +625,15 @@ class AnalysisConfigModal {
625
625
  * Select a model
626
626
  */
627
627
  selectModel(modelId) {
628
+ // Guard: never store a model that doesn't belong to the current provider.
629
+ // Callers can pass a model resolved from a different scope (e.g. 'opus'
630
+ // while the provider is 'gemini'); fall back to the provider's default so
631
+ // the UI always shows a selected card and submissions carry a valid pair.
632
+ if (modelId && this.models.length && !this.models.some(m => m.id === modelId)) {
633
+ const fallback = this.models.find(m => m.default) || this.models[0];
634
+ modelId = fallback ? fallback.id : modelId;
635
+ }
636
+
628
637
  this.selectedModel = modelId;
629
638
 
630
639
  // Update UI
@@ -897,42 +906,41 @@ class AnalysisConfigModal {
897
906
  // Build three-tab layout
898
907
  this._injectTabLayout(options);
899
908
 
900
- // Initialize voice-centric council tab
909
+ // Re-baseline council-tab state every open. The modal is reused across runs
910
+ // (and across repositories in the bulk flow), so always reset selection and
911
+ // pass normalized values — including empties — so a previous run's repo
912
+ // instructions, council selection, or dirty edits cannot bleed into the next.
913
+ const councilDefault = options.lastCouncilId || options.defaultCouncilId || null;
914
+
915
+ // Initialize voice-centric council tab.
916
+ //
917
+ // Order matters: setProviders() and setDefaultOrchestration() must run BEFORE
918
+ // reset(), because reset() repaints the UI from _defaultConfig(), which reads
919
+ // _defaultProvider/_defaultModel and the provider dropdown data. Calling reset()
920
+ // first would paint stale (previous-open) provider/model values on the reused
921
+ // modal. setDefaultCouncilId() runs last so any saved/default council is applied
922
+ // on top of the freshly reset defaults — including on cached reopens.
901
923
  if (this.councilTab) {
902
924
  const councilPanel = this.modal.querySelector('#tab-panel-council');
903
925
  this.councilTab.inject(councilPanel);
904
926
  this.councilTab.setProviders(this.providers);
905
-
906
- if (options.repoInstructions) {
907
- this.councilTab.setRepoInstructions(options.repoInstructions);
908
- }
909
- if (options.lastInstructions) {
910
- this.councilTab.setLastInstructions(options.lastInstructions);
911
- }
912
927
  this.councilTab.setDefaultOrchestration(options.currentProvider, options.currentModel);
913
- const councilDefault = options.lastCouncilId || options.defaultCouncilId || null;
914
- if (councilDefault) {
915
- this.councilTab.setDefaultCouncilId(councilDefault);
916
- }
928
+ this.councilTab.reset();
929
+ this.councilTab.setRepoInstructions(options.repoInstructions || '');
930
+ this.councilTab.setLastInstructions(options.lastInstructions || '');
931
+ this.councilTab.setDefaultCouncilId(councilDefault);
917
932
  }
918
933
 
919
- // Initialize advanced (level-centric) tab
934
+ // Initialize advanced (level-centric) tab — same ordering rationale as above.
920
935
  if (this.advancedTab) {
921
936
  const advancedPanel = this.modal.querySelector('#tab-panel-advanced');
922
937
  this.advancedTab.inject(advancedPanel);
923
938
  this.advancedTab.setProviders(this.providers);
924
-
925
- if (options.repoInstructions) {
926
- this.advancedTab.setRepoInstructions(options.repoInstructions);
927
- }
928
- if (options.lastInstructions) {
929
- this.advancedTab.setLastInstructions(options.lastInstructions);
930
- }
931
939
  this.advancedTab.setDefaultOrchestration(options.currentProvider, options.currentModel);
932
- const councilDefault = options.lastCouncilId || options.defaultCouncilId || null;
933
- if (councilDefault) {
934
- this.advancedTab.setDefaultCouncilId(councilDefault);
935
- }
940
+ this.advancedTab.reset();
941
+ this.advancedTab.setRepoInstructions(options.repoInstructions || '');
942
+ this.advancedTab.setLastInstructions(options.lastInstructions || '');
943
+ this.advancedTab.setDefaultCouncilId(councilDefault);
936
944
  }
937
945
 
938
946
  // Set initial provider and model
@@ -942,7 +950,11 @@ class AnalysisConfigModal {
942
950
  // Default to first available provider
943
951
  this.selectProvider(Object.keys(this.providers)[0]);
944
952
  }
945
- if (options.currentModel) {
953
+ // Only honour currentModel when it belongs to the selected provider.
954
+ // selectProvider() has already chosen a valid model for the provider (via
955
+ // tier matching); applying a foreign model here (e.g. 'opus' under 'gemini')
956
+ // would leave no model card selected and submit an invalid pair.
957
+ if (options.currentModel && this.models.some(m => m.id === options.currentModel)) {
946
958
  this.selectModel(options.currentModel);
947
959
  }
948
960
 
@@ -1434,3 +1446,7 @@ class AnalysisConfigModal {
1434
1446
  if (typeof window !== 'undefined') {
1435
1447
  window.AnalysisConfigModal = AnalysisConfigModal;
1436
1448
  }
1449
+
1450
+ if (typeof module !== 'undefined' && module.exports) {
1451
+ module.exports = { AnalysisConfigModal };
1452
+ }
@@ -4613,7 +4613,17 @@ class ChatPanel {
4613
4613
  const isVisible = rect.top >= 0 && rect.bottom <= window.innerHeight;
4614
4614
 
4615
4615
  if (!isVisible) {
4616
- primaryRow.scrollIntoView({ behavior: 'smooth', block: 'center' });
4616
+ // Stable variant re-corrects after lazy file bodies render mid-scroll
4617
+ // and shift the layout (plain scrollIntoView lands off target the
4618
+ // first time on large diffs). Fire-and-forget.
4619
+ // Land the target at the top of the diff panel (scroll-margin-top in
4620
+ // pr.css offsets it below the sticky toolbar + file header).
4621
+ const scrollOptions = { behavior: 'smooth', block: 'start' };
4622
+ if (window.ScrollUtils?.scrollIntoViewStable) {
4623
+ window.ScrollUtils.scrollIntoViewStable(primaryRow, scrollOptions);
4624
+ } else {
4625
+ primaryRow.scrollIntoView(scrollOptions);
4626
+ }
4617
4627
  }
4618
4628
 
4619
4629
  // Apply the highlight to all target rows