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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +4 -0
  2. package/package.json +20 -15
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +0 -0
  6. package/public/css/analysis-config.css +1807 -0
  7. package/public/css/pr.css +17 -1737
  8. package/public/index.html +11 -0
  9. package/public/js/components/AIPanel.js +89 -44
  10. package/public/js/components/AdvancedConfigTab.js +56 -4
  11. package/public/js/components/AnalysisConfigModal.js +41 -25
  12. package/public/js/components/ChatPanel.js +11 -1
  13. package/public/js/components/ReviewModal.js +135 -13
  14. package/public/js/components/SuggestionNavigator.js +55 -10
  15. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  16. package/public/js/index.js +175 -16
  17. package/public/js/local.js +58 -8
  18. package/public/js/modules/suggestion-manager.js +25 -1
  19. package/public/js/modules/tour-renderer.js +45 -5
  20. package/public/js/pr.js +703 -171
  21. package/public/js/repo-links.js +328 -0
  22. package/public/js/utils/provider-model.js +88 -0
  23. package/public/js/utils/scroll-into-view.js +164 -0
  24. package/public/js/utils/storage-keys.js +50 -0
  25. package/public/local.html +10 -0
  26. package/public/pr.html +10 -0
  27. package/public/repo-settings.html +1 -0
  28. package/public/setup.html +2 -0
  29. package/src/ai/analyzer.js +125 -18
  30. package/src/ai/claude-provider.js +31 -3
  31. package/src/config.js +664 -10
  32. package/src/external/github-adapter.js +114 -25
  33. package/src/git/base-branch.js +11 -4
  34. package/src/github/client.js +482 -588
  35. package/src/github/errors.js +55 -0
  36. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  37. package/src/github/impl/graphql/pending-review.js +153 -0
  38. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  39. package/src/github/impl/graphql/stack-walker.js +210 -0
  40. package/src/github/impl/host/pending-review-comments.js +338 -0
  41. package/src/github/impl/rest/pending-review.js +251 -0
  42. package/src/github/impl/rest/review-lifecycle.js +226 -0
  43. package/src/github/impl/rest/stack-walker.js +309 -0
  44. package/src/github/operations/pending-review-comments.js +79 -0
  45. package/src/github/operations/pending-review.js +89 -0
  46. package/src/github/operations/review-lifecycle.js +126 -0
  47. package/src/github/operations/stack-walker.js +87 -0
  48. package/src/github/parser.js +230 -4
  49. package/src/github/stack-walker.js +14 -189
  50. package/src/links/repo-links.js +230 -0
  51. package/src/local-review.js +13 -4
  52. package/src/main.js +136 -32
  53. package/src/routes/analyses.js +30 -7
  54. package/src/routes/bulk-analysis-configs.js +295 -0
  55. package/src/routes/config.js +102 -2
  56. package/src/routes/external-comments.js +20 -10
  57. package/src/routes/github-collections.js +3 -1
  58. package/src/routes/local.js +101 -11
  59. package/src/routes/mcp.js +47 -4
  60. package/src/routes/pr.js +298 -68
  61. package/src/routes/setup.js +8 -3
  62. package/src/routes/stack-analysis.js +33 -9
  63. package/src/routes/worktrees.js +3 -2
  64. package/src/server.js +2 -0
  65. package/src/setup/pr-setup.js +37 -11
  66. package/src/setup/stack-setup.js +13 -3
  67. package/src/single-port.js +6 -3
@@ -75,11 +75,12 @@ class LocalManager {
75
75
  try {
76
76
  // Fetch repo settings so we honour the repository's default provider/council
77
77
  const manager = window.prManager;
78
- const [repoSettings, reviewSettings] = await Promise.all([
78
+ const [repoSettings, reviewSettings, appConfig] = await Promise.all([
79
79
  manager.fetchRepoSettings().catch(() => null),
80
- manager.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null }))
80
+ manager.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null })),
81
+ manager._getAppConfig()
81
82
  ]);
82
- const config = await manager._buildDefaultAnalysisConfig(repoSettings, reviewSettings);
83
+ const config = await manager._buildDefaultAnalysisConfig(repoSettings, reviewSettings, appConfig);
83
84
 
84
85
  await this.startLocalAnalysis(null, config);
85
86
  } finally {
@@ -268,11 +269,25 @@ class LocalManager {
268
269
  ? manager._stalenessPromise
269
270
  : self._fetchLocalStaleness();
270
271
  manager._stalenessPromise = null; // consume it
272
+ // Pass owner+repo to /api/config (when we have a remote) so
273
+ // has_github_token reflects the repo's actual auth — covers
274
+ // repo-scoped tokens, token_command, and alt-host bindings.
275
+ // Local sessions without a remote origin fall back to the
276
+ // global-only response (has_global_github_token), which the
277
+ // modal already treats as no-GitHub-auth for dedup purposes.
278
+ let configUrl = '/api/config';
279
+ const localRepo = self.localData?.repository;
280
+ if (typeof localRepo === 'string' && localRepo.includes('/')) {
281
+ const [lOwner, lRepo] = localRepo.split('/');
282
+ if (lOwner && lRepo) {
283
+ configUrl = `/api/config?owner=${encodeURIComponent(lOwner)}&repo=${encodeURIComponent(lRepo)}`;
284
+ }
285
+ }
271
286
  const [staleResult, repoSettings, reviewSettings, appConfig] = await Promise.all([
272
287
  staleCheckWithTimeout,
273
288
  manager.fetchRepoSettings().catch(() => null),
274
289
  manager.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null })),
275
- fetch('/api/config').then(r => r.ok ? r.json() : {}).catch(() => ({}))
290
+ fetch(configUrl).then(r => r.ok ? r.json() : {}).catch(() => ({}))
276
291
  ]);
