@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.
- package/README.md +4 -0
- package/package.json +20 -15
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +0 -0
- package/public/css/analysis-config.css +1807 -0
- package/public/css/pr.css +0 -1737
- package/public/index.html +11 -0
- package/public/js/components/AIPanel.js +39 -23
- package/public/js/components/AdvancedConfigTab.js +56 -4
- package/public/js/components/AnalysisConfigModal.js +41 -25
- package/public/js/components/ReviewModal.js +135 -13
- package/public/js/components/VoiceCentricConfigTab.js +36 -0
- package/public/js/index.js +175 -16
- package/public/js/local.js +58 -8
- package/public/js/modules/suggestion-manager.js +25 -1
- package/public/js/modules/tour-renderer.js +33 -3
- package/public/js/pr.js +653 -157
- package/public/js/repo-links.js +328 -0
- package/public/js/utils/provider-model.js +88 -0
- package/public/js/utils/storage-keys.js +50 -0
- package/public/local.html +7 -0
- package/public/pr.html +7 -0
- package/public/repo-settings.html +1 -0
- package/public/setup.html +2 -0
- package/src/ai/analyzer.js +125 -18
- package/src/config.js +664 -10
- package/src/external/github-adapter.js +114 -25
- package/src/git/base-branch.js +11 -4
- package/src/github/client.js +482 -588
- package/src/github/errors.js +55 -0
- package/src/github/impl/graphql/pending-review-comments.js +230 -0
- package/src/github/impl/graphql/pending-review.js +153 -0
- package/src/github/impl/graphql/review-lifecycle.js +161 -0
- package/src/github/impl/graphql/stack-walker.js +210 -0
- package/src/github/impl/host/pending-review-comments.js +338 -0
- package/src/github/impl/rest/pending-review.js +251 -0
- package/src/github/impl/rest/review-lifecycle.js +226 -0
- package/src/github/impl/rest/stack-walker.js +309 -0
- package/src/github/operations/pending-review-comments.js +79 -0
- package/src/github/operations/pending-review.js +89 -0
- package/src/github/operations/review-lifecycle.js +126 -0
- package/src/github/operations/stack-walker.js +87 -0
- package/src/github/parser.js +230 -4
- package/src/github/stack-walker.js +14 -189
- package/src/links/repo-links.js +230 -0
- package/src/local-review.js +13 -4
- package/src/main.js +133 -30
- package/src/routes/analyses.js +30 -7
- package/src/routes/bulk-analysis-configs.js +295 -0
- package/src/routes/config.js +102 -2
- package/src/routes/external-comments.js +20 -10
- package/src/routes/github-collections.js +3 -1
- package/src/routes/local.js +101 -11
- package/src/routes/mcp.js +47 -4
- package/src/routes/pr.js +298 -68
- package/src/routes/setup.js +8 -3
- package/src/routes/stack-analysis.js +33 -9
- package/src/routes/worktrees.js +3 -2
- package/src/server.js +2 -0
- package/src/setup/pr-setup.js +37 -11
- package/src/setup/stack-setup.js +13 -3
- package/src/single-port.js +6 -3
package/public/js/index.js
CHANGED
|
@@ -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
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
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
|
|
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
|
|
1929
|
-
|
|
1930
|
-
|
|
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 ───────────────────────────────────────────────────────
|
package/public/js/local.js
CHANGED
|
@@ -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(
|
|
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
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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) {
|