@in-the-loop-labs/pair-review 3.7.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.
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.1",
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.1",
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.1",
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
  */
@@ -3323,6 +3339,10 @@ class PRManager {
3323
3339
 
3324
3340
  // NOTE: end-of-file gap validation runs per-file inside _renderFileBodyNow
3325
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();
3326
3346
  } else {
3327
3347
  diffContainer.innerHTML = '<div class="no-diff">No files changed</div>';
3328
3348
  }
@@ -6383,7 +6403,16 @@ class PRManager {
6383
6403
  if (!fileWrapper.classList.contains('collapsed')) {
6384
6404
  await this.ensureFileBodyRendered(filePath);
6385
6405
  }
6386
- fileWrapper.scrollIntoView({ behavior: 'smooth', block: 'start' });
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
+ }
6387
6416
  }
6388
6417
  }
6389
6418
 
@@ -7750,7 +7779,7 @@ class PRManager {
7750
7779
  * @param {string} file - File path
7751
7780
  * @param {number} [lineStart] - Optional line number to highlight
7752
7781
  */
7753
- scrollToContextFile(file, lineStart, contextId) {
7782
+ async scrollToContextFile(file, lineStart, contextId) {
7754
7783
  // Use contextId to find a specific chunk tbody within a merged wrapper,
7755
7784
  // or fall back to a standalone wrapper or the file-level wrapper.
7756
7785
  let target;
@@ -7769,23 +7798,30 @@ class PRManager {
7769
7798
  }
7770
7799
  if (!target) return;
7771
7800
 
7772
- 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
+ }
7773
7809
 
7774
7810
  if (lineStart) {
7775
7811
  // Search for the line row within the wrapper (not just the target chunk)
7776
7812
  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) {
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', () => {
7781
7822
  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);
7823
+ }, { once: true });
7824
+ }
7789
7825
  }
7790
7826
  }
7791
7827
 
@@ -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