277
292
  console.debug(`[Analyze] parallel-fetch (stale+settings): ${Math.round(performance.now() - _tParallel0)}ms`);
278
293
 
@@ -310,9 +325,14 @@ class LocalManager {
310
325
 
311
326
  const lastCouncilId = reviewSettings.last_council_id;
312
327
 
313
- // Determine model and provider (priority: repo default > defaults)
314
- const currentModel = repoSettings?.default_model || 'opus';
315
- const currentProvider = repoSettings?.default_provider || 'claude';
328
+ // Resolve provider and model as a MATCHED pair so the council/advanced tabs
329
+ // are never seeded with a cross-provider model (e.g. gemini + opus), which
330
+ // would blank the model <select> and be rejected by the backend.
331
+ const providersInfo = await manager._getProvidersInfo();
332
+ const { provider: currentProvider, model: currentModel } = window.resolveProviderModelPair([
333
+ { provider: repoSettings?.default_provider, model: repoSettings?.default_model },
334
+ { provider: appConfig.default_provider, model: appConfig.default_model }
335
+ ], providersInfo);
316
336
 
317
337
  // Determine default tab (priority: localStorage > repo settings > 'single')
318
338
  const tabStorageKey = `pair-review-tab:local-${reviewId}`;
@@ -340,7 +360,14 @@ class LocalManager {
340
360
  lastCouncilId,
341
361
  defaultCouncilId: repoSettings?.default_council_id || null,
342
362
  hasPr: false,
343
- hasGithubToken: Boolean(appConfig.has_github_token)
363
+ // Prefer the repo-aware field when present (we passed owner+repo
364
+ // to /api/config). For local sessions without a remote origin
365
+ // we fall back to the global capability — there is no repo
366
+ // binding to honour, so the global token is the only token a
367
+ // dedup operation could use anyway.
368
+ hasGithubToken: Boolean(
369
+ appConfig.has_github_token ?? appConfig.has_global_github_token
370
+ )
344
371
  });
345
372
 
