@in-the-loop-labs/pair-review 3.5.1 → 3.6.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 (48) hide show
  1. package/package.json +1 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  4. package/public/css/pr.css +603 -6
  5. package/public/index.html +90 -0
  6. package/public/js/components/ChatPanel.js +163 -3
  7. package/public/js/components/KeyboardShortcuts.js +10 -26
  8. package/public/js/components/TourBar.js +248 -0
  9. package/public/js/index.js +298 -25
  10. package/public/js/local.js +6 -0
  11. package/public/js/modules/cancel-background-job.js +183 -0
  12. package/public/js/modules/hunk-summary-renderer.js +116 -0
  13. package/public/js/modules/storage-cleanup.js +16 -0
  14. package/public/js/modules/tour-renderer.js +725 -0
  15. package/public/js/pr.js +1276 -2
  16. package/public/js/utils/modal-detection.js +77 -0
  17. package/public/local.html +17 -0
  18. package/public/pr.html +17 -0
  19. package/src/ai/abort-signal-wiring.js +130 -0
  20. package/src/ai/background-queue.js +290 -0
  21. package/src/ai/claude-cli.js +1 -1
  22. package/src/ai/claude-provider.js +50 -7
  23. package/src/ai/codex-provider.js +28 -5
  24. package/src/ai/copilot-provider.js +22 -3
  25. package/src/ai/cursor-agent-provider.js +22 -6
  26. package/src/ai/executable-provider.js +4 -19
  27. package/src/ai/gemini-provider.js +22 -5
  28. package/src/ai/hunk-hashing.js +161 -0
  29. package/src/ai/index.js +2 -0
  30. package/src/ai/opencode-provider.js +21 -5
  31. package/src/ai/pi-provider.js +21 -5
  32. package/src/ai/prompts/hunk-summary.js +199 -0
  33. package/src/ai/prompts/tour.js +232 -0
  34. package/src/ai/provider.js +21 -1
  35. package/src/ai/summary-generator.js +469 -0
  36. package/src/ai/tour-generator.js +568 -0
  37. package/src/config.js +114 -0
  38. package/src/database.js +282 -1
  39. package/src/local-review.js +189 -169
  40. package/src/routes/config.js +16 -1
  41. package/src/routes/context-files.js +2 -29
  42. package/src/routes/github-collections.js +168 -90
  43. package/src/routes/local.js +311 -4
  44. package/src/routes/middleware/validate-review-id.js +53 -0
  45. package/src/routes/pr.js +259 -4
  46. package/src/routes/reviews.js +145 -29
  47. package/src/utils/diff-hunks.js +65 -0
  48. package/src/utils/json-extractor.js +5 -2
package/public/js/pr.js CHANGED
@@ -138,6 +138,95 @@ class PRManager {
138
138
  this.diffOptionsDropdown = null;
139
139
  // Comment minimizer — manages minimize mode indicators
140
140
  this.commentMinimizer = window.CommentMinimizer ? new window.CommentMinimizer() : null;
141
+ // Hunk summary renderer (Phase 5) — inline natural-language summaries
142
+ this.hunkSummaryRenderer = window.HunkSummaryRenderer ? new window.HunkSummaryRenderer(this) : null;
143
+ // Per-render anchor map: contentHash -> first code-line <tr> of the hunk
144
+ this._summaryAnchorsByHash = new Map();
145
+ // Per-render file map: filePath -> Set<contentHash>
146
+ this._summaryHashesByFile = new Map();
147
+ // Summaries that arrived (via WS or fetch) before their hunk had been hashed
148
+ this._pendingSummariesByHash = new Map();
149
+ // Per-file summary visibility (persisted in localStorage per-review)
150
+ this.summariesHiddenFiles = new Set();
151
+ // Render-generation token — incremented at the top of renderDiff() so any
152
+ // fire-and-forget _kickOffHunkSummaries() from a prior render can detect
153
+ // it's stale and bail (refresh / whitespace toggle / scope change race).
154
+ this._renderGen = 0;
155
+ // Cached /api/config response (lazy-loaded)
156
+ this._appConfigPromise = null;
157
+ // Review-level summary visibility (persisted in localStorage per-review)
158
+ this._summariesHidden = false;
159
+ // Whether the background summary job is currently running for this review.
160
+ // Cleared by the `review:background_job_finished` event for jobType=summaries.
161
+ this._summariesGenerating = false;
162
+ // Whether any hunk summaries exist for this review (loaded from the server
163
+ // or mounted live during generation). Gates the toolbar button's `.active`
164
+ // (blue) state: before any summary exists the button stays colorless so the
165
+ // user can tell nothing has been generated yet. Set true by
166
+ // `_applyHunkSummaries` and the initial existing-summaries fetch.
167
+ this._summariesGenerated = false;
168
+ // Tri-state: true when /api/config reports summaries.enabled, false when
169
+ // disabled, null until /api/config resolves. Per-file toggle buttons are
170
+ // gated on this so users on disabled deployments don't see them flicker
171
+ // in.
172
+ this._summariesEnabled = null;
173
+ // Tri-state mirror of `summaries.auto_generate` in /api/config. When
174
+ // false, the click handler hits the manual-start endpoint instead of
175
+ // expecting a server-initiated kickoff. Null until /api/config resolves.
176
+ this._summariesAutoGenerate = null;
177
+ // ---- Tour state (Phase 8) -----------------------------------------
178
+ // Lazy-instantiated TourBar / TourRenderer; populated on first open.
179
+ this._tourBar = null;
180
+ this._tourRenderer = null;
181
+ // Tri-state mirror of `tours.enabled` in /api/config. Tours are
182
+ // independent of summaries on both the server and the client (see
183
+ // the explanatory comment in setupEventHandlers()).
184
+ this._toursEnabled = null;
185
+ // Tri-state mirror of `tours.auto_generate` in /api/config. When false,
186
+ // the click handler hits the manual-start endpoint instead of expecting
187
+ // a server-initiated kickoff. Null until /api/config resolves.
188
+ this._toursAutoGenerate = null;
189
+ // Cached stops from the most recent /api/reviews/:id/tour fetch, or null
190
+ // when nothing has been loaded for this review yet.
191
+ this._tourStops = null;
192
+ // 0-based index of the current stop while a tour is open; -1 when no
193
+ // tour is mounted.
194
+ this._tourActiveIndex = -1;
195
+ // Whether the background tour-generation job is currently running; drives
196
+ // the pulse on the toolbar button.
197
+ this._tourGenerating = false;
198
+ // When a `review:tour_ready` event fires while a tour is already mounted,
199
+ // we stash the new stops here rather than yank the current tour out from
200
+ // under the user. The pending stops are applied on the next exit or
201
+ // restart. Cleared once consumed.
202
+ this._tourStopsPendingRestart = null;
203
+ // Bound keydown handler; tracked so it can be removed on tour exit.
204
+ this._tourKeydownHandler = null;
205
+ // Re-entry guard for `_promptCancelJob`. The cancel-confirm dialog
206
+ // opens off the same pulsing toolbar button that triggered it, and
207
+ // the button stays clickable while the dialog is up — `ConfirmDialog`
208
+ // is a singleton, so a second click would overwrite the first
209
+ // invocation's callbacks and leave its Promise dangling. Held true
210
+ // for the lifetime of the dialog; cleared in a `finally`.
211
+ this._cancelPromptOpen = false;
212
+ // Re-entrance latch for the async `_advanceTour` probe loop. Holds the
213
+ // `_tourGen` value the in-flight call belongs to (or -1 when no call
214
+ // is in flight). Generation-scoped — not a plain boolean — so a
215
+ // teardown that bumps `_tourGen` auto-invalidates the holder and the
216
+ // next reopen passes the latch check without any teardown path having
217
+ // to remember to reset the flag explicitly.
218
+ this._advanceInFlightGen = -1;
219
+ // Tour-open generation. Bumped on every open and exit so an in-flight
220
+ // async `_advanceTour` can detect that the tour it started navigating
221
+ // has since been torn down (Escape, exit button, toolbar toggle) and
222
+ // bail instead of mutating a dead tour's state.
223
+ this._tourGen = 0;
224
+ // Drain promise stashed by `_exitTour`. Resolves once every async
225
+ // teardown step from the prior tour (fire-and-forget context-file
226
+ // DELETEs + their loadContextFiles reloads) has settled. The next
227
+ // open awaits this before reading wrappers so a stale DELETE can't
228
+ // rip the newly-mounted tour's wrapper out from under it.
229
+ this._tourCleanupPending = null;
141
230
  // Cached staleness check promise — shared between on-load and triggerAIAnalysis
142
231
  this._stalenessPromise = null;
143
232
  // Unique client ID for self-echo suppression on WebSocket review events.
@@ -320,6 +409,67 @@ class PRManager {
320
409
  }
321
410
  }
322
411
 
