@in-the-loop-labs/pair-review 3.5.2 → 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 (93) hide show
  1. package/README.md +4 -0
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/public/css/analysis-config.css +1807 -0
  6. package/public/css/pr.css +1029 -2169
  7. package/public/index.html +11 -0
  8. package/public/js/components/AIPanel.js +39 -23
  9. package/public/js/components/AdvancedConfigTab.js +56 -4
  10. package/public/js/components/AnalysisConfigModal.js +41 -25
  11. package/public/js/components/ChatPanel.js +163 -3
  12. package/public/js/components/KeyboardShortcuts.js +10 -26
  13. package/public/js/components/ReviewModal.js +135 -13
  14. package/public/js/components/TourBar.js +248 -0
  15. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  16. package/public/js/index.js +175 -16
  17. package/public/js/local.js +64 -8
  18. package/public/js/modules/cancel-background-job.js +183 -0
  19. package/public/js/modules/hunk-summary-renderer.js +116 -0
  20. package/public/js/modules/storage-cleanup.js +16 -0
  21. package/public/js/modules/suggestion-manager.js +25 -1
  22. package/public/js/modules/tour-renderer.js +755 -0
  23. package/public/js/pr.js +1826 -56
  24. package/public/js/repo-links.js +328 -0
  25. package/public/js/utils/modal-detection.js +77 -0
  26. package/public/js/utils/provider-model.js +88 -0
  27. package/public/js/utils/storage-keys.js +50 -0
  28. package/public/local.html +24 -0
  29. package/public/pr.html +24 -0
  30. package/public/repo-settings.html +1 -0
  31. package/public/setup.html +2 -0
  32. package/src/ai/abort-signal-wiring.js +130 -0
  33. package/src/ai/analyzer.js +125 -18
  34. package/src/ai/background-queue.js +290 -0
  35. package/src/ai/claude-cli.js +1 -1
  36. package/src/ai/claude-provider.js +50 -7
  37. package/src/ai/codex-provider.js +28 -5
  38. package/src/ai/copilot-provider.js +22 -3
  39. package/src/ai/cursor-agent-provider.js +22 -6
  40. package/src/ai/executable-provider.js +4 -19
  41. package/src/ai/gemini-provider.js +22 -5
  42. package/src/ai/hunk-hashing.js +161 -0
  43. package/src/ai/index.js +2 -0
  44. package/src/ai/opencode-provider.js +21 -5
  45. package/src/ai/pi-provider.js +21 -5
  46. package/src/ai/prompts/hunk-summary.js +199 -0
  47. package/src/ai/prompts/tour.js +232 -0
  48. package/src/ai/provider.js +21 -1
  49. package/src/ai/summary-generator.js +469 -0
  50. package/src/ai/tour-generator.js +568 -0
  51. package/src/config.js +778 -10
  52. package/src/database.js +282 -1
  53. package/src/external/github-adapter.js +114 -25
  54. package/src/git/base-branch.js +11 -4
  55. package/src/github/client.js +482 -588
  56. package/src/github/errors.js +55 -0
  57. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  58. package/src/github/impl/graphql/pending-review.js +153 -0
  59. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  60. package/src/github/impl/graphql/stack-walker.js +210 -0
  61. package/src/github/impl/host/pending-review-comments.js +338 -0
  62. package/src/github/impl/rest/pending-review.js +251 -0
  63. package/src/github/impl/rest/review-lifecycle.js +226 -0
  64. package/src/github/impl/rest/stack-walker.js +309 -0
  65. package/src/github/operations/pending-review-comments.js +79 -0
  66. package/src/github/operations/pending-review.js +89 -0
  67. package/src/github/operations/review-lifecycle.js +126 -0
  68. package/src/github/operations/stack-walker.js +87 -0
  69. package/src/github/parser.js +230 -4
  70. package/src/github/stack-walker.js +14 -189
  71. package/src/links/repo-links.js +230 -0
  72. package/src/local-review.js +201 -172
  73. package/src/main.js +133 -30
  74. package/src/routes/analyses.js +30 -7
  75. package/src/routes/bulk-analysis-configs.js +295 -0
  76. package/src/routes/config.js +118 -3
  77. package/src/routes/context-files.js +2 -29
  78. package/src/routes/external-comments.js +20 -10
  79. package/src/routes/github-collections.js +3 -1
  80. package/src/routes/local.js +410 -13
  81. package/src/routes/mcp.js +47 -4
  82. package/src/routes/middleware/validate-review-id.js +53 -0
  83. package/src/routes/pr.js +556 -71
  84. package/src/routes/reviews.js +145 -29
  85. package/src/routes/setup.js +8 -3
  86. package/src/routes/stack-analysis.js +33 -9
  87. package/src/routes/worktrees.js +3 -2
  88. package/src/server.js +2 -0
  89. package/src/setup/pr-setup.js +37 -11
  90. package/src/setup/stack-setup.js +13 -3
  91. package/src/single-port.js +6 -3
  92. package/src/utils/diff-hunks.js +65 -0
  93. package/src/utils/json-extractor.js +5 -2