346
373
  if (!config) {
@@ -1046,6 +1073,29 @@ class LocalManager {
1046
1073
  // Update header with local info
1047
1074
  this.updateLocalHeader(reviewData);
1048
1075
 
1076
+ // Apply per-repo header link customisation (Phase 7: alt-host support).
1077
+ // Only render the external link when the local review is associated
1078
+ // with a `repos` entry — i.e. the stored repository looks like
1079
+ // `owner/repo`. Local sessions without a remote origin skip this
1080
+ // entirely; built-in GitHub/Graphite links don't appear in Local
1081
+ // mode's header today so suppression has no visible effect there
1082
+ // either, but we still call through so any external link is
1083
+ // inserted into the icon group.
1084
+ if (window.RepoLinks && typeof reviewData.repository === 'string'
1085
+ && reviewData.repository.includes('/')) {
1086
+ const [linkOwner, linkRepo] = reviewData.repository.split('/');
1087
+ window.RepoLinks.fetchAndApplyRepoLinks(linkOwner, linkRepo, {
1088
+ owner: linkOwner,
1089
+ repo: linkRepo,
1090
+ // {number} is intentionally omitted for Local mode — templates
1091
+ // that require it will fail substitution and the link will be
1092
+ // dropped. Local reviews are not associated with a PR number.
1093
+ branch: reviewData.branch,
1094
+ base_branch: reviewData.baseBranch,
1095
+ head_sha: reviewData.localHeadSha,
1096
+ });
1097
+ }
1098
+
1049
1099
  // Fetch and display diff
1050
1100
  await this.loadLocalDiff();
1051
1101
 
@@ -258,11 +258,35 @@ class SuggestionManager {
258
258
  existingSuggestionRows.forEach(row => row.remove());
259
259
  console.log(`[UI] Removed ${existingSuggestionRows.length} existing suggestion rows`);
260
260
 
261
+ // Only inline (line-targeted) suggestions need the diff body rendered
262
+ // ahead of time. File-level findings (is_file_level === 1 or no
263
+ // line_start) are handed to FileCommentManager and rendered above the
264
+ // diff — they never scan <tr> rows or call findHiddenSuggestions, so
265
+ // forcing their bodies to render would recreate the eager-render cost
266
+ // the lazy-body change removed (reviews dominated by file-level analyses
267
+ // are a common repository-wide pattern). Filter once and reuse below.
268
+ const inlineSuggestions = suggestions.filter(
269
+ s => s.file && s.line_start != null && s.is_file_level !== 1
270
+ );
271
+
272
+ // Render the lazy body of every file an INLINE suggestion targets BEFORE
273
+ // scanning rows. With lazy rendering an unrendered file has zero rows, so
274
+ // without this findHiddenSuggestions() would flag every line as "hidden"
275
+ // and we'd gap-expand against bodies that aren't even built yet.
276
+ // ensureFileBodyRendered is a cheap no-op for files already rendered or
277
+ // not in the diff.
278
+ if (this.prManager?.ensureFileBodyRendered) {
279
+ const targetFiles = new Set(inlineSuggestions.map(s => s.file));
280
+ for (const file of targetFiles) {
281
+ await this.prManager.ensureFileBodyRendered(file);
282
+ }
283
+ }
284
+
261
285
  // Auto-expand hidden lines for suggestions that target non-visible lines
262
286
  // Pass the side parameter so expandForSuggestion knows which coordinate system to use:
263
287
  // - RIGHT side = NEW coordinates (modified file, most common for AI suggestions)
264
288
  // - LEFT side = OLD coordinates (deleted lines from original file)
265
- const hiddenSuggestions = this.findHiddenSuggestions(suggestions);
289
+ const hiddenSuggestions = this.findHiddenSuggestions(inlineSuggestions);
266
290
  if (hiddenSuggestions.length > 0) {
267
291
  console.log(`[UI] Found ${hiddenSuggestions.length} suggestions targeting hidden lines, expanding...`);
268
292
  for (const hidden of hiddenSuggestions) {
@@ -251,13 +251,29 @@ class TourRenderer {
251
251
  * `collapsedFiles` would leave the file-tree and viewed-badge state
252
252
  * lying.
253
253
  *
254
+ * Async because `toggleFileCollapse` is now async (it awaits the lazy file
255
+ * body render before revealing it). The caller (`_advanceTour`) awaits this
256
+ * so the file is visibly expanded before `scrollToStop` runs — otherwise a
257
+ * stop inside a just-expanded file could be scrolled to while its rows are
258
+ * still hidden.
259
+ *
254
260
  * @param {number} index
255
- * @returns {HTMLTableRowElement|null}
261
+ * @returns {Promise<HTMLTableRowElement|null>}
256
262
  */
257
- mountStop(index) {
263
+ async mountStop(index) {
258
264
  const stop = this._stops[index];
259
265
  if (!stop) return null;
260
266
 
267
+ // Capture the open-generation ONCE at entry. The `toggleFileCollapse`
268
+ // await below is a suspension window — the tour can be exited or
269
+ // restarted (Escape, reopen) while it's in flight, which bumps
270
+ // `_tourGen` and runs `unmountAll`. Mirrors prepareStop's guard so we
271
+ // can bail without recording state for a dead tour. `?.` because
272
+ // prManager may be absent (the non-collapsed path has no await, so
273
+ // isStale stays harmlessly false there).
274
+ const startGen = this.prManager?._tourGen;
275
+ const isStale = () => this.prManager?._tourGen !== startGen;
276
+
261
277
  if (this._mounted.has(index)) {
262
278
  const existing = this._mounted.get(index);
263
279
  if (existing && existing.isConnected) return existing;
@@ -324,7 +340,21 @@ class TourRenderer {
324
340
  if (wrapper.classList.contains('collapsed')) {
325
341
  if (this.prManager && typeof this.prManager.toggleFileCollapse === 'function') {
326
342
  try {
327
- this.prManager.toggleFileCollapse(filePath);
343
+ // Await: toggleFileCollapse renders the lazy body and removes the
344
+ // `collapsed` class. Awaiting here means the row is built into a
345
+ // visible body and the caller's scrollToStop lands correctly.
346
+ await this.prManager.toggleFileCollapse(filePath);
347
+ // The await above is a suspension window. If the tour exited or
348
+ // restarted while it was in flight, `unmountAll` already ran
349
+ // against a snapshot that does NOT include this stop (the
350
+ // `_autoExpanded.add` / `_mounted.set` below hadn't run yet).
351
+ // Recording state now would orphan it forever — the file would
352
+ // never be re-collapsed and the annotation row never removed.
353
+ // Bail without mutating renderer state, matching prepareStop's
354
+ // stale-bail behavior. (The file is left expanded; that minor
355
+ // cosmetic residue is preferable to a corrupted `_autoExpanded`
356
+ // set that mis-collapses an unrelated file on the next exit.)
357
+ if (isStale()) return null;
328
358
  // Record so unmountAll() can re-collapse on tour exit.
329
359
  this._autoExpanded.add(filePath);
330
360
  } catch (err) {
@@ -458,10 +488,20 @@ class TourRenderer {
458
488
  scrollToStop(index) {
459
489
  const row = this._mounted.get(index);
460
490
  if (!row || !row.isConnected) return;
461
- row.scrollIntoView({
491
+ const options = {
462
492
  behavior: this._reduceMotion ? 'auto' : 'smooth',
463
493
  block: 'center'
464
- });
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
+ }
465
505
  }
466
506
 
467
507
  /**