412
+ // Hunk summary toolbar toggle (Phase 5).
413
+ // Hidden by default in HTML; revealed asynchronously when /api/config
414
+ // reports summaries.enabled. The same config check also controls the
415
+ // per-file `.file-header-summary-toggle` buttons (which are created
416
+ // hidden by createFileHeader and revealed/removed once config resolves).
417
+ const summaryToggle = document.getElementById('summary-toggle-btn');
418
+ if (summaryToggle) {
419
+ summaryToggle.addEventListener('click', () => this._handleSummaryToggleClick());
420
+ }
421
+ this._getAppConfig().then((cfg) => {
422
+ const summariesCfg = (cfg && cfg.summaries) || {};
423
+ this._summariesEnabled = summariesCfg.enabled === true;
424
+ this._summariesAutoGenerate = summariesCfg.auto_generate !== false;
425
+ if (this._summariesEnabled) {
426
+ if (summaryToggle) {
427
+ summaryToggle.style.display = '';
428
+ this._syncSummaryToolbarButton();
429
+ }
430
+ document
431
+ .querySelectorAll('.file-header-summary-toggle.summary-toggle-pending')
432
+ .forEach((btn) => {
433
+ btn.classList.remove('summary-toggle-pending');
434
+ btn.style.display = '';
435
+ });
436
+ } else {
437
+ document
438
+ .querySelectorAll('.file-header-summary-toggle')
439
+ .forEach((btn) => btn.remove());
440
+ }
441
+ });
442
+
443
+ // Tour toolbar toggle (Phase 8). Hidden by default in HTML; revealed
444
+ // asynchronously once /api/config confirms `tours.enabled` is on.
445
+ // Tours are independent of `summaries.enabled` — both server- and
446
+ // client-side gates check only `tours.enabled`.
447
+ const tourToggle = document.getElementById('tour-toggle-btn');
448
+ if (tourToggle) {
449
+ tourToggle.addEventListener('click', () => this._handleTourToggleClick());
450
+ }
451
+ this._getAppConfig().then((cfg) => {
452
+ const toursCfg = (cfg && cfg.tours) || {};
453
+ this._toursEnabled = toursCfg.enabled === true;
454
+ this._toursAutoGenerate = toursCfg.auto_generate !== false;
455
+ if (this._toursEnabled && tourToggle) {
456
+ tourToggle.style.display = '';
457
+ this._syncTourToolbarButton();
458
+ }
459
+ // NOTE: do NOT probe /api/reviews/:id/tour here when no diff has
460
+ // rendered yet — `currentPR.id` is not populated until
461
+ // init()/LocalManager loads the review. The probe is normally
462
+ // deferred to renderDiff() (which fires after currentPR is set).
463
+ //
464
+ // RACE: if renderDiff() has ALREADY run by the time /api/config
465
+ // resolves, its `_toursEnabled === true` check failed (was still
466
+ // null) and the probe was skipped. Catch that case here so the
467
+ // tour toolbar still surfaces a generated tour.
468
+ if (this._toursEnabled && this._renderGen > 0) {
469
+ this._loadAndStashTour({ cancelOnRender: false }).catch(() => {});
470
+ }
471
+ });
472
+
323
473
  // PR description popover
324
474
  this.setupPRDescriptionPopover();
325
475
 
@@ -903,6 +1053,961 @@ class PRManager {
903
1053
  return this._rerenderAllOverlays();
904
1054
  }
905
1055
 
