@in-the-loop-labs/pair-review 3.7.0 → 3.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@in-the-loop-labs/pair-review",
3
- "version": "3.7.0",
3
+ "version": "3.7.2",
4
4
  "description": "Your AI-powered code review partner - Close the feedback loop with AI coding agents",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pair-review",
3
- "version": "3.7.0",
3
+ "version": "3.7.2",
4
4
  "description": "pair-review app integration — Open PRs and local changes in the pair-review web UI, run server-side AI analysis, and address review feedback. Requires the pair-review MCP server.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-critic",
3
- "version": "3.7.0",
3
+ "version": "3.7.2",
4
4
  "description": "AI-powered code review analysis — Run three-level AI analysis and implement-review-fix loops directly in your coding agent. Works standalone, no server required.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
package/public/css/pr.css CHANGED
@@ -1661,6 +1661,23 @@
1661
1661
  scroll-margin-top: var(--toolbar-height, 0px);
1662
1662
  }
1663
1663
 
1664
+ /* Suggestion / finding / comment / chat-line navigation lands the target at
1665
+ the TOP of the diff panel (scrollIntoView block:'start'). Offset it below
1666
+ the sticky toolbar AND the sticky file header so it isn't hidden behind
1667
+ them. `--diff-file-header-height` is measured in JS (_measureFileHeaderHeight)
1668
+ with a sane fallback. Suppressed during a tour, which scrolls to center and
1669
+ manages its own sticky offsets. */
1670
+ :root {
1671
+ --diff-scroll-offset: calc(var(--toolbar-height, 0px) + var(--diff-file-header-height, 38px));
1672
+ }
1673
+ body:not(.tour-active) .d2h-file-wrapper tr,
1674
+ body:not(.tour-active) .ai-suggestion,
1675
+ body:not(.tour-active) .file-comment-card,
1676
+ body:not(.tour-active) .user-comment-row,
1677
+ body:not(.tour-active) .external-comment-row {
1678
+ scroll-margin-top: var(--diff-scroll-offset);
1679
+ }
1680
+
1664
1681
  /* Hide diff content when collapsed */
1665
1682
  .d2h-file-wrapper.collapsed .d2h-file-body,
