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

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 (63) 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 +0 -1737
  8. package/public/index.html +11 -0
  9. package/public/js/components/AIPanel.js +39 -23
  10. package/public/js/components/AdvancedConfigTab.js +56 -4
  11. package/public/js/components/AnalysisConfigModal.js +41 -25
  12. package/public/js/components/ReviewModal.js +135 -13
  13. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  14. package/public/js/index.js +175 -16
  15. package/public/js/local.js +58 -8
  16. package/public/js/modules/suggestion-manager.js +25 -1
  17. package/public/js/modules/tour-renderer.js +33 -3
  18. package/public/js/pr.js +653 -157
  19. package/public/js/repo-links.js +328 -0
  20. package/public/js/utils/provider-model.js +88 -0
  21. package/public/js/utils/storage-keys.js +50 -0
  22. package/public/local.html +7 -0
  23. package/public/pr.html +7 -0
  24. package/public/repo-settings.html +1 -0
  25. package/public/setup.html +2 -0
  26. package/src/ai/analyzer.js +125 -18
  27. package/src/config.js +664 -10
  28. package/src/external/github-adapter.js +114 -25
  29. package/src/git/base-branch.js +11 -4
  30. package/src/github/client.js +482 -588
  31. package/src/github/errors.js +55 -0
  32. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  33. package/src/github/impl/graphql/pending-review.js +153 -0
  34. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  35. package/src/github/impl/graphql/stack-walker.js +210 -0
  36. package/src/github/impl/host/pending-review-comments.js +338 -0
  37. package/src/github/impl/rest/pending-review.js +251 -0
  38. package/src/github/impl/rest/review-lifecycle.js +226 -0
  39. package/src/github/impl/rest/stack-walker.js +309 -0
  40. package/src/github/operations/pending-review-comments.js +79 -0
  41. package/src/github/operations/pending-review.js +89 -0
  42. package/src/github/operations/review-lifecycle.js +126 -0
  43. package/src/github/operations/stack-walker.js +87 -0
  44. package/src/github/parser.js +230 -4
  45. package/src/github/stack-walker.js +14 -189
  46. package/src/links/repo-links.js +230 -0
  47. package/src/local-review.js +13 -4
  48. package/src/main.js +133 -30
  49. package/src/routes/analyses.js +30 -7
  50. package/src/routes/bulk-analysis-configs.js +295 -0
  51. package/src/routes/config.js +102 -2
  52. package/src/routes/external-comments.js +20 -10
  53. package/src/routes/github-collections.js +3 -1
  54. package/src/routes/local.js +101 -11
  55. package/src/routes/mcp.js +47 -4
  56. package/src/routes/pr.js +298 -68
  57. package/src/routes/setup.js +8 -3
  58. package/src/routes/stack-analysis.js +33 -9
  59. package/src/routes/worktrees.js +3 -2
  60. package/src/server.js +2 -0
  61. package/src/setup/pr-setup.js +37 -11
  62. package/src/setup/stack-setup.js +13 -3
  63. package/src/single-port.js +6 -3
@@ -90,6 +90,8 @@
90
90
 
91
91
  /** localStorage key for persisting the active tab */
92
92
  const TAB_STORAGE_KEY = 'pair-review-active-tab';
93
+ const BULK_ANALYSIS_TAB_STORAGE_KEY = 'pair-review-bulk-analysis-tab';
94
+ const BULK_ANALYSIS_INSTRUCTIONS_STORAGE_KEY = 'pair-review-bulk-analysis-instructions';
93
95
 