1056
+ /**
1057
+ * Resolve the cached `/api/config` payload, fetching it on first use.
1058
+ * @returns {Promise<Object>}
1059
+ */
1060
+ _getAppConfig() {
1061
+ if (!this._appConfigPromise) {
1062
+ this._appConfigPromise = fetch('/api/config')
1063
+ .then((r) => (r.ok ? r.json() : {}))
1064
+ .catch(() => ({}));
1065
+ }
1066
+ return this._appConfigPromise;
1067
+ }
1068
+
1069
+ /**
1070
+ * Attach `data-hunk-start` to each anchor row using the server-supplied
1071
+ * `content_hash`, then kick off the initial summary fetch (gated by
1072
+ * `summaries.enabled` in `/api/config`). Drains any summaries that
1073
+ * arrived before mounting finished.
1074
+ *
1075
+ * Order matters:
1076
+ * 1. Config gate first — bail before paying any setup cost when the
1077
+ * feature is off.
1078
+ * 2. Restore localStorage visibility state.
1079
+ * 3. Walk records and wire each anchor row to its server-supplied
1080
+ * content hash. The server computes hashes from the canonical
1081
+ * (non-whitespace-filtered) diff so they stay aligned with persisted
1082
+ * summary keys; records lacking a `contentHash` are logged-and-skipped.
1083
+ * 4. Drain pending summaries against the freshly-built anchor map.
1084
+ * 5. Fetch existing summaries from the server.
1085
+ *
1086
+ * Race-safety: `_renderGen` is captured at entry and rechecked after
1087
+ * every `await`. If `renderDiff()` ran again mid-flight, we stop touching
1088
+ * the (now stale) maps and DOM.
1089
+ * @returns {Promise<void>}
1090
+ */
1091
+ async _kickOffHunkSummaries() {
1092
+ const gen = this._renderGen;
1093
+ const records = this._pendingHunkRecords || [];
1094
+ this._pendingHunkRecords = null; // single-use; renderDiff resets
1095
+
1096
+ // 1. Config gate — bail before doing any work when the feature is off.
1097
+ const cfg = await this._getAppConfig();
1098
+ if (gen !== this._renderGen) return;
1099
+ if (!(cfg.summaries && cfg.summaries.enabled)) return;
1100
+
1101
+ // 2. Restore localStorage visibility state.
1102
+ if (this.currentPR?.id) {
1103
+ const hidden = window.localStorage.getItem(`pair-review:summaries-hidden:${this.currentPR.id}`) === '1';
1104
+ this._summariesHidden = hidden;
1105
+ document.body.classList.toggle('summaries-hidden', hidden);
1106
+ this._syncSummaryToolbarButton();
1107
+ this._restoreSummariesHiddenFiles();
1108
+ // Apply per-file hidden state to wrappers already in the DOM, syncing
1109
+ // both the wrapper class AND the toggle button so its visible state and
1110
+ // aria-pressed match the persisted hidden flag.
1111
+ for (const filePath of this.summariesHiddenFiles) {
1112
+ const wrapper = document.querySelector(
1113
+ `.d2h-file-wrapper[data-file-name="${CSS.escape(filePath)}"]`
1114
+ );
1115
+ if (!wrapper) continue;
1116
+ wrapper.classList.add('summaries-hidden-file');
1117
+ const btn = wrapper.querySelector('.file-header-summary-toggle');
1118
+ if (btn) this._syncFileSummaryToggleButton(btn, filePath);
1119
+ }
1120
+ }
1121
+
1122
+ // 3. Wire each anchor row to its server-supplied content hash. The
1123
+ // backend computes these from the canonical (unfiltered) diff so they
1124
+ // stay aligned with persisted summary keys regardless of `?w=1`.
1125
+ // Records missing `contentHash` indicate a server bug (or a hash array
1126
+ // length mismatch the renderPatch guard already dropped) — log + skip.
1127
+ for (const rec of records) {
1128
+ if (!rec.anchorRow || !rec.anchorRow.isConnected) continue;
1129
+ const hex = rec.contentHash;
1130
+ if (!hex) {
1131
+ console.warn(
1132
+ `[HunkSummary] no server contentHash for ${rec.file} ` +
1133
+ `hunk ${rec.header}; skipping (summaries will not anchor here).`
1134
+ );
1135
+ continue;
1136
+ }
1137
+ rec.anchorRow.dataset.hunkStart = hex;
1138
+ this._summaryAnchorsByHash.set(hex, rec.anchorRow);
1139
+ let bucket = this._summaryHashesByFile.get(rec.file);
1140
+ if (!bucket) {
1141
+ bucket = new Set();
1142
+ this._summaryHashesByFile.set(rec.file, bucket);
1143
+ }
1144
+ bucket.add(hex);
1145
+ }
1146
+
1147
+ // 4. Drain summaries that arrived (via WS) before hashing finished.
1148
+ if (this._pendingSummariesByHash.size > 0) {
1149
+ const filesWithMounts = new Set();
1150
+ for (const [hash, summary] of this._pendingSummariesByHash.entries()) {
1151
+ if (this._summaryAnchorsByHash.has(hash)) {
1152
+ const row = this._renderOneSummary(summary);
1153
+ if (row) {
1154
+ // Find the file this hash belongs to, so we can re-enable its
1155
+ // toggle button.
1156
+ for (const [filePath, bucket] of this._summaryHashesByFile.entries()) {
1157
+ if (bucket.has(hash)) {
1158
+ filesWithMounts.add(filePath);
1159
+ break;
1160
+ }
1161
+ }
1162
+ }
1163
+ this._pendingSummariesByHash.delete(hash);
1164
+ }
1165
+ }
1166
+ for (const filePath of filesWithMounts) this._refreshFileSummaryToggle(filePath);
1167
+ }
1168
+
1169
+ // 5. Load existing summaries from the server.
1170
+ if (!this.currentPR?.id) return;
1171
+
1172
+ try {
1173
+ const resp = await fetch(`/api/reviews/${this.currentPR.id}/hunk-summaries`);
1174
+ if (gen !== this._renderGen) return;
1175
+ if (!resp.ok) return;
1176
+ const data = await resp.json();
1177
+ if (gen !== this._renderGen) return;
1178
+ const summaries = Array.isArray(data.summaries) ? data.summaries : [];
1179
+ // Group by file path so we can refresh each file's toggle button once.
1180
+ const byFile = new Map();
1181
+ for (const summary of summaries) {
1182
+ const fp = summary.file_path;
1183
+ if (!fp) continue;
1184
+ if (!byFile.has(fp)) byFile.set(fp, []);
1185
+ byFile.get(fp).push(summary);
1186
+ }
1187
+ // If summaries lack file_path, fall back to ungrouped rendering. Set
1188
+ // `_summariesGenerated` only after a summary actually mounts against the
1189
+ // current render anchors — never from the raw fetch count — so stale-hash
1190
+ // rows the anchor filter rejects can't flip the toolbar into Hide/Show
1191
+ // mode with nothing in the DOM. The grouped path defers this to
1192
+ // `_applyHunkSummaries`, which sets the flag on a successful mount.
1193
+ if (byFile.size === 0 && summaries.length > 0) {
1194
+ let mountedAny = false;
1195
+ for (const summary of summaries) {
1196
+ if (this._renderOneSummary(summary)) mountedAny = true;
1197
+ }
1198
+ if (mountedAny) {
1199
+ this._summariesGenerated = true;
1200
+ this._syncSummaryToolbarButton();
1201
+ }
1202
+ } else {
1203
+ for (const [filePath, fileSummaries] of byFile.entries()) {
1204
+ this._applyHunkSummaries(filePath, fileSummaries);
1205
+ }
1206
+ }
1207
+ // Mirror the queue's view of whether summaries are still being generated.
1208
+ // The `review:background_job_finished` WS event clears this when the job
1209
+ // completes mid-session.
1210
+ this._summariesGenerating = data.generating === true;
1211
+ this._syncSummaryToolbarButton();
1212
+ } catch (err) {
1213
+ console.warn('[HunkSummary] failed to load summaries:', err);
1214
+ }
1215
+ }
1216
+
1217
+ /**
1218
+ * Apply a batch of summaries delivered via the WS
1219
+ * `review:hunk_summaries_ready` event for a single file. Validates each
1220
+ * summary's hash against the per-file hash bucket so a hash collision
1221
+ * across files can't pull a summary into the wrong file's view, and
1222
+ * re-enables the per-file toggle button once a file has at least one
1223
+ * summary mounted.
1224
+ * @param {string} filePath - File path the summaries belong to
1225
+ * @param {Array<Object>} summaries - Summary rows for that file
1226
+ */
1227
+ _applyHunkSummaries(filePath, summaries) {
1228
+ if (!Array.isArray(summaries)) return;
1229
+ const allowedHashes = this._summaryHashesByFile.get(filePath) || new Set();
1230
+ let mountedAny = false;
1231
+ for (const summary of summaries) {
1232
+ if (!summary?.content_hash) continue;
1233
+ if (allowedHashes.size > 0 && !allowedHashes.has(summary.content_hash)) {
1234
+ if (!this._warnedCrossFileHashMismatch) {
1235
+ this._warnedCrossFileHashMismatch = true;
1236
+ console.warn(
1237
+ `[HunkSummary] dropping summary for ${filePath}: hash ${summary.content_hash} ` +
1238
+ 'not present in file hash bucket. Likely cross-file collision or stale render.'
1239
+ );
1240
+ }
1241
+ continue;
1242
+ }
1243
+ const row = this._renderOneSummary(summary);
1244
+ if (row) mountedAny = true;
1245
+ }
1246
+ if (mountedAny) {
1247
+ // At least one summary mounted — the feature now has data, so the
1248
+ // toolbar button can show its `.active` (blue) state.
1249
+ this._summariesGenerated = true;
1250
+ this._syncSummaryToolbarButton();
1251
+ }
1252
+ if (mountedAny && filePath) this._refreshFileSummaryToggle(filePath);
1253
+ }
1254
+
1255
+ /**
1256
+ * Refresh the per-file summary toggle button for `filePath` so it reflects
1257
+ * the current state: enabled iff there is at least one mounted summary in
1258
+ * that file's hash bucket.
1259
+ * @param {string} filePath
1260
+ */
1261
+ _refreshFileSummaryToggle(filePath) {
1262
+ if (!filePath) return;
1263
+ const wrapper = document.querySelector(
1264
+ `.d2h-file-wrapper[data-file-name="${CSS.escape(filePath)}"]`
1265
+ );
1266
+ if (!wrapper) return;
1267
+ const btn = wrapper.querySelector('.file-header-summary-toggle');
1268
+ if (!btn) return;
1269
+ this._syncFileSummaryToggleButton(btn, filePath);
1270
+ }
1271
+
1272
+ /**
1273
+ * Apply the canonical per-file summary toggle button state derived from
1274
+ * `_summaryHashesByFile` and `summariesHiddenFiles`. Sets `disabled`,
1275
+ * `summaries-off`, `aria-pressed`, and `title` on the button.
1276
+ *
1277
+ * Used by three call sites that must agree on the button's visible state:
1278
+ * - createFileHeader (initial render)
1279
+ * - _kickOffHunkSummaries (rehydrate after localStorage restore)
1280
+ * - _refreshFileSummaryToggle (when summaries arrive late)
1281
+ * - toggleFileSummaries (user click)
1282
+ *
1283
+ * @param {HTMLButtonElement} btn
1284
+ * @param {string} filePath
1285
+ */
1286
+ _syncFileSummaryToggleButton(btn, filePath) {
1287
+ if (!btn || !filePath) return;
1288
+ const hasSummaries = (this._summaryHashesByFile.get(filePath)?.size || 0) > 0;
1289
+ const isHidden = this.summariesHiddenFiles.has(filePath);
1290
+ btn.classList.toggle('summaries-off', isHidden);
1291
+ btn.setAttribute('aria-pressed', isHidden ? 'false' : 'true');
1292
+ if (!hasSummaries) {
1293
+ btn.disabled = true;
1294
+ btn.title = 'No summaries available';
1295
+ } else {
1296
+ btn.disabled = false;
1297
+ btn.title = isHidden ? 'Show file summaries' : 'Hide file summaries';
1298
+ }
1299
+ }
1300
+
1301
+ /**
1302
+ * Render a single summary row, or queue it if the matching hunk hasn't
1303
+ * been hashed yet (race between WS broadcast and post-render hashing).
1304
+ * Trivial / model-skipped / model-malformed rows are ignored.
1305
+ * @param {Object} summary - { content_hash, summary_text, trivial_reason }
1306
+ * @returns {HTMLTableRowElement|null} The mounted row, or null if queued/skipped.
1307
+ */
1308
+ _renderOneSummary(summary) {
1309
+ if (!summary || !summary.content_hash) return null;
1310
+ if (!summary.summary_text) return null; // trivial / opt-out — nothing to show
1311
+ const hash = summary.content_hash;
1312
+ const anchor = this._summaryAnchorsByHash.get(hash);
1313
+ if (!anchor || !anchor.isConnected) {
1314
+ // Anchor missing or detached (stale render) → defer; the next render
1315
+ // pass that re-establishes the hash will drain this map.
1316
+ this._pendingSummariesByHash.set(hash, summary);
1317
+ return null;
1318
+ }
1319
+ if (!this.hunkSummaryRenderer) return null;
1320
+ return this.hunkSummaryRenderer.renderInline(anchor, summary);
1321
+ }
1322
+
1323
+ /**
1324
+ * Storage key for per-file summary visibility. Mirrors the
1325
+ * `pair-review:summaries-hidden:${reviewId}` review-level key.
1326
+ * @param {number|string} reviewId
1327
+ * @returns {string}
1328
+ */
1329
+ static summariesHiddenFilesStorageKey(reviewId) {
1330
+ return `pair-review:summaries-hidden-files:${reviewId}`;
1331
+ }
1332
+
1333
+ /**
1334
+ * Toggle the visibility of summaries for a single file. Updates the
1335
+ * `summariesHiddenFiles` set, the wrapper's CSS class, the per-file toggle
1336
+ * button's `summaries-off` class, and persists the set per-review.
1337
+ * @param {string} filePath
1338
+ * @param {HTMLElement} fileWrapper - The `.d2h-file-wrapper` element
1339
+ */
1340
+ toggleFileSummaries(filePath, fileWrapper) {
1341
+ if (!filePath || !fileWrapper) return;
1342
+ const isHidden = this.summariesHiddenFiles.has(filePath);
1343
+ if (isHidden) {
1344
+ this.summariesHiddenFiles.delete(filePath);
1345
+ } else {
1346
+ this.summariesHiddenFiles.add(filePath);
1347
+ }
1348
+ fileWrapper.classList.toggle('summaries-hidden-file', !isHidden);
1349
+ const btn = fileWrapper.querySelector('.file-header-summary-toggle');
1350
+ if (btn) this._syncFileSummaryToggleButton(btn, filePath);
1351
+ if (this.currentPR?.id != null) {
1352
+ try {
1353
+ window.localStorage.setItem(
1354
+ PRManager.summariesHiddenFilesStorageKey(this.currentPR.id),
1355
+ JSON.stringify([...this.summariesHiddenFiles])
1356
+ );
1357
+ } catch {
1358
+ // localStorage unavailable; in-session state still applies.
1359
+ }
1360
+ }
1361
+ }
1362
+
1363
+ /**
1364
+ * Hydrate `summariesHiddenFiles` from localStorage for the current review.
1365
+ * Safe to call multiple times — the state always reflects what's in storage.
1366
+ */
1367
+ _restoreSummariesHiddenFiles() {
1368
+ if (!this.currentPR?.id) return;
1369
+ try {
1370
+ const raw = window.localStorage.getItem(
1371
+ PRManager.summariesHiddenFilesStorageKey(this.currentPR.id)
1372
+ );
1373
+ if (!raw) {
1374
+ this.summariesHiddenFiles = new Set();
1375
+ return;
1376
+ }
1377
+ const arr = JSON.parse(raw);
1378
+ this.summariesHiddenFiles = new Set(Array.isArray(arr) ? arr : []);
1379
+ } catch {
1380
+ this.summariesHiddenFiles = new Set();
1381
+ }
1382
+ }
1383
+
1384
+ /**
1385
+ * Toggle review-level summary visibility. Persists per-review.
1386
+ */
1387
+ toggleSummariesVisibility() {
1388
+ this._summariesHidden = !this._summariesHidden;
1389
+ document.body.classList.toggle('summaries-hidden', this._summariesHidden);
1390
+ if (this.currentPR?.id != null) {
1391
+ try {
1392
+ window.localStorage.setItem(
1393
+ `pair-review:summaries-hidden:${this.currentPR.id}`,
1394
+ this._summariesHidden ? '1' : '0'
1395
+ );
1396
+ } catch {
1397
+ // localStorage unavailable; in-session state still applies.
1398
+ }
1399
+ }
1400
+ this._syncSummaryToolbarButton();
1401
+ }
1402
+
1403
+ /**
1404
+ * Reflect the current state (visible / hidden / generating) on the
1405
+ * toolbar toggle button. The button gets:
1406
+ * - `.active` when summaries are visible
1407
+ * - `.generating` when a background summary job is in flight
1408
+ * - `title` + `aria-label` + `data-label` (CSS hover fallback) all kept
1409
+ * in sync so the user always knows what the button does.
1410
+ */
1411
+ _syncSummaryToolbarButton() {
1412
+ const btn = document.getElementById('summary-toggle-btn');
1413
+ if (!btn) return;
1414
+ // `.active` (blue) only once summaries actually exist AND are visible.
1415
+ // Before any generation the button stays colorless so the pre-generated
1416
+ // state is visually distinct from "generated but hidden".
1417
+ btn.classList.toggle('active', this._summariesGenerated && !this._summariesHidden);
1418
+ btn.classList.toggle('generating', this._summariesGenerating === true);
1419
+
1420
+ let label;
1421
+ if (this._summariesGenerating) {
1422
+ // Hint at the cancel affordance — clicking the pulsing button now
1423
+ // opens a confirm dialog ("Cancel Summaries" / "OK") instead of
1424
+ // toggling visibility. See _handleSummaryToggleClick.
1425
+ label = 'Generating summaries… (click to cancel)';
1426
+ } else if (!this._summariesGenerated) {
1427
+ // Pre-generated state: nothing generated yet. Colorless button; a click
1428
+ // kicks off generation. See _handleSummaryToggleClick.
1429
+ label = 'Generate hunk summaries';
1430
+ } else {
1431
+ label = this._summariesHidden ? 'Show hunk summaries' : 'Hide hunk summaries';
1432
+ }
1433
+ btn.title = label;
1434
+ btn.setAttribute('aria-label', label);
1435
+ btn.dataset.label = label;
1436
+ btn.setAttribute(
1437
+ 'aria-pressed',
1438
+ (this._summariesGenerated && !this._summariesHidden) ? 'true' : 'false'
1439
+ );
1440
+ }
1441
+
1442
+ // ===== Tour (Phase 8) ===================================================
1443
+
1444
+ /**
1445
+ * Whether a tour is currently mounted in the UI.
1446
+ * @returns {boolean}
1447
+ */
1448
+ _tourIsActive() {
1449
+ return this._tourActiveIndex >= 0 && !!this._tourRenderer;
1450
+ }
1451
+
1452
+ /**
1453
+ * Reflect tour state on the toolbar toggle button. Mirrors the structure
1454
+ * of `_syncSummaryToolbarButton` so future tweaks stay in lockstep.
1455
+ */
1456
+ _syncTourToolbarButton() {
1457
+ const btn = document.getElementById('tour-toggle-btn');
1458
+ if (!btn) return;
1459
+ const active = this._tourIsActive();
1460
+ const hasPending = active && Array.isArray(this._tourStopsPendingRestart);
1461
+ btn.classList.toggle('active', active);
1462
+ btn.classList.toggle('generating', this._tourGenerating === true);
1463
+ btn.classList.toggle('tour-updated-pending', hasPending);
1464
+
1465
+ let label;
1466
+ if (this._tourGenerating) {
1467
+ // Hint at the cancel affordance — see _handleTourToggleClick.
1468
+ label = active
1469
+ ? 'Generating tour… (click to cancel)'
1470
+ : 'Generating guided tour… (click to cancel)';
1471
+ } else if (hasPending) {
1472
+ label = 'Tour updated — restart to apply new stops';
1473
+ } else if (active) {
1474
+ label = 'Exit guided tour';
1475
+ } else if (this._tourStops && this._tourStops.length > 0) {
1476
+ label = 'Start guided tour';
1477
+ } else {
1478
+ label = 'Start guided tour (none available yet)';
1479
+ }
1480
+ btn.title = label;
1481
+ btn.setAttribute('aria-label', label);
1482
+ btn.dataset.label = label;
1483
+ btn.setAttribute('aria-pressed', active ? 'true' : 'false');
1484
+ }
1485
+
1486
+ // ===== Cancel flow (shared) =============================================
1487
+
1488
+ /**
1489
+ * Toolbar click handler for the summaries toggle. If a summary job is
1490
+ * in flight (`.generating` pulse), intercept and open the cancel-confirm
1491
+ * dialog instead of toggling visibility. Toggle visibility otherwise.
1492
+ *
1493
+ * Kept thin so `addEventListener` callers don't need to know about the
1494
+ * cancel flow — that lives in `_promptCancelJob`.
1495
+ * @returns {void}
1496
+ */
1497
+ async _handleSummaryToggleClick() {
1498
+ if (this._summariesGenerating) {
1499
+ await this._promptCancelJob({
1500
+ kind: 'summaries',
1501
+ onCleared: () => {
1502
+ this._summariesGenerating = false;
1503
+ this._syncSummaryToolbarButton();
1504
+ },
1505
+ });
1506
+ return;
1507
+ }
1508
+ if (!this._summariesGenerated) {
1509
+ // Pre-generated state: a click triggers generation rather than toggling
1510
+ // visibility. `_startGenerationJob` sets the pulsing `.generating` state
1511
+ // optimistically (there is no `review:background_job_started` event).
1512
+ await this._startGenerationJob('summary');
1513
+ return;
1514
+ }
1515
+ this.toggleSummariesVisibility();
1516
+ }
1517
+
1518
+ /**
1519
+ * Toolbar click handler for the tour toggle. If a tour job is in flight
1520
+ * (`.generating` pulse), intercept and open the cancel-confirm dialog
1521
+ * instead of opening/exiting the tour. Defer to `startOrToggleTour`
1522
+ * otherwise.
1523
+ * @returns {Promise<void>}
1524
+ */
1525
+ async _handleTourToggleClick() {
1526
+ if (this._tourGenerating) {
1527
+ await this._promptCancelJob({
1528
+ kind: 'tour',
1529
+ onCleared: () => {
1530
+ this._tourGenerating = false;
1531
+ this._syncTourToolbarButton();
1532
+ },
1533
+ });
1534
+ return;
1535
+ }
1536
+ await this.startOrToggleTour();
1537
+ }
1538
+
1539
+ /**
1540
+ * Shared cancel-flow entrypoint: opens the right confirm dialog for the
1541
+ * given job kind, POSTs the cancel on confirm, and runs `onCleared` so
1542
+ * the caller can reset the pulse state. The corresponding broadcast
1543
+ * (`review:background_job_finished` with `cancelled: true`) will arrive
1544
+ * shortly after; that handler also clears the flag, so a double-clear
1545
+ * is harmless.
1546
+ *
1547
+ * @param {Object} opts
1548
+ * @param {'tour'|'summaries'} opts.kind
1549
+ * @param {Function} opts.onCleared - Called after the user confirms.
1550
+ * @returns {Promise<void>}
1551
+ */
1552
+ async _promptCancelJob({ kind, onCleared }) {
1553
+ // Re-entry guard: the pulsing toolbar button stays clickable while the
1554
+ // confirm dialog is up. ConfirmDialog is a singleton — a second call to
1555
+ // .show() overwrites the first invocation's callbacks and orphans its
1556
+ // Promise. Drop the second click instead.
1557
+ if (this._cancelPromptOpen) return;
1558
+ const helper = typeof window !== 'undefined' ? window.CancelBackgroundJob : null;
1559
+ if (!helper) return;
1560
+ const reviewId = this.currentPR && this.currentPR.id;
1561
+ if (!reviewId) return;
1562
+ const show = kind === 'tour'
1563
+ ? helper.showCancelTourDialog
1564
+ : helper.showCancelSummariesDialog;
1565
+ this._cancelPromptOpen = true;
1566
+ try {
1567
+ await show({ reviewId, onCancelled: onCleared });
1568
+ } finally {
1569
+ this._cancelPromptOpen = false;
1570
+ }
1571
+ }
1572
+
1573
+ /**
1574
+ * Manually trigger a summary or tour generation job for the current review.
1575
+ * Used when `auto_generate` is off so generation does not kick off on load;
1576
+ * the user clicks the toolbar button to start it.
1577
+ *
1578
+ * Mode-aware: PR reviews POST to `/api/pr/...`, local reviews to
1579
+ * `/api/local/...`. The server enqueues the job with `trigger: 'manual'`
1580
+ * (bypassing the `auto_generate` gate) and responds with `{ started }` /
1581
+ * `{ alreadyRunning }`. There is no `review:background_job_started`
1582
+ * broadcast, so this method optimistically sets the matching `*Generating`
1583
+ * flag and pulses the button itself; `review:background_job_finished`
1584
+ * clears the flag when the job ends.
1585
+ *
1586
+ * @param {'summary'|'tour'} jobKey
1587
+ * @returns {Promise<void>}
1588
+ */
1589
+ async _startGenerationJob(jobKey) {
1590
+ const pr = this.currentPR;
1591
+ if (!pr || pr.id == null) return;
1592
+ const isLocal = pr.reviewType === 'local'
1593
+ || (typeof window !== 'undefined' && window.PAIR_REVIEW_LOCAL_MODE === true);
1594
+ const url = isLocal
1595
+ ? `/api/local/${encodeURIComponent(pr.id)}/jobs/${encodeURIComponent(jobKey)}/start`
1596
+ : `/api/pr/${encodeURIComponent(pr.owner)}/${encodeURIComponent(pr.repo)}/${encodeURIComponent(pr.number)}/jobs/${encodeURIComponent(jobKey)}/start`;
1597
+ try {
1598
+ const resp = await fetch(url, { method: 'POST' });
1599
+ if (resp.status === 409) {
1600
+ // Feature disabled in config — shouldn't happen (the button is hidden
1601
+ // when disabled) but surface it rather than failing silently.
1602
+ // NOTE: the toast singleton is lowercase `window.toast` (see
1603
+ // cancel-background-job.js); `window.Toast` does not exist.
1604
+ if (window.toast?.error) window.toast.error('This feature is disabled in config.');
1605
+ return;
1606
+ }
1607
+ if (!resp.ok) {
1608
+ console.warn(`[StartJob] ${jobKey} start POST failed: ${resp.status}`);
1609
+ return;
1610
+ }
1611
+ // Optimistic UI: there is no `review:background_job_started` broadcast,
1612
+ // so set the generating flag now — when the server enqueued a job
1613
+ // (`started`) or one was already running (`alreadyRunning`) — to start
1614
+ // the pulse immediately. Results arrive via `review:hunk_summaries_ready`
1615
+ // / `review:tour_ready`; `review:background_job_finished` clears the flag
1616
+ // when the job ends.
1617
+ const payload = await resp.json().catch(() => ({}));
1618
+ if (payload.started || payload.alreadyRunning) {
1619
+ if (jobKey === 'summary') {
1620
+ this._summariesGenerating = true;
1621
+ this._syncSummaryToolbarButton();
1622
+ } else if (jobKey === 'tour') {
1623
+ this._tourGenerating = true;
1624
+ this._syncTourToolbarButton();
1625
+ }
1626
+ }
1627
+ } catch (err) {
1628
+ console.warn(`[StartJob] ${jobKey} start POST error:`, err.message);
1629
+ }
1630
+ }
1631
+
1632
+ /**
1633
+ * Fetch /api/reviews/:reviewId/tour and stash the result in `_tourStops`
1634
+ * / `_tourGenerating`. Does NOT open the tour.
1635
+ *
1636
+ * If `deferIfActive` is true and a tour is currently mounted, the fetched
1637
+ * stops are stashed on `_tourStopsPendingRestart` instead of replacing
1638
+ * the active tour's stops. The pending stops apply on the next exit or
1639
+ * restart. This is the v1 simple approach — replacing the running tour
1640
+ * mid-flight is doable but adds complexity (mounted refs keyed by old
1641
+ * indices, current-stop drift, etc.) without a clear UX win.
1642
+ *
1643
+ * @param {Object} [opts]
1644
+ * @param {boolean} [opts.deferIfActive=false]
1645
+ * @param {boolean} [opts.cancelOnRender=true] - When true (default),
1646
+ * the probe captures `_renderGen` and aborts before mutating state
1647
+ * if a later render bumps the generation. Render-triggered probes
1648
+ * want this so a stale fetch can't clobber a fresh reset. One-shot
1649
+ * recovery callers (e.g. the deferred config-probe) pass `false`
1650
+ * so they don't self-cancel.
1651
+ * @returns {Promise<Array<Object>|null>} resolved stops, or null on miss.
1652
+ */
1653
+ async _loadAndStashTour({ deferIfActive = false, cancelOnRender = true } = {}) {
1654
+ if (!this.currentPR?.id) return null;
1655
+ if (this._toursEnabled === false) return null;
1656
+ // Capture the current render generation; if a later renderDiff bumps
1657
+ // _renderGen between our awaits, bail before mutating state. Only
1658
+ // applied when cancelOnRender is true — the deferred config probe
1659
+ // and other one-shot recovery callers pass `cancelOnRender: false`.
1660
+ const gen = this._renderGen;
1661
+ const guardStale = () => cancelOnRender && gen !== this._renderGen;
1662
+ try {
1663
+ const resp = await fetch(`/api/reviews/${this.currentPR.id}/tour`);
1664
+ if (guardStale()) return null;
1665
+ if (!resp.ok) return null;
1666
+ const data = await resp.json();
1667
+ if (guardStale()) return null;
1668
+ this._tourGenerating = data.generating === true;
1669
+ const stops = Array.isArray(data.tour?.stops) ? data.tour.stops : null;
1670
+ if (deferIfActive && this._tourIsActive()) {
1671
+ this._tourStopsPendingRestart = stops;
1672
+ } else {
1673
+ this._tourStops = stops;
1674
+ this._tourStopsPendingRestart = null;
1675
+ }
1676
+ this._syncTourToolbarButton();
1677
+ return stops;
1678
+ } catch (err) {
1679
+ console.warn('[Tour] failed to load tour:', err);
1680
+ return null;
1681
+ }
1682
+ }
1683
+
1684
+ /**
1685
+ * Toolbar click entrypoint. If a tour is active, exit. Otherwise fetch
1686
+ * stops if needed, then open from the first stop. No-ops when no stops
1687
+ * exist (toolbar button stays inert with the "none available yet" label).
1688
+ * @returns {Promise<void>}
1689
+ */
1690
+ async startOrToggleTour() {
1691
+ if (this._tourIsActive()) {
1692
+ this._exitTour();
1693
+ return;
1694
+ }
1695
+ if (!this._tourStops || this._tourStops.length === 0) {
1696
+ await this._loadAndStashTour();
1697
+ }
1698
+ if (!this._tourStops || this._tourStops.length === 0) {
1699
+ // No tour stops available. When auto-generation is off and nothing is
1700
+ // already in flight, a click triggers manual generation (mirrors the
1701
+ // summaries button). `_startGenerationJob` sets the pulsing state
1702
+ // optimistically (there is no `review:background_job_started` event).
1703
+ // When `review:tour_ready` arrives the stops load and the button becomes
1704
+ // "Start guided tour" — the user clicks again to open it (no auto-open).
1705
+ if (this._toursAutoGenerate === false && !this._tourGenerating) {
1706
+ await this._startGenerationJob('tour');
1707
+ }
1708
+ return;
1709
+ }
1710
+ await this._openTourAtStart();
1711
+ }
1712
+
1713
+ /**
1714
+ * Open the tour UI starting at stop 0. Lazy-creates the TourBar and
1715
+ * TourRenderer on first call so we pay zero cost for users who never
1716
+ * trigger a tour.
1717
+ */
1718
+ async _openTourAtStart() {
1719
+ if (!this._tourStops || this._tourStops.length === 0) return;
1720
+
1721
+ // Drain any pending teardown from the previous tour BEFORE we read
1722
+ // wrappers below. Otherwise a fire-and-forget DELETE + its
1723
+ // loadContextFiles reload landing mid-open can rip the wrapper the
1724
+ // first stop is about to mount against. allSettled-wrapped so it
1725
+ // never rejects.
1726
+ if (this._tourCleanupPending) {
1727
+ const pending = this._tourCleanupPending;
1728
+ this._tourCleanupPending = null;
1729
+ await pending;
1730
+ }
1731
+
1732
+ if (!this._tourRenderer && typeof window !== 'undefined' && window.TourRenderer) {
1733
+ this._tourRenderer = new window.TourRenderer(this);
1734
+ }
1735
+ if (!this._tourBar && typeof window !== 'undefined' && window.TourBar) {
1736
+ this._tourBar = new window.TourBar({
1737
+ onPrev: () => this._advanceTour(-1),
1738
+ onNext: () => this._advanceTour(1),
1739
+ onExit: () => this._exitTour(),
1740
+ onRestart: () => this._restartTour(),
1741
+ });
1742
+ }
1743
+ if (!this._tourRenderer || !this._tourBar) {
1744
+ console.warn('[Tour] TourRenderer/TourBar not available; cannot open tour');
1745
+ return;
1746
+ }
1747
+
1748
+ this._tourRenderer.setStops(this._tourStops);
1749
+ this._tourRenderer.setActive(true);
1750
+ // Mount inside the diff-view scroll container so the bar (position:
1751
+ // sticky) spans only the diff width — the file-tree sidebar and its
1752
+ // controls stay visible.
1753
+ const diffView = document.querySelector('.main-layout .diff-view');
1754
+ this._tourBar.mount(diffView || undefined);
1755
+ this._tourBar.setStops(this._tourStops);
1756
+ this._tourBar.setCompleted(false);
1757
+
1758
+ this._tourActiveIndex = -1;
1759
+ // Bump the generation BEFORE the first _advanceTour call so it sees
1760
+ // the fresh value as its baseline. Subsequent exits bump it again,
1761
+ // making in-flight probes from this open detect the mismatch and bail.
1762
+ this._tourGen += 1;
1763
+ this._registerTourKeyboardHandlers();
1764
+ this._advanceTour(1);
1765
+ this._syncTourToolbarButton();
1766
+ }
1767
+
1768
+ /**
1769
+ * Advance (or rewind) the active stop by `delta`. Going past the end of
1770
+ * the tour flips the bar into completion state; going before the start
1771
+ * clamps at 0.
1772
+ *
1773
+ * Async because each probe candidate is run through
1774
+ * `TourRenderer.prepareStop` first, which may need to await a file
1775
+ * fetch (adding a non-diff file as a context file) and/or a gap-expand
1776
+ * to surface folded rows the stop anchors on. Re-entrant calls (rapid
1777
+ * Next presses, keyboard mashing) are dropped via `_advanceInFlight`
1778
+ * so we never have two probe loops mutating tour state concurrently.
1779
+ *
1780
+ * @param {number} delta - Typically +1 (next) or -1 (prev).
1781
+ * @returns {Promise<void>}
1782
+ */
1783
+ async _advanceTour(delta) {
1784
+ if (!this._tourRenderer || !this._tourBar || !this._tourStops) return;
1785
+ const total = this._tourStops.length;
1786
+ if (total === 0) return;
1787
+
1788
+ // Drop overlapping nav requests. The keyboard / button callbacks all
1789
+ // fire-and-forget, so a fast Next-Next-Next while a file fetch is in
1790
+ // flight would otherwise interleave probe loops on shared mutable
1791
+ // state (`_tourActiveIndex`, the renderer's `_mounted` map).
1792
+ //
1793
+ // Latch is generation-scoped: an in-flight call from a torn-down
1794
+ // generation no longer matches `_tourGen`, so a fresh reopen passes
1795
+ // the check without any teardown path having to remember to clear
1796
+ // the slot. Fixes the exit-then-reopen wedge where the boolean
1797
+ // latch survived `_exitTour` and silently dropped the next open's
1798
+ // first `_advanceTour`.
1799
+ if (this._advanceInFlightGen === this._tourGen) return;
1800
+ this._advanceInFlightGen = this._tourGen;
1801
+ // Capture the open-generation so we can detect a teardown (exit /
1802
+ // reopen) that happened while we were sitting on an await below.
1803
+ const startGen = this._tourGen;
1804
+ const isStale = () => this._tourGen !== startGen;
1805
+ try {
1806
+ const startIndex = this._tourActiveIndex + delta;
1807
+ const dir = delta >= 0 ? 1 : -1;
1808
+
1809
+ // Forward past the end (initial open uses delta=1 from -1, so this only
1810
+ // fires once we've actually reached the last stop and pressed Next again).
1811
+ if (startIndex >= total) {
1812
+ this._tourBar.setCompleted(true);
1813
+ this._tourBar.setActiveIndex(total - 1);
1814
+ this._syncTourToolbarButton();
1815
+ return;
1816
+ }
1817
+
1818
+ // Probe-then-mount: locate the next mountable index WITHOUT unmounting
1819
+ // the current one. Only swap once we have a confirmed replacement. This
1820
+ // avoids the wedge where the current stop is torn down and no successor
1821
+ // mounts (file filtered out, scope change, etc.).
1822
+ let probe = Math.max(0, startIndex);
1823
+ let nextRow = null;
1824
+ let nextIndex = -1;
1825
+ while (probe >= 0 && probe < total) {
1826
+ // Skip re-probing the index that's already mounted — `mountStop` is
1827
+ // idempotent and returns the existing row, but we want to keep going
1828
+ // past the current active when delta moves us off it.
1829
+ if (probe !== this._tourActiveIndex) {
1830
+ // Prepare the stop first: add the file as a context file if it
1831
+ // isn't in the diff, and unfold any gap covering its line range.
1832
+ // prepareStop returning true is no guarantee mountStop will
1833
+ // succeed — genuinely missing data still falls through.
1834
+ await this._tourRenderer.prepareStop(probe);
1835
+ // Tour could have been exited (or re-opened) while prepareStop
1836
+ // was awaiting a file fetch / loadContextFiles. Bail before
1837
+ // mounting against a torn-down tour.
1838
+ if (isStale()) return;
1839
+ const row = this._tourRenderer.mountStop(probe);
1840
+ if (row) {
1841
+ nextRow = row;
1842
+ nextIndex = probe;
1843
+ break;
1844
+ }
1845
+ } else if (dir > 0) {
1846
+ // Already-active probe under forward motion shouldn't count as a hit;
1847
+ // we want to advance past it.
1848
+ } else {
1849
+ // Backward delta landing on the current stop: nothing earlier mounted.
1850
+ break;
1851
+ }
1852
+ probe += dir;
1853
+ }
1854
+
1855
+ if (!nextRow) {
1856
+ if (dir > 0) {
1857
+ // Forward exhaustion: flip to completion using the last successfully
1858
+ // mounted index. If we never mounted anything (initial open found no
1859
+ // mountable stops), bail out cleanly so the toolbar resets.
1860
+ if (this._tourActiveIndex < 0) {
1861
+ console.warn('[Tour] no mountable stops found; exiting');
1862
+ this._exitTour();
1863
+ return;
1864
+ }
1865
+ this._tourBar.setCompleted(true);
1866
+ this._tourBar.setActiveIndex(this._tourActiveIndex);
1867
+ this._syncTourToolbarButton();
1868
+ return;
1869
+ }
1870
+ // Backward exhaustion: leave the current stop mounted/active untouched.
1871
+ console.debug('[Tour] no earlier mountable stop; staying put');
1872
+ return;
1873
+ }
1874
+
1875
+ // Successful candidate — only now unmount the previous stop.
1876
+ if (this._tourActiveIndex >= 0 && this._tourActiveIndex !== nextIndex) {
1877
+ this._tourRenderer.unmountStop(this._tourActiveIndex);
1878
+ }
1879
+
1880
+ this._tourActiveIndex = nextIndex;
1881
+ this._tourRenderer.highlightActive(nextIndex);
1882
+ this._tourRenderer.scrollToStop(nextIndex);
1883
+ this._tourBar.setCompleted(false);
1884
+ this._tourBar.setActiveIndex(nextIndex);
1885
+ this._syncTourToolbarButton();
1886
+ // Suppress unused-var lints; nextRow exists for symmetry with future
1887
+ // post-mount work (focus management, telemetry).
1888
+ void nextRow;
1889
+ } finally {
1890
+ // Only release the latch if we still own the slot. A teardown that
1891
+ // bumped `_tourGen` between entry and now has already invalidated
1892
+ // our holder — and a fresh generation may have taken the slot for
1893
+ // its own call. Clobbering it with `-1` would let two _advanceTour
1894
+ // calls run concurrently on the new generation.
1895
+ if (this._advanceInFlightGen === startGen) {
1896
+ this._advanceInFlightGen = -1;
1897
+ }
1898
+ }
1899
+ }
1900
+
1901
+ /**
1902
+ * Tear down the tour: unmount every annotation, unmount the bar, drop the
1903
+ * body class, and unregister keyboard handlers.
1904
+ */
1905
+ _exitTour() {
1906
+ // Bump generation FIRST so any in-flight `_advanceTour` (sitting on an
1907
+ // ensureContextFile / ensureLinesVisible await) sees the mismatch on
1908
+ // resume and bails instead of mutating state for a torn-down tour.
1909
+ this._tourGen += 1;
1910
+ let drain = Promise.resolve();
1911
+ if (this._tourRenderer) {
1912
+ // unmountAll fires-and-forgets context-file DELETEs but returns a
1913
+ // drain promise. Stash it on `_tourCleanupPending` so the next open
1914
+ // can await it before reading wrappers — otherwise the DELETE's
1915
+ // loadContextFiles reload can rip the new tour's wrapper out from
1916
+ // under an active stop.
1917
+ drain = this._tourRenderer.unmountAll();
1918
+ this._tourRenderer.setActive(false);
1919
+ }
1920
+ this._tourCleanupPending = drain;
1921
+ if (this._tourBar) {
1922
+ this._tourBar.unmount();
1923
+ }
1924
+ this._unregisterTourKeyboardHandlers();
1925
+ this._tourActiveIndex = -1;
1926
+ // Consume any pending tour stashed by `review:tour_ready` while we
1927
+ // were running. Next open uses the fresh stops.
1928
+ if (Array.isArray(this._tourStopsPendingRestart)) {
1929
+ this._tourStops = this._tourStopsPendingRestart;
1930
+ this._tourStopsPendingRestart = null;
1931
+ }
1932
+ this._syncTourToolbarButton();
1933
+ }
1934
+
1935
+ /**
1936
+ * Exit, then re-open from stop 0. Async because `_openTourAtStart`
1937
+ * drains the prior tour's pending teardown (context-file DELETEs +
1938
+ * their loadContextFiles reloads) before reading wrappers, so the
1939
+ * fresh tour can't mount against a wrapper the old DELETE is about to
1940
+ * tear down. If a newer tour was stashed via `review:tour_ready`,
1941
+ * `_exitTour` swaps it in before we reopen.
1942
+ *
1943
+ * Caller (`onRestart` toolbar callback) is fire-and-forget; the
1944
+ * returned promise is for symmetry / testability.
1945
+ *
1946
+ * @returns {Promise<void>}
1947
+ */
1948
+ async _restartTour() {
1949
+ this._exitTour();
1950
+ await this._openTourAtStart();
1951
+ }
1952
+
1953
+ /**
1954
+ * Install the keyboard shortcut handler. Bound to `document` so it fires
1955
+ * regardless of focus, with a guard that skips when the user is typing
1956
+ * in a text field.
1957
+ */
1958
+ _registerTourKeyboardHandlers() {
1959
+ if (this._tourKeydownHandler) return;
1960
+ const handler = (e) => {
1961
+ if (!this._tourIsActive()) return;
1962
+
1963
+ // Skip when the user is typing — text fields, contenteditable, etc.
1964
+ // Arrow keys move the caret; Escape is owned by the surrounding form.
1965
+ const target = e.target;
1966
+ const tag = target && target.tagName;
1967
+ const isEditable = tag === 'TEXTAREA' || tag === 'INPUT' || tag === 'SELECT' ||
1968
+ (target && (target.isContentEditable || target.contentEditable === 'true'));
1969
+ if (isEditable) return;
1970
+
1971
+ // Skip when a modal is open. Modals own their own Escape ladder
1972
+ // (close dropdown, blur, dismiss); we don't want to compete. Defer
1973
+ // to the shared ModalDetection utility so the selector list stays
1974
+ // in sync with KeyboardShortcuts.
1975
+ if (window.ModalDetection?.isModalOpen()) return;
1976
+
1977
+ // Skip ALL tour shortcuts when the chat panel is open. ChatPanel
1978
+ // binds its own document-level Escape handler with a ladder of
1979
+ // states (provider/session dropdown, streaming stop, blur input,
1980
+ // close panel). Arrow keys may also be in use by chat surfaces.
1981
+ // Yanking the tour out from under the user — by advancing OR
1982
+ // exiting — when they have the chat panel open would be surprising.
1983
+ const chatPanel = document.querySelector('.chat-panel.chat-panel--open');
1984
+ if (chatPanel) return;
1985
+
1986
+ if (e.key === 'ArrowRight') {
1987
+ e.preventDefault();
1988
+ this._advanceTour(1);
1989
+ } else if (e.key === 'ArrowLeft') {
1990
+ e.preventDefault();
1991
+ this._advanceTour(-1);
1992
+ } else if (e.key === 'Escape') {
1993
+ e.preventDefault();
1994
+ // Stop propagation so other Escape-bound listeners (chat panel
1995
+ // when it's closed-but-bound, future keyboard shortcuts) don't
1996
+ // also fire for the same key event.
1997
+ e.stopImmediatePropagation();
1998
+ this._exitTour();
1999
+ }
2000
+ };
2001
+ document.addEventListener('keydown', handler);
2002
+ this._tourKeydownHandler = handler;
2003
+ }
2004
+
2005
+ _unregisterTourKeyboardHandlers() {
2006
+ if (!this._tourKeydownHandler) return;
2007
+ document.removeEventListener('keydown', this._tourKeydownHandler);
2008
+ this._tourKeydownHandler = null;
2009
+ }
2010
+
906
2011
  /**
907
2012
  * Listen for review-scoped CustomEvents dispatched by ChatPanel's
908
2013
  * WebSocket pub/sub connection.
@@ -984,6 +2089,50 @@ class PRManager {
984
2089
  await this.ensureLinesVisible([{ file, line_start, line_end, side: side || 'right' }]);
985
2090
  });
986
2091
 
2092
+ document.addEventListener('review:hunk_summaries_ready', (e) => {
2093
+ if (e.detail?.reviewId !== reviewId()) return;
2094
+ // Per-file completion implies the review-level job is still working,
2095
+ // so reflect "generating" until `background_job_finished` clears it.
2096
+ // (No-op when the toolbar button is already pulsing.)
2097
+ if (!this._summariesGenerating) {
2098
+ this._summariesGenerating = true;
2099
+ this._syncSummaryToolbarButton();
2100
+ }
2101
+ this._applyHunkSummaries(e.detail.filePath, e.detail.summaries || []);
2102
+ });
2103
+
2104
+ // Tour-ready broadcasts arrive after the tour-generation job persists a
2105
+ // new tour. We refresh the cached stops in the background but do NOT
2106
+ // auto-open the tour — user must click the toolbar button. If a tour is
2107
+ // already mounted, the new stops are stashed for restart so we don't
2108
+ // yank the active tour out from under the user (v1 simple approach).
2109
+ document.addEventListener('review:tour_ready', (e) => {
2110
+ if (e.detail?.reviewId !== reviewId()) return;
2111
+ this._loadAndStashTour({ deferIfActive: true }).catch(() => {});
2112
+ });
2113
+
2114
+ document.addEventListener('review:background_job_finished', (e) => {
2115
+ if (e.detail?.reviewId !== reviewId()) return;
2116
+ const jobType = e.detail?.jobType || '';
2117
+ const isSummaries = jobType === 'summaries' || jobType.startsWith('summaries:');
2118
+ const isTour = jobType === 'tour' || jobType.startsWith('tour:');
2119
+ if (!isSummaries && !isTour) return;
2120
+ // The queue can host multiple `${type}:${digest}` jobs back-to-back
2121
+ // (refresh, scope change, whitespace toggle). The broadcast payload
2122
+ // carries `hasActiveForType` from the queue's view AFTER this job's
2123
+ // key was deleted, so a sibling job still in flight keeps the pulse
2124
+ // visible.
2125
+ if (e.detail?.hasActiveForType === true) return;
2126
+ if (isSummaries) {
2127
+ this._summariesGenerating = false;
2128
+ this._syncSummaryToolbarButton();
2129
+ }
2130
+ if (isTour) {
2131
+ this._tourGenerating = false;
2132
+ this._syncTourToolbarButton();
2133
+ }
2134
+ });
2135
+
987
2136
  document.addEventListener('visibilitychange', () => {
988
2137
  if (document.hidden) return;
989
2138
  if (this._dirtyComments) { this._dirtyComments = false; this.loadUserComments(); }
@@ -1844,8 +2993,39 @@ class PRManager {
1844
2993
  const diffContainer = document.getElementById('diff-container');
1845
2994
  if (!diffContainer) return;
1846
2995
 
2996
+ // Tear down any active tour BEFORE wiping the diff DOM: unmountAll()
2997
+ // re-collapses files the tour auto-expanded by looking them up via
2998
+ // `.d2h-file-wrapper[data-file-name=...]`. If we cleared innerHTML
2999
+ // first those lookups would all miss, and the user's pre-tour
3000
+ // collapse state would be silently lost. (Mirrors the rationale for
3001
+ // hunkSummaryRenderer.reset below — anchor-based DOM state cannot
3002
+ // survive a re-render.)
3003
+ if (this._tourIsActive && this._tourIsActive()) {
3004
+ this._exitTour();
3005
+ }
3006
+
1847
3007
  diffContainer.innerHTML = '';
1848
3008
 
3009
+ // Reset hunk-summary tracking — `renderPatch` will populate this as it
3010
+ // walks each block, and we hash the records once render finishes.
3011
+ this._pendingHunkRecords = [];
3012
+ if (this.hunkSummaryRenderer) {
3013
+ this.hunkSummaryRenderer.reset();
3014
+ }
3015
+ this._tourStops = null;
3016
+ this._summaryAnchorsByHash = new Map();
3017
+ this._summaryHashesByFile = new Map();
3018
+ this._pendingSummariesByHash = new Map();
3019
+ // Reset alongside the other per-render summary state. Set true again only
3020
+ // when a summary actually mounts (see _applyHunkSummaries / the existing-
3021
+ // summary fetch). Without this, a re-render whose subsequent fetch returns
3022
+ // no matching rows keeps the stale `true`, leaving the toolbar stuck in
3023
+ // Hide/Show mode with nothing in the DOM and blocking click-to-generate.
3024
+ this._summariesGenerated = false;
3025
+ // Bump generation so any in-flight `_kickOffHunkSummaries` from the
3026
+ // previous render bails out instead of mutating maps we just reset.
3027
+ this._renderGen = (this._renderGen || 0) + 1;
3028
+
1849
3029
  // Use changed_files array from API
1850
3030
  const files = pr.changed_files || pr.files || [];
1851
3031
 
@@ -1880,6 +3060,21 @@ class PRManager {
1880
3060
  // Load context files after diff is rendered
1881
3061
  this.contextFiles = [];
1882
3062
  this.loadContextFiles();
3063
+
3064
+ // Kick off hunk-summary hashing + load (Phase 5). Fire-and-forget — the
3065
+ // diff is fully usable while summaries arrive asynchronously.
3066
+ if (this.hunkSummaryRenderer) {
3067
+ this._kickOffHunkSummaries().catch((err) => {
3068
+ console.warn('[HunkSummary] kickoff failed:', err);
3069
+ });
3070
+ }
3071
+
3072
+ // Probe tour endpoint after diff is rendered. `currentPR.id` is now
3073
+ // set (init()/LocalManager populates it before calling renderDiff),
3074
+ // so the toolbar button can reflect the right state.
3075
+ if (this._toursEnabled === true) {
3076
+ this._loadAndStashTour().catch(() => {});
3077
+ }
1883
3078
  }
1884
3079
 
1885
3080
  /**
@@ -1976,6 +3171,43 @@ class PRManager {
1976
3171
  }
1977
3172
  });
1978
3173
  header.appendChild(fileChatBtn);
3174
+
3175
+ // Per-file hunk-summary toggle. Mirrors the toolbar toggle but scoped to
3176
+ // one file. Disabled (greyed) when no summaries exist yet for this file;
3177
+ // _applyHunkSummaries / _kickOffHunkSummaries re-enable it as soon as
3178
+ // hashes for this file are recorded (so a summary that arrives later
3179
+ // doesn't get hidden behind a permanently-disabled button).
3180
+ // Gated on `_summariesEnabled`: skipped entirely when /api/config has
3181
+ // already reported the feature is off; created hidden + revealed later
3182
+ // when config has not yet resolved.
3183
+ if (this._summariesEnabled !== false) {
3184
+ const summaryToggleBtn = document.createElement('button');
3185
+ summaryToggleBtn.className = 'file-header-summary-toggle';
3186
+ summaryToggleBtn.dataset.file = file.file;
3187
+ summaryToggleBtn.innerHTML = `
3188
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
3189
+ <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"/>
3190
+ </svg>
3191
+ `;
3192
+
3193
+ if (this._summariesEnabled !== true) {
3194
+ // Config still pending; hide until the gate resolves.
3195
+ summaryToggleBtn.classList.add('summary-toggle-pending');
3196
+ summaryToggleBtn.style.display = 'none';
3197
+ }
3198
+
3199
+ const fileIsHidden = this.summariesHiddenFiles?.has(file.file) || false;
3200
+ if (fileIsHidden) {
3201
+ wrapper.classList.add('summaries-hidden-file');
3202
+ }
3203
+ this._syncFileSummaryToggleButton(summaryToggleBtn, file.file);
3204
+
3205
+ summaryToggleBtn.addEventListener('click', (e) => {
3206
+ e.stopPropagation();
3207
+ this.toggleFileSummaries(file.file, wrapper);
3208
+ });
3209
+ header.appendChild(summaryToggleBtn);
3210
+ }
1979
3211
  }
1980
3212
 
1981
3213
  // Create diff table
@@ -1986,7 +3218,7 @@ class PRManager {
1986
3218
 
1987
3219
  // Parse the diff content
1988
3220
  if (file.patch) {
1989
- this.renderPatch(tbody, file.patch, file.file);
3221
+ this.renderPatch(tbody, file.patch, file.file, file.hunk_hashes || null);
1990
3222
  } else if (file.binary) {
1991
3223
  const row = document.createElement('tr');
1992
3224
  row.innerHTML = '<td colspan="2" class="binary-file">Binary file</td>';
@@ -2010,14 +3242,39 @@ class PRManager {
2010
3242
  * @param {HTMLElement} tbody - Table body element
2011
3243
  * @param {string} patch - Unified diff patch string
2012
3244
  * @param {string} fileName - File name
3245
+ * @param {string[]|null} [hunkHashes] - Per-hunk content hashes parallel
3246
+ * to the order `parseDiffIntoBlocks` returns hunks. When supplied, these
3247
+ * are used instead of computing client-side hashes. Computed by the
3248
+ * backend from the canonical (non-whitespace-filtered) diff so they
3249
+ * stay aligned with persisted summary keys.
2013
3250
  */
