@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 +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +17 -0
- package/public/js/components/AIPanel.js +63 -34
- package/public/js/components/ChatPanel.js +11 -1
- package/public/js/components/SuggestionNavigator.js +55 -10
- package/public/js/modules/tour-renderer.js +12 -2
- package/public/js/pr.js +50 -14
- package/public/js/utils/scroll-into-view.js +164 -0
- package/public/local.html +3 -0
- package/public/pr.html +3 -0
- package/src/ai/claude-provider.js +31 -3
- package/src/main.js +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pair-review",
|
|
3
|
-
"version": "3.7.
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1308
|
-
|
|
1309
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1388
|
-
|
|
1389
|
-
|
|
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
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
7778
|
-
|
|
7779
|
-
|
|
7780
|
-
|
|
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
|
-
|
|
7783
|
-
|
|
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
|
|
30
|
-
* xhigh|max; Opus 4.6 & Sonnet 4.6 support low|medium|high|max
|
|
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:
|
|
163
|
-
opus-4.
|
|
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
|