94
96
  /**
95
97
  * Format a relative time string from a date
@@ -133,6 +135,10 @@
133
135
  return div.innerHTML;
134
136
  }
135
137
 
138
+ // encodeBase64Utf8 / getRepoStorageKey live in public/js/utils/storage-keys.js
139
+ // (window.encodeBase64Utf8 / window.getRepoStorageKey), shared with pr.js so the
140
+ // per-repo keys this page writes stay byte-identical to those the PR page reads.
141
+
136
142
  const LOCAL_REVIEW_PATH_URL_ERROR = 'Local reviews require a filesystem path, not a URL. Pass GitHub or Graphite URLs as PR review inputs instead.';
137
143
 
138
144
  function isUrlLikeLocalReviewPath(value) {
@@ -1377,6 +1383,9 @@
1377
1383
  window.__pairReview.chatProvider = config.chat_provider || 'pi';
1378
1384
  const chatProviders = config.chat_providers || [];
1379
1385
  window.__pairReview.chatProviders = chatProviders;
1386
+ window.__pairReview.defaultProvider = config.default_provider || 'claude';
1387
+ window.__pairReview.defaultModel = config.default_model || 'opus';
1388
+ window.__pairReview.hasGithubToken = Boolean(config.has_github_token);
1380
1389
  window.__pairReview.enableGraphite = config.enable_graphite === true;
1381
1390
  window.__pairReview.chatSpinner = config.chat_spinner || 'dots';
1382
1391
  window.__pairReview.chatEnterToSend = config.chat_enter_to_send !== false;
@@ -1404,6 +1413,7 @@
1404
1413
 
1405
1414
  /** Currently active SelectionMode instance (only one tab at a time) */
1406
1415
  var activeSelection = null;
1416
+ var bulkAnalysisConfigModal = null;
1407
1417
 
1408
1418
  /**
1409
1419
  * SelectionMode manages checkbox-based selection for a single tab's table.
@@ -1877,6 +1887,40 @@
1877
1887
  });
1878
1888
  }
1879
1889
 
1890
+ /**
1891
+ * Read selected collection rows as PR descriptors.
1892
+ * @param {Set} selectedIds - PR URLs (data-pr-url values)
1893
+ * @param {string} tbodyId - tbody element ID
1894
+ * @returns {Array<{owner: string, repo: string, number: string, prUrl: string}>}
1895
+ */
1896
+ function getSelectedCollectionRows(selectedIds, tbodyId) {
1897
+ var tbody = document.getElementById(tbodyId);
1898
+ if (!tbody) return [];
1899
+
1900
+ var rows = [];
1901
+ var trs = tbody.querySelectorAll('tr[data-pr-url]');
1902
+ for (var i = 0; i < trs.length; i++) {
1903
+ var tr = trs[i];
1904
+ var prUrl = tr.dataset.prUrl;
1905
+ if (!selectedIds.has(prUrl)) continue;
1906
+ if (tr.dataset.owner && tr.dataset.repo && tr.dataset.number) {
1907
+ rows.push({
1908
+ owner: tr.dataset.owner,
1909
+ repo: tr.dataset.repo,
1910
+ number: tr.dataset.number,
1911
+ prUrl: prUrl
1912
+ });
1913
+ }
1914
+ }
1915
+ return rows;
1916
+ }
1917
+
1918
+ function buildReviewUrlsFromRows(rows, query) {
1919
+ return rows.map(function (row) {
1920
+ return '/pr/' + encodeURIComponent(row.owner) + '/' + encodeURIComponent(row.repo) + '/' + row.number + (query || '');
1921
+ });
1922
+ }
1923
+
1880
1924
  /**
1881
1925
  * Build pair-review URLs from selected collection rows.
1882
1926
  * @param {Set} selectedIds - PR URLs (data-pr-url values)
@@ -1885,20 +1929,116 @@
1885
1929
  * @returns {string[]} array of pair-review URLs
1886
1930
  */