@@ -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
 
@@ -1404,6 +1454,9 @@ class LocalManager {
1404
1454
  const diffContent = data.diff || '';
1405
1455
  const stats = data.stats || {};
1406
1456
  const generatedFiles = new Set(data.generated_files || []);
1457
+ // Server-computed per-file hunk hashes (computed from the canonical
1458
+ // diff so they remain stable across whitespace-filtered renders).
1459
+ const hunkHashesByFile = data.hunk_hashes_by_file || {};
1407
1460
 
1408
1461
  if (!diffContent) {
1409
1462
  const diffContainer = document.getElementById('diff-container');
@@ -1468,6 +1521,9 @@ class LocalManager {
1468
1521
  insertions: additions,
1469
1522
  deletions: deletions,
1470
1523
  generated: isGenerated,
1524
+ // Pass through the server-computed canonical hunk hashes; renderPatch
1525
+ // requires these to anchor persisted summaries (no client-side fallback).
1526
+ hunk_hashes: hunkHashesByFile[fileName] || null,
1471
1527
  status: patch.includes('new file mode') ? 'added' :
1472
1528
  patch.includes('deleted file mode') ? 'removed' : 'modified'
1473
1529
  });
@@ -0,0 +1,183 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Shared frontend helpers for cancelling in-flight background jobs
4
+ * (tour and summaries). Both flows share the same dialog -> POST ->
5
+ * reset-button-state shape, so the per-artifact wrappers stay thin.
6
+ *
7
+ * Backend contract (see src/routes/reviews.js handleJobCancel):
8
+ * POST /api/reviews/:reviewId/jobs/:jobKey/cancel
9
+ * 200 -> { cancelled: true, count: N }
10
+ * 404 -> { cancelled: false } (nothing in flight)
11
+ * 400 -> invalid jobKey
12
+ *
13
+ * Local-mode parity: the local route at
14
+ * POST /api/local/:reviewId/jobs/:jobKey/cancel
15
+ * is a thin wrapper. Both modes share the `reviews` table, so we can
16
+ * use ONE endpoint here. Local mode is detected via document.body.dataset
17
+ * to keep this module independent of PRManager internals.
18
+ */
19
+
20
+ (function () {
21
+ 'use strict';
22
+
23
+ /**
24
+ * POST the cancel request for a single (reviewId, jobKey) pair.
25
+ * Returns a Promise resolving to the parsed JSON response.
26
+ *
27
+ * @param {number|string} reviewId
28
+ * @param {string} jobKey - bare prefix (`tour` | `summaries`) or full
29
+ * suffix (`summaries:<digest>`); the backend treats bare prefixes as
30
+ * "cancel all matching".
31
+ * @returns {Promise<{ok: boolean, status: number, body: any}>}
32
+ */
33
+ async function postCancel(reviewId, jobKey) {
34
+ if (!reviewId) {
35
+ return { ok: false, status: 0, body: { error: 'missing reviewId' } };
36
+ }
37
+ // Both modes share the /api/reviews/... endpoint thanks to the
38
+ // shared reviews table. No need to branch on local-vs-PR mode here.
39
+ const url = `/api/reviews/${reviewId}/jobs/${encodeURIComponent(jobKey)}/cancel`;
40
+ try {
41
+ const resp = await fetch(url, { method: 'POST' });
42
+ let body = null;
43
+ try {
44
+ body = await resp.json();
45
+ } catch {
46
+ body = null;
47
+ }
48
+ return { ok: resp.ok, status: resp.status, body };
49
+ } catch (err) {
50
+ // Network or unexpected fetch error — log and surface as failure.
51
+ // We deliberately do not toast here so callers can decide the UX.
52
+ // eslint-disable-next-line no-console
53
+ console.warn(`[cancel-background-job] POST ${url} failed:`, err);
54
+ return { ok: false, status: 0, body: { error: err && err.message } };
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Open the shared ConfirmDialog with cancel-job copy, then on confirm
60
+ * POST the cancel and invoke the caller's `onCancelled` callback so it
61
+ * can sync UI state (button class toggles, in-memory `_generating`
62
+ * flags, etc.).
63
+ *
64
+ * The caller's `onCancelled` runs only when the backend confirms the
65
+ * cancel reached a terminal state: 200 (cancelled) or 404 (already gone).
66
+ * For any other HTTP status (400 validation, 500 server error, etc.) or
67
+ * a network failure, we toast an error and leave the active state intact
68
+ * so the pulse stays visible and the user can retry the cancel click.
69
+ *
70
+ * @param {Object} opts
71
+ * @param {number|string} opts.reviewId
72
+ * @param {string} opts.jobKey - `tour` or `summaries`
73
+ * @param {string} opts.title - Dialog title (e.g. "Tour is still generating")
74
+ * @param {string} opts.message - Dialog body
75
+ * @param {string} opts.confirmText - Confirm button label (e.g. "Cancel Tour")
76
+ * @param {Function} opts.onCancelled - Called after a confirmed cancel.
77
+ * @returns {Promise<void>}
78
+ */
79
+ async function showCancelJobDialog(opts) {
80
+ const { reviewId, jobKey, title, message, confirmText, onCancelled } = opts || {};
81
+ const dialog = typeof window !== 'undefined' ? window.confirmDialog : null;
82
+ if (!dialog || typeof dialog.show !== 'function') {
83
+ // ConfirmDialog hasn't initialized yet — bail silently. The button
84
+ // still works on its second click (after DOMContentLoaded fires).
85
+ return;
86
+ }
87
+ const result = await dialog.show({
88
+ title: title || 'Generation in progress',
89
+ message: message || 'Cancel this job?',
90
+ confirmText: confirmText || 'Cancel',
91
+ confirmClass: 'btn-danger',
92
+ cancelText: 'OK',
93
+ });
94
+ if (result !== 'confirm') return;
95
+
96
+ const { status, body } = await postCancel(reviewId, jobKey);
97
+ // ONLY 200 (cancelled) and 404 (already gone) are UI-clearing outcomes.
98
+ // Anything else (400, 500, 503, network failure with status=0) means
99
+ // the job may still be running — keep the pulse, toast an error, and
100
+ // let the user re-click to retry.
101
+ if (status !== 200 && status !== 404) {
102
+ // eslint-disable-next-line no-console
103
+ console.error(
104
+ `[cancel-background-job] cancel failed for ${jobKey} (status ${status}):`,
105
+ body
106
+ );
107
+ if (typeof window !== 'undefined' && window.toast?.error) {
108
+ const detail = body && body.error ? `: ${body.error}` : '';
109
+ const msg = status === 0
110
+ ? 'Failed to cancel — check connection'
111
+ : `Failed to cancel (HTTP ${status})${detail}`;
112
+ window.toast.error(msg);
113
+ }
114
+ return;
115
+ }
116
+ // 200 or 404 — terminal. Reset UI state.
117
+ if (typeof onCancelled === 'function') {
118
+ try {
119
+ onCancelled({ cancelled: status === 200 && body && body.cancelled, status });
120
+ } catch (err) {
121
+ // eslint-disable-next-line no-console
122
+ console.warn('[cancel-background-job] onCancelled handler threw:', err);
123
+ }
124
+ }
125
+ }
126
+
127
+ // ---- Per-artifact wrappers (thin convenience layer) --------------------
128
+
129
+ /**
130
+ * Open the "Cancel Tour" confirm dialog.
131
+ * @param {Object} opts
132
+ * @param {number|string} opts.reviewId
133
+ * @param {Function} opts.onCancelled
134
+ * @returns {Promise<void>}
135
+ */
136
+ function showCancelTourDialog(opts) {
137
+ return showCancelJobDialog({
138
+ reviewId: opts.reviewId,
139
+ jobKey: 'tour',
140
+ title: 'Tour is still being generated',
141
+ message:
142
+ 'A guided tour is still being generated for this review. ' +
143
+ 'Cancelling will stop the upstream AI call.',
144
+ confirmText: 'Cancel Tour',
145
+ onCancelled: opts.onCancelled,
146
+ });
147
+ }
148
+
149
+ /**
150
+ * Open the "Cancel Summaries" confirm dialog.
151
+ * @param {Object} opts
152
+ * @param {number|string} opts.reviewId
153
+ * @param {Function} opts.onCancelled
154
+ * @returns {Promise<void>}
155
+ */
156
+ function showCancelSummariesDialog(opts) {
157
+ return showCancelJobDialog({
158
+ reviewId: opts.reviewId,
159
+ jobKey: 'summaries',
160
+ title: 'Summaries are still being generated',
161
+ message:
162
+ 'Hunk summaries are still being generated for this review. ' +
163
+ 'Cancelling will stop the upstream AI call. Summaries already ' +
164
+ 'persisted will remain.',
165
+ confirmText: 'Cancel Summaries',
166
+ onCancelled: opts.onCancelled,
167
+ });
168
+ }
169
+
170
+ const api = {
171
+ postCancel,
172
+ showCancelJobDialog,
173
+ showCancelTourDialog,
174
+ showCancelSummariesDialog,
175
+ };
176
+
177
+ if (typeof window !== 'undefined') {
178
+ window.CancelBackgroundJob = api;
179
+ }
180
+ if (typeof module !== 'undefined' && module.exports) {
181
+ module.exports = api;
182
+ }
183
+ })();
@@ -0,0 +1,116 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * HunkSummaryRenderer - Renders inline natural-language hunk summaries.
4
+ *
5
+ * A summary is a short natural-language description of a non-trivial hunk,
6
+ * generated by a background AI job. Renderer responsibilities:
7
+ * - Insert a styled annotation row immediately above the hunk's first code row
8
+ * - Keep the mounted-row map in sync so re-renders update text in place
9
+ * - Remove an annotation when its underlying hunk goes away (file re-render)
10
+ *
11
+ * Visibility is controlled outside the renderer via CSS classes:
12
+ * - body.summaries-hidden — review-level toggle
13
+ * - .d2h-file-wrapper.summaries-hidden-file — per-file toggle
14
+ *
15
+ * Trivial / model-skipped / model-malformed rows are filtered out by the
16
+ * caller; this renderer only handles rows with non-empty `summary_text`.
17
+ */
18
+
19
+ class HunkSummaryRenderer {
20
+ /**
21
+ * @param {Object} prManager - PRManager instance (kept for future callbacks).
22
+ */
23
+ constructor(prManager) {
24
+ this.prManager = prManager;
25
+ // Map<contentHash, HTMLTableRowElement> — annotation rows currently mounted
26
+ this._mounted = new Map();
27
+ }
28
+
29
+ /**
30
+ * Render a summary inline, immediately above its hunk anchor row.
31
+ * Idempotent: if a summary for the same hash is already mounted, replace
32
+ * its text rather than stack a duplicate.
33
+ *
34
+ * @param {HTMLTableRowElement} anchorRow - The first code-line `<tr>` of
35
+ * the target hunk (carries `data-hunk-start="${contentHash}"`).
36
+ * @param {Object} summary - Summary object: { content_hash, summary_text }.
37
+ * @param {Object} [_opts] - Reserved for future per-render options.
38
+ * @returns {HTMLTableRowElement|null}
39
+ */
40
+ renderInline(anchorRow, summary, _opts) {
41
+ if (!anchorRow || !summary || !summary.summary_text) return null;
42
+ const contentHash = summary.content_hash;
43
+ if (!contentHash) return null;
44
+
45
+ const existing = this._mounted.get(contentHash);
46
+ if (existing && existing.isConnected) {
47
+ const textEl = existing.querySelector('.hunk-summary-text');
48
+ if (textEl) textEl.textContent = summary.summary_text;
49
+ return existing;
50
+ }
51
+
52
+ const row = document.createElement('tr');
53
+ row.className = 'hunk-summary-row';
54
+ row.dataset.contentHash = contentHash;
55
+
56
+ const cell = document.createElement('td');
57
+ cell.colSpan = 2;
58
+ cell.className = 'hunk-summary-cell';
59
+
60
+ const annotation = document.createElement('div');
61
+ annotation.className = 'hunk-summary-annotation';
62
+
63
+ const icon = document.createElement('span');
64
+ icon.className = 'hunk-summary-icon';
65
+ icon.setAttribute('aria-hidden', 'true');
66
+ icon.innerHTML = '<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">' +
67
+ '<path d="M0 3.75C0 2.784.784 2 1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25Zm1.75-.25a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25ZM3.5 6.25a.75.75 0 0 1 .75-.75h7a.75.75 0 0 1 0 1.5h-7a.75.75 0 0 1-.75-.75Zm.75 2.25h4a.75.75 0 0 1 0 1.5h-4a.75.75 0 0 1 0-1.5Z"/>' +
68
+ '</svg>';
69
+
70
+ const text = document.createElement('span');
71
+ text.className = 'hunk-summary-text';
72
+ text.textContent = summary.summary_text;
73
+
74
+ annotation.appendChild(icon);
75
+ annotation.appendChild(text);
76
+ cell.appendChild(annotation);
77
+ row.appendChild(cell);
78
+
79
+ // Insert IMMEDIATELY ABOVE the hunk's first code line so a reader
80
+ // scrolling down sees the description before the change itself.
81
+ anchorRow.parentNode.insertBefore(row, anchorRow);
82
+ this._mounted.set(contentHash, row);
83
+ return row;
84
+ }
85
+
86
+ /**
87
+ * Remove a mounted annotation by its content hash.
88
+ * @param {string} contentHash
89
+ * @returns {boolean} true if a row was found and removed
90
+ */
91
+ removeByHash(contentHash) {
92
+ const row = this._mounted.get(contentHash);
93
+ if (!row) return false;
94
+ if (row.isConnected) row.remove();
95
+ this._mounted.delete(contentHash);
96
+ return true;
97
+ }
98
+
99
+ /**
100
+ * Clear all mounted annotations (e.g. before re-rendering the diff).
101
+ */
102
+ reset() {
103
+ for (const row of this._mounted.values()) {
104
+ if (row.isConnected) row.remove();
105
+ }
106
+ this._mounted.clear();
107
+ }
108
+ }
109
+
110
+ if (typeof window !== 'undefined') {
111
+ window.HunkSummaryRenderer = HunkSummaryRenderer;
112
+ }
113
+
114
+ if (typeof module !== 'undefined' && module.exports) {
115
+ module.exports = { HunkSummaryRenderer };
116
+ }