2014
- renderPatch(tbody, patch, fileName) {
3251
+ renderPatch(tbody, patch, fileName, hunkHashes = null) {
2015
3252
  let diffPosition = 0; // GitHub diff_position (1-indexed, consecutive)
2016
3253
  let prevBlockEnd = { old: 0, new: 0 };
2017
3254
  let isFirstHunk = true;
2018
3255
 
2019
3256
  const blocks = window.HunkParser.parseDiffIntoBlocks(patch);
2020
3257
 
3258
+ // Defend against length drift between server-supplied (canonical) hashes
3259
+ // and the rendered (possibly whitespace-filtered) blocks: under `?w=1`,
3260
+ // `git diff -w` can drop or merge whitespace-only hunks so the canonical
3261
+ // and rendered hunk counts diverge. Misaligned hashes would write the
3262
+ // wrong canonical hash onto every block after the first dropped hunk,
3263
+ // anchoring summaries to the wrong rendered hunk. Fail closed: drop the
3264
+ // hashes for this file. Summaries then simply won't anchor — visibly
3265
+ // missing rather than visibly wrong.
3266
+ if (Array.isArray(hunkHashes) && hunkHashes.length !== blocks.length) {
3267
+ if (!this._warnedHunkHashLengthMismatch) {
3268
+ this._warnedHunkHashLengthMismatch = true;
3269
+ console.warn(
3270
+ `[HunkSummary] hunk_hashes length mismatch for ${fileName}: ` +
3271
+ `${hunkHashes.length} canonical hashes, ${blocks.length} rendered ` +
3272
+ 'blocks. Dropping hashes for this file.'
3273
+ );
3274
+ }
3275
+ hunkHashes = null;
3276
+ }
3277
+
2021
3278
  // Render blocks with gap sections
2022
3279
  blocks.forEach((block, blockIndex) => {
2023
3280
  diffPosition++; // Hunk header counts as a position
@@ -2097,6 +3354,7 @@ class PRManager {
2097
3354
  let oldLineNum = block.oldStart;
2098
3355
  let newLineNum = block.newStart;
2099
3356
 
3357
+ let firstLineRow = null;
2100
3358
  block.lines.forEach(line => {
2101
3359
  if (!line && line !== '') return; // Skip undefined
2102
3360
 
@@ -2126,8 +3384,24 @@ class PRManager {
2126
3384
  };
2127
3385
 
2128
3386
  this.renderDiffLine(tbody, lineData, fileName, diffPosition);
3387
+ if (!firstLineRow) firstLineRow = tbody.lastElementChild;
2129
3388
  });
2130
3389
 
3390
+ // Record this hunk's first rendered code row as the anchor for any
3391
+ // inline summary annotation. The canonical hash comes from the
3392
+ // backend (`hunkHashes[blockIndex]`); _kickOffHunkSummaries mounts
3393
+ // it as `data-hunk-start` after render finishes so the summary
3394
+ // renderer can find the anchor and insert the annotation above it.
3395
+ if (this._pendingHunkRecords && firstLineRow) {
3396
+ const serverHash = Array.isArray(hunkHashes) ? hunkHashes[blockIndex] || null : null;
3397
+ this._pendingHunkRecords.push({
3398
+ file: fileName,
3399
+ header: block.header,
3400
+ anchorRow: firstLineRow,
3401
+ contentHash: serverHash
3402
+ });
3403
+ }
3404
+
2131
3405
  // Update previous block end coordinates
2132
3406
  const endBounds = window.HunkParser.getBlockCoordinateBounds(
2133
3407
  { lines: this.parseBlockLines(block) },