1887
1931
  function buildReviewUrls(selectedIds, tbodyId, query) {
1888
- var tbody = document.getElementById(tbodyId);
1889
- if (!tbody) return [];
1890
- var urls = [];
1891
- selectedIds.forEach(function (prUrl) {
1892
- var row = tbody.querySelector('tr[data-pr-url="' + CSS.escape(prUrl) + '"]');
1893
- if (!row) return;
1894
- var owner = row.dataset.owner;
1895
- var repo = row.dataset.repo;
1896
- var number = row.dataset.number;
1897
- if (owner && repo && number) {
1898
- urls.push('/pr/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo) + '/' + number + (query || ''));
1932
+ return buildReviewUrlsFromRows(getSelectedCollectionRows(selectedIds, tbodyId), query);
1933
+ }
1934
+
1935
+ function getCommonRepository(rows) {
1936
+ if (rows.length === 0) return null;
1937
+ var first = rows[0];
1938
+ var firstKey = (first.owner + '/' + first.repo).toLowerCase();
1939
+ for (var i = 1; i < rows.length; i++) {
1940
+ if ((rows[i].owner + '/' + rows[i].repo).toLowerCase() !== firstKey) {
1941
+ return null;
1899
1942
  }
1943
+ }
1944
+ return { owner: first.owner, repo: first.repo };
1945
+ }
1946
+
1947
+ async function fetchBulkRepoSettings(commonRepo) {
1948
+ if (!commonRepo) return null;
1949
+ try {
1950
+ var response = await fetch('/api/repos/' + encodeURIComponent(commonRepo.owner) + '/' + encodeURIComponent(commonRepo.repo) + '/settings');
1951
+ if (!response.ok) return null;
1952
+ return await response.json();
1953
+ } catch (error) {
1954
+ console.warn('Failed to fetch repo settings for bulk analysis:', error);
1955
+ return null;
1956
+ }
1957
+ }
1958
+
1959
+ function getBulkAnalysisStorageKeys(commonRepo) {
1960
+ if (!commonRepo) {
1961
+ return {
1962
+ tab: BULK_ANALYSIS_TAB_STORAGE_KEY,
1963
+ instructions: BULK_ANALYSIS_INSTRUCTIONS_STORAGE_KEY
1964
+ };
1965
+ }
1966
+ return {
1967
+ tab: window.getRepoStorageKey('pair-review-tab', commonRepo.owner, commonRepo.repo),
1968
+ instructions: window.getRepoStorageKey('pair-review-instructions', commonRepo.owner, commonRepo.repo)
1969
+ };
1970
+ }
1971
+
1972
+ // Cache the /api/providers metadata so the bulk modal can resolve a coherent
1973
+ // provider/model pair before showing (mirrors PRManager._getProvidersInfo).
1974
+ function getBulkProvidersInfo() {
1975
+ window.__pairReview = window.__pairReview || {};
1976
+ if (!window.__pairReview._providersInfoPromise) {
1977
+ window.__pairReview._providersInfoPromise = fetch('/api/providers')
1978
+ .then(function (r) { return r.ok ? r.json() : {}; })
1979
+ .then(function (d) { return Array.isArray(d.providers) ? d.providers : []; })
1980
+ .catch(function () { return []; });
1981
+ }
1982
+ return window.__pairReview._providersInfoPromise;
1983
+ }
1984
+
1985
+ async function showBulkAnalysisConfig(rows) {
1986
+ if (!window.AnalysisConfigModal) return null;
1987
+
1988
+ if (!bulkAnalysisConfigModal) {
1989
+ bulkAnalysisConfigModal = new window.AnalysisConfigModal();
1990
+ }
1991
+
1992
+ var commonRepo = getCommonRepository(rows);
1993
+ var storageKeys = getBulkAnalysisStorageKeys(commonRepo);
1994
+ var repoSettings = await fetchBulkRepoSettings(commonRepo);
1995
+ var rememberedTab = localStorage.getItem(storageKeys.tab);
1996
+ var lastInstructions = localStorage.getItem(storageKeys.instructions) || '';
1997
+
1998
+ bulkAnalysisConfigModal.onTabChange = function (tabId) {
1999
+ localStorage.setItem(storageKeys.tab, tabId);
2000
+ };
2001
+
2002
+ var providersInfo = await getBulkProvidersInfo();
2003
+ var resolvedPair = window.resolveProviderModelPair([
2004
+ { provider: repoSettings?.default_provider, model: repoSettings?.default_model },
2005
+ { provider: window.__pairReview?.defaultProvider, model: window.__pairReview?.defaultModel }
2006
+ ], providersInfo);
2007
+
2008
+ var config = await bulkAnalysisConfigModal.show({
2009
+ currentModel: resolvedPair.model,
2010
+ currentProvider: resolvedPair.provider,
2011
+ defaultTab: rememberedTab || repoSettings?.default_tab || 'single',
2012
+ repoInstructions: commonRepo ? (repoSettings?.default_instructions || '') : '',
2013
+ lastInstructions: lastInstructions,
2014
+ defaultCouncilId: commonRepo ? (repoSettings?.default_council_id || null) : null,
2015
+ hasPr: true,
2016
+ hasGithubToken: window.__pairReview?.hasGithubToken !== false
2017
+ });
2018
+
2019
+ if (!config) return null;
2020
+
2021
+ var submittedInstructions = config.customInstructions || '';
2022
+ if (submittedInstructions) {
2023
+ localStorage.setItem(storageKeys.instructions, submittedInstructions);
2024
+ } else {
2025
+ localStorage.removeItem(storageKeys.instructions);
2026
+ }
2027
+
2028
+ return config;
2029
+ }
2030
+
2031
+ async function storeBulkAnalysisConfig(config) {
2032
+ var response = await fetch('/api/bulk-analysis-configs', {
2033
+ method: 'POST',
2034
+ headers: { 'Content-Type': 'application/json' },
2035
+ body: JSON.stringify({ analysisConfig: config })
1900
2036
  });
1901
- return urls;
2037
+ var data = await response.json().catch(function () { return {}; });
2038
+ if (!response.ok || !data.id) {
2039
+ throw new Error(data.error || 'Failed to save analysis settings');
2040
+ }
2041
+ return data.id;
1902
2042
  }
1903
2043
 
1904
2044
  /**
@@ -1924,10 +2064,29 @@
1924
2064
  bulkOpenUrls(urls);
1925
2065
  }
1926
2066
 
1927
- function handleBulkAnalyze(selectedIds, selectionInstance) {
1928
- var urls = buildReviewUrls(selectedIds, selectionInstance.config.tbodyId, '?analyze=true');
1929
- selectionInstance.exit();
1930
- bulkOpenUrls(urls);
2067
+ async function handleBulkAnalyze(selectedIds, selectionInstance) {
2068
+ var rows = getSelectedCollectionRows(selectedIds, selectionInstance.config.tbodyId);
2069
+ if (rows.length === 0) return;
2070
+
2071
+ if (!window.AnalysisConfigModal) {
2072
+ selectionInstance.exit();
2073
+ bulkOpenUrls(buildReviewUrlsFromRows(rows, '?analyze=true'));
2074
+ return;
2075
+ }
2076
+
2077
+ try {
2078
+ var config = await showBulkAnalysisConfig(rows);
2079
+ if (!config) return;
2080
+
2081
+ var configId = await storeBulkAnalysisConfig(config);
2082
+ var query = '?analyze=true&analysisConfigId=' + encodeURIComponent(configId);
2083
+ var urls = buildReviewUrlsFromRows(rows, query);
2084
+ selectionInstance.exit();
2085
+ bulkOpenUrls(urls);
2086
+ } catch (error) {
2087
+ console.error('Bulk analyze error:', error);
2088
+ if (window.toast) window.toast.error('Failed to start bulk analysis: ' + error.message);
2089
+ }
1931
2090
  }
1932
2091
 
1933
2092
  // ─── Event Delegation ───────────────────────────────────────────────────────
@@ -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) {