1666
1683
  .d2h-file-wrapper.collapsed .d2h-diff-table {
@@ -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
 
@@ -1178,9 +1182,18 @@ class AIPanel {
1178
1182
  /**
1179
1183
  * Scroll to an AI finding/suggestion in the diff view
1180
1184
  */
1181
- scrollToFinding(findingId, file, line) {
1185
+ async scrollToFinding(findingId, file, line) {
1186
+ const myGen = ++this._navGen;
1182
1187
  // Expand the file first if it's collapsed
1183
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
+ }
1184
1197
 
1185
1198
  const doScroll = () => {
1186
1199
  let targetSuggestion = null;
@@ -1216,36 +1229,54 @@ class AIPanel {
1216
1229
 
1217
1230
  if (targetSuggestion) {
1218
1231
  const minimizer = window.prManager?.commentMinimizer;
1232
+ let scrollTarget = targetSuggestion;
1219
1233
  if (minimizer?.active) {
1220
1234
  // Expand file-level comments so the target becomes visible
1221
1235
  minimizer.expandForElement(targetSuggestion);
1222
1236
  // Comments are minimized — scroll to the parent diff line instead
1223
- const diffRow = minimizer.findDiffRowFor(targetSuggestion);
1224
- (diffRow || targetSuggestion).scrollIntoView({ behavior: 'smooth', block: 'center' });
1225
- } else {
1226
- targetSuggestion.scrollIntoView({ behavior: 'smooth', block: 'center' });
1237
+ scrollTarget = minimizer.findDiffRowFor(targetSuggestion) || targetSuggestion;
1227
1238
  }
1239
+ this._scrollDiffTarget(scrollTarget);
1228
1240
  targetSuggestion.classList.add('current-suggestion');
1229
1241
  setTimeout(() => targetSuggestion.classList.remove('current-suggestion'), 2000);
1230
1242
  }
1231
1243
  };
1232
1244
 
1233
- // When expansion routed through the async lazy-body render, wait for it
1234
- // to settle so the row lookup runs against a rendered, visible body.
1235
- // Otherwise scroll synchronously (fast path: file already expanded).
1236
- if (expansion && typeof expansion.then === 'function') {
1237
- expansion.then(doScroll);
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);
1238
1262
  } else {
1239
- doScroll();
1263
+ target.scrollIntoView(options);
1240
1264
  }
1241
1265
  }
1242
1266
 
1243
1267
  /**
1244
1268
  * Scroll to a user comment in the diff view
1245
1269
  */
1246
- scrollToComment(commentId, file, line) {
1270
+ async scrollToComment(commentId, file, line) {
1271
+ const myGen = ++this._navGen;
1247
1272
  // Expand the file first if it's collapsed
1248
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
+ }
1249
1280
 
1250
1281
  const doScroll = () => {
1251
1282
  let targetElement = null;
@@ -1288,13 +1319,13 @@ class AIPanel {
1288
1319
 
1289
1320
  if (targetElement) {
1290
1321
  const minimizer = window.prManager?.commentMinimizer;
1322
+ let scrollTarget = targetElement;
1291
1323
  if (minimizer?.active) {
1292
1324
  minimizer.expandForElement(targetElement);
1293
1325
  const diffRow = isFileLevel ? null : minimizer.findDiffRowFor(targetElement);
1294
- (diffRow || targetElement).scrollIntoView({ behavior: 'smooth', block: 'center' });
1295
- } else {
1296
- targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
1326
+ scrollTarget = diffRow || targetElement;
1297
1327
  }
1328
+ this._scrollDiffTarget(scrollTarget);
1298
1329
  // Add highlight effect
1299
1330
  const commentDiv = isFileLevel ? targetElement : targetElement.querySelector('.user-comment');
1300
1331
  if (commentDiv) {
@@ -1304,13 +1335,9 @@ class AIPanel {
1304
1335
  }
1305
1336
  };
1306
1337
 
1307
- // Await the async lazy-body render when expansion triggered one;
1308
- // scroll synchronously otherwise.
1309
- if (expansion && typeof expansion.then === 'function') {
1310
- expansion.then(doScroll);
1311
- } else {
1312
- doScroll();
1313
- }
1338
+ // A newer navigation took over while we awaited — let it win.
1339
+ if (myGen !== this._navGen) return;
1340
+ doScroll();
1314
1341
  }
1315
1342
 
1316
1343
  /**
@@ -1325,9 +1352,16 @@ class AIPanel {
1325
1352
  * @param {string} file - File path for collapse-expand fallback
1326
1353
  * @param {string|number} line - Anchor line; used for file/line fallback
1327
1354
  */
1328
- scrollToExternalThread(threadId, source, file, line) {
1355
+ async scrollToExternalThread(threadId, source, file, line) {
1356
+ const myGen = ++this._navGen;
1329
1357
  // Expand the file first if it's collapsed
1330
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
+ }
1331
1365
 
1332
1366
  const doScroll = () => {
1333
1367
  let target = null;
@@ -1368,13 +1402,12 @@ class AIPanel {
1368
1402
 
1369
1403
  if (target) {
1370
1404
  const minimizer = window.prManager?.commentMinimizer;
1405
+ let scrollTarget = target;
1371
1406
  if (minimizer?.active) {
1372
1407
  minimizer.expandForElement(target);
1373
- const diffRow = minimizer.findDiffRowFor(target);
1374
- (diffRow || target).scrollIntoView({ behavior: 'smooth', block: 'center' });
1375
- } else {
1376
- target.scrollIntoView({ behavior: 'smooth', block: 'center' });
1408
+ scrollTarget = minimizer.findDiffRowFor(target) || target;
1377
1409
  }
1410
+ this._scrollDiffTarget(scrollTarget);
1378
1411
 
1379
1412
  // Transient focus flash. The class is removed after 2s — if
1380
1413
  // the row is rebuilt before then, the class is lost with it,
@@ -1384,13 +1417,9 @@ class AIPanel {
1384
1417
  }
1385
1418
  };
1386
1419
 
1387
- // Await the async lazy-body render when expansion triggered one;
1388
- // scroll synchronously otherwise.
1389
- if (expansion && typeof expansion.then === 'function') {
1390
- expansion.then(doScroll);
1391
- } else {
1392
- doScroll();
1393
- }
1420
+ // A newer navigation took over while we awaited — let it win.
1421
+ if (myGen !== this._navGen) return;
1422
+ doScroll();
1394
1423
  }
1395
1424
 
1396
1425
  // ========================================
@@ -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
@@ -10,7 +10,10 @@ class SuggestionNavigator {
10
10
  this.isCollapsed = this.loadCollapsedState();
11
11
  this.element = null;
12
12
  this.collapseToggle = null;
13
-
13
+ // Monotonic token so a fast Next/Prev that supersedes an in-flight
14
+ // goToSuggestion can tell the older call to bail after its await.
15
+ this._navGen = 0;
16
+
14
17
  this.init();
15
18
  this.bindEvents();
16
19
  }
@@ -236,7 +239,7 @@ class SuggestionNavigator {
236
239
  /**
237
240
  * Navigate to specific suggestion by index
238
241
  */
239
- goToSuggestion(index) {
242
+ async goToSuggestion(index) {
240
243
  if (index < 0 || index >= this.suggestions.length) {
241
244
  return;
242
245
  }
@@ -244,10 +247,42 @@ class SuggestionNavigator {
244
247
  this.currentSuggestionIndex = index;
245
248
  this.updateCounter();
246
249
  this.updateNavigationButtons();
250
+ // The suggestion's row only exists once its file body has rendered
251
+ // (lazy bodies start empty), so render it before the highlight/scroll
252
+ // lookups below — otherwise both silently miss on the first attempt.
253
+ // A collapsed file is expanded first so the row is actually visible.
254
+ const myGen = ++this._navGen;
255
+ await this.ensureSuggestionVisible(this.suggestions[index]);
256
+ // A newer goToSuggestion ran while we awaited and moved
257
+ // this.currentSuggestionIndex — let it own the highlight/scroll.
258
+ if (myGen !== this._navGen) return;
247
259
  this.highlightCurrentSuggestion();
248
260
  this.scrollToSuggestion();
249
261
  }
250
262
 
263
+ /**
264
+ * Make sure a suggestion's file is expanded and its lazy diff body is
265
+ * rendered so the suggestion row exists in the DOM. Best effort: any
266
+ * failure falls through to the old lookup-miss behavior.
267
+ * @param {Object} suggestion
268
+ */
269
+ async ensureSuggestionVisible(suggestion) {
270
+ const file = suggestion?.file;
271
+ const pm = window.prManager;
272
+ if (!file || !pm) return;
273
+ try {
274
+ const wrapper = pm.findFileElement?.(file);
275
+ if (wrapper?.classList.contains('collapsed') && pm.toggleFileCollapse) {
276
+ // Renders the lazy body and removes `collapsed`.
277
+ await pm.toggleFileCollapse(wrapper.dataset.fileName || file);
278
+ } else if (pm.ensureFileBodyRendered) {
279
+ await pm.ensureFileBodyRendered(file);
280
+ }
281
+ } catch (err) {
282
+ console.warn('[SuggestionNavigator] could not prepare suggestion file', file, err);
283
+ }
284
+ }
285
+
251
286
  /**
252
287
  * Check if a suggestion should be skipped during navigation
253
288
  */
@@ -370,18 +405,22 @@ class SuggestionNavigator {
370
405
 
371
406
  if (suggestionEl) {
372
407
  const minimizer = window.prManager?.commentMinimizer;
408
+ let scrollTarget = suggestionEl;
373
409
  if (minimizer?.active) {
374
410
  // Expand file-level comments so the target becomes visible
375
411
  minimizer.expandForElement(suggestionEl);
376
412
  // Comments are minimized — scroll to the parent diff line instead
377
- const diffRow = minimizer.findDiffRowFor(suggestionEl);
378
- if (diffRow) {
379
- diffRow.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
380
- } else {
381
- suggestionEl.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
382
- }
413
+ scrollTarget = minimizer.findDiffRowFor(suggestionEl) || suggestionEl;
414
+ }
415
+ // Land the target at the top of the diff panel (scroll-margin-top in
416
+ // pr.css offsets it below the sticky toolbar + file header).
417
+ const options = { behavior: 'smooth', block: 'start', inline: 'nearest' };
418
+ // Stable variant re-corrects after lazy file bodies render
419
+ // mid-scroll and shift the layout. Fire-and-forget.
420
+ if (window.ScrollUtils?.scrollIntoViewStable) {
421
+ window.ScrollUtils.scrollIntoViewStable(scrollTarget, options);
383
422
  } else {
384
- suggestionEl.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
423
+ scrollTarget.scrollIntoView(options);
385
424
  }
386
425
  }
387
426
  }
@@ -474,4 +513,10 @@ class SuggestionNavigator {
474
513
  }
475
514
 
476
515
  // Export for use
477
- window.SuggestionNavigator = SuggestionNavigator;
516
+ if (typeof window !== 'undefined') {
517
+ window.SuggestionNavigator = SuggestionNavigator;
518
+ }
519
+
520
+ if (typeof module !== 'undefined' && module.exports) {
521
+ module.exports = SuggestionNavigator;
522
+ }
@@ -488,10 +488,20 @@ class TourRenderer {
488
488
  scrollToStop(index) {
489
489
  const row = this._mounted.get(index);
490
490
  if (!row || !row.isConnected) return;
491
- row.scrollIntoView({
491
+ const options = {
492
492
  behavior: this._reduceMotion ? 'auto' : 'smooth',
493
493
  block: 'center'
494
- });
494
+ };
495
+ // Lazy bodies between the viewport and the stop render as the scroll
496
+ // passes them, shifting layout so a plain scrollIntoView lands off
497
+ // target. The stable variant re-corrects once the scroll settles.
498
+ // Fire-and-forget: it bails on its own if the row unmounts (tour exit)
499
+ // or the user scrolls.
500
+ if (window.ScrollUtils?.scrollIntoViewStable) {
501
+ window.ScrollUtils.scrollIntoViewStable(row, options);
502
+ } else {
503
+ row.scrollIntoView(options);
504
+ }
495
505
  }
496
506
 
497
507
  /**
package/public/js/pr.js CHANGED
@@ -402,6 +402,22 @@ class PRManager {
402
402
  }
403
403
  }
404
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
+
405
421
  /**
406
422
  * Set up event handlers
407
423
  */
@@ -1674,8 +1690,12 @@ class PRManager {
1674
1690
  label = 'Exit guided tour';
1675
1691
  } else if (this._tourStops && this._tourStops.length > 0) {
1676
1692
  label = 'Start guided tour';
1693
+ } else if (this._toursAutoGenerate === false) {
1694
+ // No stops yet and auto-generation is off: a click kicks off manual
1695
+ // generation (see startOrToggleTour), so the verb is "Generate".
1696
+ label = 'Generate guided tour';
1677
1697
  } else {
1678
- label = 'Start guided tour (none available yet)';
1698
+ label = 'Guided tour (none available yet)';
1679
1699
  }
1680
1700
  btn.title = label;
1681
1701
  btn.setAttribute('aria-label', label);
@@ -3323,6 +3343,10 @@ class PRManager {
3323
3343
 
3324
3344
  // NOTE: end-of-file gap validation runs per-file inside _renderFileBodyNow
3325
3345
  // now (bodies render lazily), not once globally here.
3346
+
3347
+ // Measure the now-rendered sticky file header so navigation can offset
3348
+ // targets below it (scroll-margin-top in pr.css).
3349
+ this._measureFileHeaderHeight();
3326
3350
  } else {
3327
3351
  diffContainer.innerHTML = '<div class="no-diff">No files changed</div>';
3328
3352
  }
@@ -6383,7 +6407,16 @@ class PRManager {
6383
6407
  if (!fileWrapper.classList.contains('collapsed')) {
6384
6408
  await this.ensureFileBodyRendered(filePath);
6385
6409
  }
6386
- fileWrapper.scrollIntoView({ behavior: 'smooth', block: 'start' });
6410
+ // Stable variant: lazy bodies between here and the target render as
6411
+ // the smooth scroll passes them, shifting layout mid-flight. The
6412
+ // helper re-corrects after the scroll settles so the first attempt
6413
+ // lands where the second used to.
6414
+ const scrollOptions = { behavior: 'smooth', block: 'start' };
6415
+ if (window.ScrollUtils?.scrollIntoViewStable) {
6416
+ await window.ScrollUtils.scrollIntoViewStable(fileWrapper, scrollOptions);
6417
+ } else {
6418
+ fileWrapper.scrollIntoView(scrollOptions);
6419
+ }
6387
6420
  }
6388
6421
  }
6389
6422
 
@@ -7750,7 +7783,7 @@ class PRManager {
7750
7783
  * @param {string} file - File path
7751
7784
  * @param {number} [lineStart] - Optional line number to highlight
7752
7785
  */
7753
- scrollToContextFile(file, lineStart, contextId) {
7786
+ async scrollToContextFile(file, lineStart, contextId) {
7754
7787
  // Use contextId to find a specific chunk tbody within a merged wrapper,
7755
7788
  // or fall back to a standalone wrapper or the file-level wrapper.
7756
7789
  let target;
@@ -7769,23 +7802,30 @@ class PRManager {
7769
7802
  }
7770
7803
  if (!target) return;
7771
7804
 
7772
- target.scrollIntoView({ behavior: 'smooth', block: 'start' });
7805
+ // Stable variant ensures the target's lazy body is rendered and
7806
+ // re-corrects after lazy renders along the scroll path shift layout.
7807
+ const scrollOptions = { behavior: 'smooth', block: 'start' };
7808
+ if (window.ScrollUtils?.scrollIntoViewStable) {
7809
+ await window.ScrollUtils.scrollIntoViewStable(target, scrollOptions);
7810
+ } else {
7811
+ target.scrollIntoView(scrollOptions);
7812
+ }
7773
7813
 
7774
7814
  if (lineStart) {
7775
7815
  // Search for the line row within the wrapper (not just the target chunk)
7776
7816
  const wrapper = target.closest('.d2h-file-wrapper') || target;
7777
- // Brief delay to let scroll settle, then highlight the target line
7778
- setTimeout(() => {
7779
- const row = wrapper.querySelector(`tr[data-line-number="${lineStart}"]`);
7780
- if (row) {
7817
+ // The awaited stable scroll has already settled (and rendered the lazy
7818
+ // body), so the row exists now — highlight it immediately rather than
7819
+ // pulsing on a stale timer that would fire after the scroll completes.
7820
+ const row = wrapper.querySelector(`tr[data-line-number="${lineStart}"]`);
7821
+ if (row) {
7822
+ row.classList.remove('chat-line-highlight');
7823
+ void row.offsetWidth;
7824
+ row.classList.add('chat-line-highlight');
7825
+ row.addEventListener('animationend', () => {
7781
7826
  row.classList.remove('chat-line-highlight');
7782
- void row.offsetWidth;
7783
- row.classList.add('chat-line-highlight');
7784
- row.addEventListener('animationend', () => {
7785
- row.classList.remove('chat-line-highlight');
7786
- }, { once: true });
7787
- }
7788
- }, 400);
7827
+ }, { once: true });
7828
+ }
7789
7829
  }
7790
7830
  }
7791
7831
 
@@ -0,0 +1,164 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Stable scroll-into-view for the lazily rendered diff panel.
4
+ *
5
+ * Since the large-PR perf fix, expanded file bodies start as empty
6
+ * placeholders (`minHeight` ≈ patch lines × APPROX_DIFF_LINE_PX) and only
7
+ * render real rows when an IntersectionObserver sees them near the viewport.
8
+ * A plain `scrollIntoView()` therefore lands wrong on the first attempt:
9
+ * the browser computes the destination from placeholder heights, then the
10
+ * bodies passed during the scroll render and change height, shifting the
11
+ * target away from where the animation ends. A second attempt "works"
12
+ * because everything along the path has rendered by then.
13
+ *
14
+ * `scrollIntoViewStable()` fixes this by:
15
+ * 1. Rendering the target's own file body first (rows inside a lazy body
16
+ * don't exist until rendered, and its placeholder height is wrong).
17
+ * 2. Issuing the caller's scroll (smooth behavior preserved).
18
+ * 3. Waiting for the viewport-relative position of the target to stop
19
+ * moving (scroll animation done AND observer-triggered renders settled),
20
+ * then re-issuing an instant scroll. If that correction moved the
21
+ * target, newly revealed placeholders rendered and shifted layout
22
+ * again — so settle and correct again, up to MAX_CORRECTIONS times.
23
+ *
24
+ * The settle loop aborts if the user starts scrolling themselves (wheel /
25
+ * touch / scroll-intent keys) so corrections never fight real input, and
26
+ * whenever the target leaves the DOM (file list re-render, tour unmount).
27
+ */
28
+
29
+ /** Corrective re-scroll attempts after the initial scroll. */
30
+ const MAX_CORRECTIONS = 4;
31
+ /** Position delta (px) treated as "didn't move". */
32
+ const STABLE_PX = 2;
33
+ /** Consecutive same-position frames before the target counts as settled. */
34
+ const SETTLE_FRAMES = 3;
35
+ /** Hard cap on one settle wait — covers the longest smooth animation. */
36
+ const SETTLE_TIMEOUT_MS = 2000;
37
+
38
+ /** Keys that express scroll intent and should cancel pending corrections. */
39
+ const SCROLL_KEYS = new Set([
40
+ 'ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '
41
+ ]);
42
+
43
+ /**
44
+ * Latest-scroll-wins token. Every call captures `++activeGeneration`; any
45
+ * call whose captured value no longer matches has been superseded by a newer
46
+ * scroll and must bow out so stale settle loops don't fight or snap the
47
+ * viewport back to an old target.
48
+ */
49
+ let activeGeneration = 0;
50
+
51
+ /**
52
+ * Wait until `target`'s viewport-relative top is unchanged for
53
+ * SETTLE_FRAMES consecutive animation frames (or SETTLE_TIMEOUT_MS passes).
54
+ * Resolves early when the target is disconnected or `isCancelled()` trips.
55
+ * @param {Element} target
56
+ * @param {() => boolean} isCancelled
57
+ * @returns {Promise<void>}
58
+ */
59
+ function waitForStablePosition(target, isCancelled) {
60
+ return new Promise((resolve) => {
61
+ const start = Date.now();
62
+ let lastTop = null;
63
+ let stableFrames = 0;
64
+ const tick = () => {
65
+ if (isCancelled() || !target.isConnected || Date.now() - start > SETTLE_TIMEOUT_MS) {
66
+ resolve();
67
+ return;
68
+ }
69
+ const top = target.getBoundingClientRect().top;
70
+ if (lastTop !== null && Math.abs(top - lastTop) <= STABLE_PX) {
71
+ stableFrames += 1;
72
+ if (stableFrames >= SETTLE_FRAMES) {
73
+ resolve();
74
+ return;
75
+ }
76
+ } else {
77
+ stableFrames = 0;
78
+ }
79
+ lastTop = top;
80
+ requestAnimationFrame(tick);
81
+ };
82
+ requestAnimationFrame(tick);
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Scroll `target` into view and keep it there while lazy file bodies render.
88
+ * Safe to call on any element; outside a lazy diff it degrades to one
89
+ * `scrollIntoView` plus a settle wait. Fire-and-forget friendly.
90
+ * @param {Element} target - Element to bring into view
91
+ * @param {ScrollIntoViewOptions} [options] - Passed to the initial scroll;
92
+ * corrective re-scrolls force `behavior: 'auto'`.
93
+ * @returns {Promise<void>} resolves once the position is stable (or aborted)
94
+ */
95
+ async function scrollIntoViewStable(target, options = {}) {
96
+ if (!target || !target.isConnected || typeof target.scrollIntoView !== 'function') return;
97
+
98
+ // Claim the active-scroll slot. A later call bumps activeGeneration past
99
+ // ours, at which point isCancelled() trips and this call bows out.
100
+ const myGen = ++activeGeneration;
101
+
102
+ // Render the target's own lazy body first: until then its rows don't
103
+ // exist and the wrapper's height is the placeholder estimate. Skip
104
+ // collapsed wrappers — their body is display:none (zero height), so
105
+ // rendering would pay full renderPatch cost without changing the scroll.
106
+ // Skip file-level comment/suggestion cards too: they live in
107
+ // `.file-comments-zone`, which sits above the lazy body, so rendering the
108
+ // body can't move them — only burn the renderPatch cost we want to avoid.
109
+ const prManager = (typeof window !== 'undefined') ? window.prManager : null;
110
+ const wrapper = target.closest?.('.d2h-file-wrapper');
111
+ if (wrapper && !target.closest?.('.file-comments-zone')
112
+ && !wrapper.classList.contains('collapsed')
113
+ && typeof prManager?.ensureFileBodyRendered === 'function') {
114
+ try {
115
+ await prManager.ensureFileBodyRendered(wrapper);
116
+ } catch (err) {
117
+ console.warn('[ScrollUtils] ensureFileBodyRendered failed; scrolling anyway', err);
118
+ }
119
+ if (!target.isConnected || myGen !== activeGeneration) return;
120
+ }
121
+
122
+ // Cancel corrections when the user scrolls on their own OR when a newer
123
+ // scrollIntoViewStable call supersedes this one (latest-scroll-wins).
124
+ let cancelled = false;
125
+ const isCancelled = () => cancelled || myGen !== activeGeneration;
126
+ const cancel = () => { cancelled = true; };
127
+ const onKeyDown = (e) => {
128
+ // Scroll-intent keys are also everyday caret/typing keys inside form
129
+ // fields — there they mean "move the cursor", not "scroll the page", so
130
+ // they must not abort the correction loop.
131
+ if (e.target?.closest?.('input, textarea, select') || e.target?.isContentEditable) return;
132
+ if (SCROLL_KEYS.has(e.key)) cancelled = true;
133
+ };
134
+ window.addEventListener('wheel', cancel, { capture: true, passive: true });
135
+ window.addEventListener('touchstart', cancel, { capture: true, passive: true });
136
+ window.addEventListener('keydown', onKeyDown, { capture: true });
137
+
138
+ try {
139
+ target.scrollIntoView(options);
140
+ for (let i = 0; i < MAX_CORRECTIONS; i++) {
141
+ await waitForStablePosition(target, isCancelled);
142
+ if (isCancelled() || !target.isConnected) return;
143
+ // Re-issue instantly: a no-op when the smooth scroll landed true, a
144
+ // snap to the real position when lazy renders shifted the layout.
145
+ const before = target.getBoundingClientRect().top;
146
+ target.scrollIntoView({ ...options, behavior: 'auto' });
147
+ if (Math.abs(target.getBoundingClientRect().top - before) <= STABLE_PX) return;
148
+ // The correction moved us — newly revealed bodies may render and
149
+ // shift layout once more; loop to settle and verify again.
150
+ }
151
+ } finally {
152
+ window.removeEventListener('wheel', cancel, { capture: true });
153
+ window.removeEventListener('touchstart', cancel, { capture: true });
154
+ window.removeEventListener('keydown', onKeyDown, { capture: true });
155
+ }
156
+ }
157
+
158
+ if (typeof window !== 'undefined') {
159
+ window.ScrollUtils = { scrollIntoViewStable, waitForStablePosition };
160
+ }
161
+
162
+ if (typeof module !== 'undefined' && module.exports) {
163
+ module.exports = { scrollIntoViewStable, waitForStablePosition, MAX_CORRECTIONS, STABLE_PX, SETTLE_FRAMES, SETTLE_TIMEOUT_MS };
164
+ }
package/public/local.html CHANGED
@@ -614,6 +614,9 @@
614
614
  <!-- Modal detection (shared by KeyboardShortcuts and PRManager) -->
615
615
  <script src="/js/utils/modal-detection.js"></script>
616
616
 
617
+ <!-- Stable scroll-into-view for lazily rendered diff bodies -->
618
+ <script src="/js/utils/scroll-into-view.js"></script>
619
+
617
620
  <!-- WebSocket client -->
618
621
  <script src="/js/ws-client.js"></script>
619
622
 
package/public/pr.html CHANGED
@@ -417,6 +417,9 @@
417
417
  <!-- Modal detection (shared by KeyboardShortcuts and PRManager) -->
418
418
  <script src="/js/utils/modal-detection.js"></script>
419
419
 
420
+ <!-- Stable scroll-into-view for lazily rendered diff bodies -->
421
+ <script src="/js/utils/scroll-into-view.js"></script>
422
+
420
423
  <!-- WebSocket client -->
421
424
  <script src="/js/ws-client.js"></script>
422
425
 
@@ -26,12 +26,40 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
26
26
  * in the constructor's base args; individual models can override this via extra_args
27
27
  * (e.g., Haiku uses adaptive thinking for efficiency).
28
28
  *
29
- * Effort support by model (newest CLIs): Opus 4.8 / 4.7 support low|medium|high|
30
- * xhigh|max; Opus 4.6 & Sonnet 4.6 support low|medium|high|max (no xhigh); Haiku
31
- * has no effort levels.
29
+ * Effort support by model (newest CLIs): Fable 5 and Opus 4.8 / 4.7 support
30
+ * low|medium|high|xhigh|max; Opus 4.6 & Sonnet 4.6 support low|medium|high|max
31
+ * (no xhigh); Haiku has no effort levels.
32
32
  */
33
33
  const CLAUDE_MODELS = [
34
34
  // ── Thorough tier ───────────────────────────────────────────────────────
35
+ {
36
+ id: 'fable',
37
+ aliases: ['fable-5-xhigh'],
38
+ cli_model: 'claude-fable-5',
39
+ env: { CLAUDE_CODE_EFFORT_LEVEL: 'xhigh' },
40
+ name: 'Fable 5 XHigh',
41
+ tier: 'thorough',
42
+ tagline: 'New Model Tier',
43
+ description: 'Fable 5 (new tier above Opus) with extra-high effort',
44
+ badge: 'Extra-High Effort',
45
+ badgeClass: 'badge-power',
46
+ // Fable 5 is adaptive-thinking-only: an explicit "enabled"/"disabled"
47
+ // thinking mode is rejected by the API, so override the global
48
+ // `--thinking enabled` base arg (last occurrence wins in commander).
49
+ extra_args: ['--thinking', 'adaptive']
50
+ },
51
+ {
52
+ id: 'fable-5-high',
53
+ cli_model: 'claude-fable-5',
54
+ env: { CLAUDE_CODE_EFFORT_LEVEL: 'high' },
55
+ name: 'Fable 5 High',
56
+ tier: 'thorough',
57
+ tagline: 'New Model Tier',
58
+ description: 'Fable 5 with high effort — quicker than XHigh',
59
+ badge: 'High Effort',
60
+ badgeClass: 'badge-power',
61
+ extra_args: ['--thinking', 'adaptive']
62
+ },
35
63
  {
36
64
  id: 'opus',
37
65
  aliases: ['opus-4.7-xhigh'],
package/src/main.js CHANGED
@@ -159,8 +159,9 @@ OPTIONS:
159
159
  The web UI also starts for the human reviewer.
160
160
  --model <name> Override the AI model. Claude Code is the default provider.
161
161
  Available models: opus, sonnet, haiku (Claude Code);
162
- also: opus-4.8-xhigh, opus-4.8-high, opus-4.7-xhigh,
163
- opus-4.7-high, opus-4.6-high, opus-4.6-1m, sonnet-4.6
162
+ also: fable-5-xhigh, fable-5-high, opus-4.8-xhigh,
163
+ opus-4.8-high, opus-4.7-xhigh, opus-4.7-high,
164
+ opus-4.6-high, opus-4.6-1m, sonnet-4.6
164
165
  (opus is Opus 4.7 XHigh, the default)
165
166
  or use provider-specific models with Gemini/Codex
166
167
  --use-checkout Use current directory instead of creating worktree
@@ -19,7 +19,7 @@ const { normalizeRepository } = require('../utils/paths');
19
19
  const { mergeInstructions } = require('../utils/instructions');
20
20
  const { GitWorktreeManager } = require('../git/worktree');
21
21
  const { GitHubClient } = require('../github/client');
22
- const { getGitHubToken, resolveHostBinding, resolveBindingRepositoryFromPR, resolveLoadSkills, buildCouncilProviderOverrides } = require('../config');
22
+ const { getGitHubToken, resolveHostBinding, resolveBindingRepositoryFromPR, resolveRepoOptions, resolveLoadSkills, buildCouncilProviderOverrides } = require('../config');
23
23
  const { setupStackPR } = require('../setup/stack-setup');
24
24
  const Analyzer = require('../ai/analyzer');
25
25
  const { getProviderClass, createProvider } = require('../ai/provider');
@@ -166,6 +166,7 @@ const defaults = {
166
166
  getGitHubToken,
167
167
  resolveHostBinding,
168
168
  resolveBindingRepositoryFromPR,
169
+ resolveRepoOptions,
169
170
  setupStackPR,
170
171
  Analyzer,
171
172
  getProviderClass,
@@ -205,8 +206,23 @@ async function executeStackAnalysis(params) {
205
206
  if (!state) return;
206
207
 
207
208
  try {
209
+ // 0. Resolve the config-binding key once. It drives both the per-repo
210
+ // worktree config (just below) and the host binding (step 3). For
211
+ // monorepo-style `url_pattern` configs this differs from the PR
212
+ // identity `${owner}/${repo}`.
213
+ const bindingRepository = deps.resolveBindingRepositoryFromPR(owner, repo, config);
214
+ // Honor the repo's configured worktree options so stack worktrees match the
215
+ // non-stack path. This carries through:
216
+ // - worktreeConfig (worktree_name_template / worktree_directory) so they
217
+ // don't fall back to pair-review's default naming and location, and
218
+ // - the checkout script + timeout and sparse-checkout inheritance used
219
+ // during per-PR worktree creation (forwarded to createWorktreeForPR).
220
+ // These derive purely from file config, so DB repo_settings (pool config)
221
+ // are not needed here.
222
+ const { worktreeConfig, checkoutScript, checkoutTimeout } = deps.resolveRepoOptions(config, bindingRepository);
223
+
208
224
  // 1. Resolve repositoryPath from trigger worktree
209
- const worktreeManager = new deps.GitWorktreeManager(db);
225
+ const worktreeManager = new deps.GitWorktreeManager(db, worktreeConfig || {});
210
226
  let repositoryPath;
211
227
  try {
212
228
  const owningRepoGit = await worktreeManager.resolveOwningRepo(triggerWorktreePath);
@@ -230,13 +246,13 @@ async function executeStackAnalysis(params) {
230
246
  logger.warn(`Bulk git fetch failed, will fetch per-PR: ${fetchError.message}`);
231
247
  }
232
248
 
233
- // 3. Fetch all PR data from GitHub in parallel
234
- // Use the per-repo binding so alt-host stack analyses target the right host.
235
- // The PR identity (`${owner}/${repo}`) is used for DB rows and worktree
236
- // identity. For host-binding lookups we MUST use the config-binding key,
237
- // which differs from the PR identity for monorepo-style `url_pattern`
238
- // configs (one `repos[...]` entry serves many captured owner/repo pairs).
239
- const bindingRepository = deps.resolveBindingRepositoryFromPR(owner, repo, config);
249
+ // 3. Fetch all PR data from GitHub in parallel.
250
+ // `bindingRepository` (resolved in step 0) is the config-binding key. We use
251
+ // it — not the PR identity `${owner}/${repo}` for host-binding lookups so
252
+ // alt-host stack analyses target the right host. The two differ for
253
+ // monorepo-style `url_pattern` configs (one `repos[...]` entry serves many
254
+ // captured owner/repo pairs). The PR identity is still used for DB rows and
255
+ // worktree identity.
240
256
  const stackBinding = deps.resolveHostBinding(bindingRepository, config);
241
257
  const githubToken = stackBinding.token;
242
258
  const prDataMap = new Map();
@@ -275,7 +291,16 @@ async function executeStackAnalysis(params) {
275
291
 
276
292
  const prInfo = { owner, repo, number: prNum };
277
293
  const { path: perPRWorktreePath } = await worktreeManager.createWorktreeForPR(
278
- prInfo, prData, repositoryPath
294
+ prInfo, prData, repositoryPath,
295
+ {
296
+ checkoutScript,
297
+ checkoutTimeout,
298
+ // No checkout script → inherit the trigger worktree's sparse-checkout
299
+ // layout instead of a full checkout from the repo root. When a script is
300
+ // configured it sets up sparse-checkout itself, so worktreeSourcePath is
301
+ // unused (and omitted) in that case.
302
+ ...(checkoutScript ? {} : { worktreeSourcePath: triggerWorktreePath }),
303
+ }
279
304
  );
280
305
  worktreePathMap.set(prNum, perPRWorktreePath);
281
306
  } catch (wtError) {
@@ -307,6 +332,7 @@ async function executeStackAnalysis(params) {
307
332
  worktreePath: worktreePathMap.get(prNum),
308
333
  analysisConfig, stackAnalysisId, state,
309
334
  githubToken, binding: stackBinding, prData: prDataMap.get(prNum),
335
+ worktreeConfig, checkoutScript,
310
336
  onAnalysisIdReady
311
337
  }).then(result => {
312
338
  state.prStatuses.set(prNum, {
@@ -346,6 +372,7 @@ async function executeStackAnalysis(params) {
346
372
  async function analyzeStackPR(deps, db, config, {
347
373
  owner, repo, repository, bindingRepository, prNum, worktreePath,
348
374
  analysisConfig, stackAnalysisId, state, githubToken, binding, prData,
375
+ worktreeConfig, checkoutScript,
349
376
  onAnalysisIdReady
350
377
  }) {
351
378
  // Build a GitHubClient for analyzer-side dedup pre-fetch. The stack
@@ -357,12 +384,17 @@ async function analyzeStackPR(deps, db, config, {
357
384
  if (stackGithubClient) {
358
385
  logger.debug(`analyzer githubClient wired for ${owner}/${repo}#${prNum} (stack)`);
359
386
  }
360
- // 1. Setup PR (generates diff, stores metadata)
361
- const worktreeManager = new deps.GitWorktreeManager(db);
387
+ // 1. Setup PR (expands sparse-checkout, generates diff, stores metadata)
388
+ // Construct with the repo's resolved worktreeConfig for consistency with the
389
+ // creation manager. setupStackPR operates against the explicit worktreePath
390
+ // (diff generation + sparse-cone expansion), so the worktreeConfig naming /
391
+ // directory options are not exercised today — threading them through guards
392
+ // against silent latent regressions if that changes.
393
+ const worktreeManager = new deps.GitWorktreeManager(db, worktreeConfig || {});
362
394
  await deps.setupStackPR({
363
395
  db, owner, repo, prNumber: prNum,
364
396
  githubToken, binding, bindingRepository,
365
- worktreePath, worktreeManager, prData
397
+ worktreePath, worktreeManager, prData, checkoutScript
366
398
  });
367
399
 
368
400
  // 2. Fetch prMetadata from DB
@@ -32,9 +32,12 @@ const logger = require('../utils/logger');
32
32
  * @param {string} params.worktreePath - Path to the per-PR worktree
33
33
  * @param {import('../git/worktree').GitWorktreeManager} params.worktreeManager - Worktree manager instance
34
34
  * @param {Object} [params.prData] - Pre-fetched PR data from GitHub (skips API call when provided)
35
+ * @param {string|null} [params.checkoutScript] - Repo's configured checkout script, if any. When set,
36
+ * the script owns all sparse-checkout setup, so built-in sparse-cone expansion is skipped (mirrors
37
+ * the non-stack `pr-setup.js` contract).
35
38
  * @returns {Promise<{ reviewId: number, prMetadata: Object, prData: Object, isNew: boolean }>}
36
39
  */
37
- async function setupStackPR({ db, owner, repo, prNumber, githubToken, binding, bindingRepository, worktreePath, worktreeManager, prData: prefetchedPRData }) {
40
+ async function setupStackPR({ db, owner, repo, prNumber, githubToken, binding, bindingRepository, worktreePath, worktreeManager, prData: prefetchedPRData, checkoutScript }) {
38
41
  // `bindingRepository` is accepted so callers (e.g. `executeStackAnalysis`)
39
42
  // can thread the resolved config-binding key through to any downstream
40
43
  // per-repo lookups added in this function. Currently unused inside this
@@ -56,13 +59,38 @@ async function setupStackPR({ db, owner, repo, prNumber, githubToken, binding, b
56
59
  const prFiles = await githubClient.fetchPullRequestFiles(owner, repo, prNumber);
57
60
  logger.info(`PR #${prNumber} has ${prFiles.length} changed files`);
58
61
 
59
- // 3. Generate diff in the worktree (SHA-based, works after checkout)
62
+ // 3. Expand sparse-checkout for PR-changed directories (mirrors pr-setup.js).
63
+ // Stack worktrees inherit the trigger worktree's sparse-checkout layout, which
64
+ // may omit directories a sibling PR touches. The SHA-based diff below reads
65
+ // commit objects (not the working tree) so it is unaffected, but the later
66
+ // file-context and codebase-context analysis steps DO read files from disk —
67
+ // an unexpanded cone would silently under-review those files. Expanding here
68
+ // ensures every PR-changed directory is present on disk.
69
+ //
70
+ // IMPORTANT: when a checkout_script is configured the script owns all
71
+ // sparse-checkout setup, so we must NOT auto-expand — doing so would override
72
+ // the cone the script just configured. This matches the pr-setup.js contract.
73
+ if (!checkoutScript && prFiles.length > 0) {
74
+ const isSparse = await worktreeManager.isSparseCheckoutEnabled(worktreePath);
75
+ if (isSparse) {
76
+ try {
77
+ const addedDirs = await worktreeManager.ensurePRDirectoriesInSparseCheckout(worktreePath, prFiles);
78
+ if (addedDirs.length > 0) {
79
+ logger.info(`Stack PR #${prNumber}: expanded sparse-checkout for: ${addedDirs.join(', ')}`);
80
+ }
81
+ } catch (sparseError) {
82
+ logger.warn(`Stack PR #${prNumber}: sparse-checkout expansion failed (non-fatal): ${sparseError.message}`);
83
+ }
84
+ }
85
+ }
86
+
87
+ // 4. Generate diff in the worktree (SHA-based, works after checkout)
60
88
  const diff = await worktreeManager.generateUnifiedDiff(worktreePath, prData);
61
89
 
62
- // 4. Get changed files with stats
90
+ // 5. Get changed files with stats
63
91
  const changedFiles = await worktreeManager.getChangedFiles(worktreePath, prData);
64
92
 
65
- // 5. Store via storePRData (creates/updates pr_metadata, reviews, worktrees records)
93
+ // 6. Store via storePRData (creates/updates pr_metadata, reviews, worktrees records)
66
94
  const prInfo = { owner, repo, number: prNumber };
67
95
  const { isNewReview, reviewId } = await storePRData(db, prInfo, prData, diff, changedFiles, worktreePath);
68
96