@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
@@ -0,0 +1,755 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * TourRenderer - Inline tour-stop annotations + body-level tour mode.
4
+ *
5
+ * Responsibilities:
6
+ * - Look up the DOM anchor row for a stop by (file_path, side, line_start).
7
+ * - Mount a styled <tr class="tour-annotation-row"> immediately above the
8
+ * anchor row, carrying the stop's title + description and Prev/Next
9
+ * buttons that callback into the owning PRManager.
10
+ * - Track the currently-highlighted row and toggle the `active-stop` class.
11
+ * - Toggle the `body.tour-active` class for tour-specific chrome styling
12
+ * (sticky tour-bar offsets on .diff-toolbar / .d2h-file-header, plus
13
+ * the active-stop annotation highlight). Hunk summaries and tour stops
14
+ * are NOT mutually exclusive — both can render at once; the user
15
+ * toggles each independently.
16
+ *
17
+ * Stops are line-range based (see plans/semantic-hunk-summaries-and-tours.md);
18
+ * there is NO content_hash on stops. The anchor row is found via the
19
+ * diff-renderer's existing `data-line-number` + `data-side` attributes.
20
+ *
21
+ * A stop whose anchor row is missing (file filtered out, line not in the
22
+ * rendered diff, etc.) is skipped with a console.warn — the caller treats
23
+ * a null return as "couldn't render, advance to the next stop".
24
+ *
25
+ * Before calling `mountStop`, the caller should `await prepareStop(index)`,
26
+ * which makes the stop's lines mountable when possible:
27
+ * - Files not present in the diff at all are auto-added via
28
+ * PRManager.ensureContextFile (the same surface used by the AI
29
+ * suggestion "open context" flow). Auto-added files are tracked on
30
+ * `_autoAddedContextFileIds` and removed on tour exit so they don't
31
+ * leak into the user's persistent context-files list.
32
+ * - Folded gaps covering the stop's [line_start, line_end] range are
33
+ * expanded via PRManager.ensureLinesVisible so the anchor row exists
34
+ * by the time mountStop runs its DOM scan.
35
+ */
36
+
37
+ // Prefixed to avoid collision with TourBar.js when both load as plain
38
+ // <script> tags into the shared global scope.
39
+ const TOUR_RENDERER_LOCATION_PATH = 'm12.596 11.596-3.535 3.536a1.5 1.5 0 0 1-2.122 0l-3.535-3.536a6.5 6.5 0 1 1 9.192-9.193 6.5 6.5 0 0 1 0 9.193Zm-1.06-8.132v-.001a5 5 0 1 0-7.072 7.072L8 14.07l3.536-3.534a5 5 0 0 0 0-7.072ZM8 9a2 2 0 1 1-.001-3.999A2 2 0 0 1 8 9Z';
40
+
41
+ /**
42
+ * Escape a string for inclusion in a CSS attribute selector. Prefers the
43
+ * native `CSS.escape` when available (real browsers) and falls back to a
44
+ * minimal escape for jsdom / older runtimes that don't expose it.
45
+ * @param {string} value
46
+ * @returns {string}
47
+ */
48
+ function tourRendererEscapeAttr(value) {
49
+ const s = String(value);
50
+ if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
51
+ return CSS.escape(s);
52
+ }
53
+ // Fallback: backslash-escape anything that isn't a safe attribute char.
54
+ return s.replace(/[^a-zA-Z0-9_\-./]/g, (ch) => `\\${ch}`);
55
+ }
56
+
57
+ class TourRenderer {
58
+ /**
59
+ * @param {Object} prManager - Owning PRManager. Callbacks invoke
60
+ * prManager._advanceTour / prManager._exitTour so the renderer doesn't
61
+ * own navigation state.
62
+ */
63
+ constructor(prManager) {
64
+ this.prManager = prManager;
65
+ this._stops = [];
66
+ // Map<number, HTMLTableRowElement> — stop index -> mounted row
67
+ this._mounted = new Map();
68
+ // Set<string> — file paths that we auto-expanded during this tour so we
69
+ // can re-collapse them on exit. Tracking this lets us preserve the
70
+ // user's pre-tour collapse state (the user-facing `collapsedFiles` set
71
+ // on PRManager) instead of silently clobbering it.
72
+ this._autoExpanded = new Set();
73
+ // Set<number> — context-file IDs we auto-added during this tour (via
74
+ // prepareStop -> prManager.ensureContextFile) for files outside the
75
+ // PR diff. Removed in unmountAll so the user's persistent context-files
76
+ // list isn't polluted by transient tour state.
77
+ this._autoAddedContextFileIds = new Set();
78
+ // Set<number> — stop indices whose description the user has expanded
79
+ // via "Show more". Survives unmount/remount within the same tour
80
+ // session (e.g. when the user scrolls past, then back to, the stop)
81
+ // so the description doesn't snap back to its clamped form. Cleared
82
+ // when setStops replaces the tour entirely.
83
+ this._expandedDescriptions = new Set();
84
+ // Cache the user's motion preference at construction so scrollIntoView
85
+ // honors it on every navigation. Reading matchMedia each call would
86
+ // still work; caching just avoids the lookup.
87
+ this._reduceMotion = (
88
+ typeof window !== 'undefined' &&
89
+ typeof window.matchMedia === 'function' &&
90
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches === true
91
+ );
92
+ }
93
+
94
+ /**
95
+ * Stash the stops list. Does NOT mount anything — call `mountStop` per
96
+ * stop as the user navigates.
97
+ *
98
+ * Any previously-mounted annotation rows are unmounted, because `_mounted`
99
+ * is keyed by integer index into `_stops` and a fresh stops list silently
100
+ * remaps those indices to different entries (or none at all). Leaving
101
+ * stale rows behind would orphan them in the DOM.
102
+ *
103
+ * @param {Array<Object>} stops
104
+ */
105
+ setStops(stops) {
106
+ this.unmountAll();
107
+ this._stops = Array.isArray(stops) ? stops : [];
108
+ // A new stops list silently remaps indices; previously-expanded entries
109
+ // would point at the wrong descriptions. Reset rather than carry stale
110
+ // state across.
111
+ this._expandedDescriptions.clear();
112
+ }
113
+
114
+ /**
115
+ * Toggle the page-level "tour is active" body class. Drives tour-specific
116
+ * chrome styling (sticky tour-bar offsets, active-stop highlight) and any
117
+ * future global tour styling. Idempotent. Hunk summaries are NOT hidden
118
+ * by this class — both annotation styles can coexist in the diff.
119
+ * @param {boolean} isActive
120
+ */
121
+ setActive(isActive) {
122
+ if (typeof document === 'undefined') return;
123
+ document.body.classList.toggle('tour-active', isActive === true);
124
+ }
125
+
126
+ /**
127
+ * Make the stop at `index` mountable by ensuring its file is present in
128
+ * the diff view and the rows covering its [line_start, line_end] range
129
+ * are unfolded. Safe to call repeatedly — both the file-add path
130
+ * (`ensureContextFile`) and the gap-expand path (`ensureLinesVisible`)
131
+ * are idempotent.
132
+ *
133
+ * Resolves to `true` when the prep succeeded enough that `mountStop`
134
+ * has a chance of finding an anchor row, `false` on a hard failure
135
+ * (no PRManager, no stop, file fetch/POST failed). A `true` return is
136
+ * NOT a promise that `mountStop` will succeed — genuinely missing data
137
+ * (bad line numbers, file not in repo) still falls through to mountStop
138
+ * returning null. The caller's probe loop handles that.
139
+ *
140
+ * Tracks auto-additions and auto-expansions on `_autoAddedContextFileIds`
141
+ * and `_autoExpanded` (via the existing mountStop expand path) so
142
+ * `unmountAll` can restore pre-tour state on exit.
143
+ *
144
+ * @param {number} index
145
+ * @returns {Promise<boolean>}
146
+ */
147
+ async prepareStop(index) {
148
+ const stop = this._stops[index];
149
+ if (!stop || !this.prManager) return false;
150
+
151
+ // Capture the open-generation ONCE at entry. Every await below is a
152
+ // suspension window — if `_tourGen` bumps while we're suspended, the
153
+ // tour we started preparing for is gone (Escape, exit, reopen) and
154
+ // `unmountAll` has already run with an empty snapshot of
155
+ // `_autoAddedContextFileIds`. Anything we add after that snapshot would
156
+ // orphan, so we roll back directly on stale.
157
+ const startGen = this.prManager._tourGen;
158
+ const isStale = () => this.prManager._tourGen !== startGen;
159
+
160
+ const filePath = stop.file_path;
161
+ const lineStart = stop.line_start;
162
+ if (!filePath || typeof lineStart !== 'number') return false;
163
+
164
+ const lineEnd = (typeof stop.line_end === 'number' && stop.line_end >= lineStart)
165
+ ? stop.line_end
166
+ : lineStart;
167
+ const side = stop.side || 'RIGHT';
168
+
169
+ // 1) Ensure the file's wrapper is in the DOM. If the file isn't in the
170
+ // PR diff, route through ensureContextFile — which adds it as a
171
+ // context file (or PATCHes an existing context file to cover the
172
+ // range). Track the new id so unmountAll can DELETE it on exit and
173
+ // not leave the user with surprise persistent entries.
174
+ const existingWrapper = document.querySelector(
175
+ `.d2h-file-wrapper[data-file-name="${tourRendererEscapeAttr(filePath)}"]`
176
+ );
177
+ if (!existingWrapper && typeof this.prManager.ensureContextFile === 'function') {
178
+ const wasAlreadyContext = Array.isArray(this.prManager.contextFiles) &&
179
+ this.prManager.contextFiles.some((cf) => cf.file === filePath);
180
+ try {
181
+ const result = await this.prManager.ensureContextFile(filePath, lineStart, lineEnd);
182
+ // Only track the id when WE added it (not when an existing context
183
+ // file already covered or got merged-with this range — leaving
184
+ // user-created entries alone is the right default).
185
+ if (
186
+ result &&
187
+ result.type === 'context' &&
188
+ !wasAlreadyContext &&
189
+ result.contextFile &&
190
+ result.contextFile.id != null
191
+ ) {
192
+ if (isStale()) {
193
+ // Tour exited while the POST was in flight. unmountAll already
194
+ // ran with an empty snapshot — tracking the id now would orphan
195
+ // it forever. Roll back directly, fire-and-forget so the
196
+ // mid-exit user isn't blocked on a DELETE.
197
+ if (typeof this.prManager.removeContextFile === 'function') {
198
+ try {
199
+ const undo = this.prManager.removeContextFile(result.contextFile.id);
200
+ if (undo && typeof undo.catch === 'function') undo.catch(() => {});
201
+ } catch (_) {
202
+ // best-effort rollback
203
+ }
204
+ }
205
+ return false;
206
+ }
207
+ this._autoAddedContextFileIds.add(result.contextFile.id);
208
+ }
209
+ } catch (err) {
210
+ console.warn('[TourRenderer] ensureContextFile failed for', filePath, err);
211
+ // Fall through — mountStop will return null and the probe advances.
212
+ }
213
+ }
214
+
215
+ // Skip the gap-unfold on a dead tour. Gap-unfolds aren't tracked in
216
+ // _autoExpanded (that's collapse-state, not unfold-state) so they
217
+ // don't leak persistent state, but they ARE visible UI churn the user
218
+ // didn't ask for after pressing Escape.
219
+ if (isStale()) return false;
220
+
221
+ // 2) Unfold any gap covering the stop's range. Safe no-op when the
222
+ // rows are already visible. Works on both diff-file and context-file
223
+ // wrappers (both produce tr[data-line-number][data-side] rows).
224
+ if (typeof this.prManager.ensureLinesVisible === 'function') {
225
+ try {
226
+ await this.prManager.ensureLinesVisible([
227
+ { file: filePath, line_start: lineStart, line_end: lineEnd, side }
228
+ ]);
229
+ } catch (err) {
230
+ console.warn('[TourRenderer] ensureLinesVisible failed for', filePath, err);
231
+ // Fall through — the anchor scan may still succeed if a row in
232
+ // the range happened to render via a different path.
233
+ }
234
+ }
235
+
236
+ return true;
237
+ }
238
+
239
+ /**
240
+ * Mount the annotation row for the stop at `index`. Returns the row, or
241
+ * null if no anchor could be located.
242
+ *
243
+ * If the target file's wrapper is collapsed, expand it via the
244
+ * prManager's `toggleFileCollapse` so the renderer and PRManager's
245
+ * `collapsedFiles` set stay in sync. The expansion is recorded on
246
+ * `_autoExpanded` so `unmountAll` can re-collapse on tour exit, restoring
247
+ * the user's pre-tour collapse state.
248
+ *
249
+ * If `toggleFileCollapse` is missing we refuse to expand rather than
250
+ * strip the `collapsed` class directly — diverging the DOM from
251
+ * `collapsedFiles` would leave the file-tree and viewed-badge state
252
+ * lying.
253
+ *
254
+ * Async because `toggleFileCollapse` is now async (it awaits the lazy file
255
+ * body render before revealing it). The caller (`_advanceTour`) awaits this
256
+ * so the file is visibly expanded before `scrollToStop` runs — otherwise a
257
+ * stop inside a just-expanded file could be scrolled to while its rows are
258
+ * still hidden.
259
+ *
260
+ * @param {number} index
261
+ * @returns {Promise<HTMLTableRowElement|null>}
262
+ */
263
+ async mountStop(index) {
264
+ const stop = this._stops[index];
265
+ if (!stop) return null;
266
+
267
+ // Capture the open-generation ONCE at entry. The `toggleFileCollapse`
268
+ // await below is a suspension window — the tour can be exited or
269
+ // restarted (Escape, reopen) while it's in flight, which bumps
270
+ // `_tourGen` and runs `unmountAll`. Mirrors prepareStop's guard so we
271
+ // can bail without recording state for a dead tour. `?.` because
272
+ // prManager may be absent (the non-collapsed path has no await, so
273
+ // isStale stays harmlessly false there).
274
+ const startGen = this.prManager?._tourGen;
275
+ const isStale = () => this.prManager?._tourGen !== startGen;
276
+
277
+ if (this._mounted.has(index)) {
278
+ const existing = this._mounted.get(index);
279
+ if (existing && existing.isConnected) return existing;
280
+ this._mounted.delete(index);
281
+ }
282
+
283
+ const filePath = stop.file_path;
284
+ const side = stop.side || 'RIGHT';
285
+ const lineStart = stop.line_start;
286
+ // `line_end` may be missing on legacy/older stops; fall back to
287
+ // `line_start` so the range scan still works.
288
+ const lineEnd = (typeof stop.line_end === 'number' && stop.line_end >= lineStart)
289
+ ? stop.line_end
290
+ : lineStart;
291
+
292
+ if (!filePath || typeof lineStart !== 'number') {
293
+ console.warn('[TourRenderer] stop missing file_path/line_start; skipping', stop);
294
+ return null;
295
+ }
296
+
297
+ const wrapper = document.querySelector(
298
+ `.d2h-file-wrapper[data-file-name="${tourRendererEscapeAttr(filePath)}"]`
299
+ );
300
+ if (!wrapper) {
301
+ console.warn(`[TourRenderer] no wrapper for ${filePath}; skipping stop ${index}`);
302
+ return null;
303
+ }
304
+
305
+ // Look up the anchor row BEFORE expanding. Collapsed wrappers hide
306
+ // .d2h-file-body via CSS (`display: none`) but the rows are still in
307
+ // the DOM, so the query succeeds either way. Only expand once we've
308
+ // confirmed a row to mount against — otherwise an unmountable stop
309
+ // would leave the file expanded for no benefit.
310
+ //
311
+ // The validator accepts any stop whose [line_start, line_end] range
312
+ // intersects the changed-line set, so the EXACT line_start row may
313
+ // not be in the rendered diff (e.g. line_start is a context line that
314
+ // got folded). Scan forward through the range and anchor on the first
315
+ // row that exists.
316
+ let anchorRow = null;
317
+ for (let n = lineStart; n <= lineEnd; n++) {
318
+ const candidate = wrapper.querySelector(
319
+ `tr[data-line-number="${n}"][data-side="${side}"]`
320
+ );
321
+ if (candidate) {
322
+ anchorRow = candidate;
323
+ break;
324
+ }
325
+ }
326
+ if (!anchorRow) {
327
+ console.warn(
328
+ `[TourRenderer] no anchor row for ${filePath}:${lineStart}-${lineEnd} (${side}); ` +
329
+ 'stop will be skipped'
330
+ );
331
+ return null;
332
+ }
333
+
334
+ // We have an anchor — now safe to expand if the file was collapsed.
335
+ // Route through PRManager.toggleFileCollapse so the user-facing
336
+ // `collapsedFiles` set stays in sync with the DOM; refuse to expand
337
+ // (and bail) when the API is missing rather than strip the class
338
+ // directly, which would silently desync the two views of collapse
339
+ // state.
340
+ if (wrapper.classList.contains('collapsed')) {
341
+ if (this.prManager && typeof this.prManager.toggleFileCollapse === 'function') {
342
+ try {
343
+ // Await: toggleFileCollapse renders the lazy body and removes the
344
+ // `collapsed` class. Awaiting here means the row is built into a
345
+ // visible body and the caller's scrollToStop lands correctly.
346
+ await this.prManager.toggleFileCollapse(filePath);
347
+ // The await above is a suspension window. If the tour exited or
348
+ // restarted while it was in flight, `unmountAll` already ran
349
+ // against a snapshot that does NOT include this stop (the
350
+ // `_autoExpanded.add` / `_mounted.set` below hadn't run yet).
351
+ // Recording state now would orphan it forever — the file would
352
+ // never be re-collapsed and the annotation row never removed.
353
+ // Bail without mutating renderer state, matching prepareStop's
354
+ // stale-bail behavior. (The file is left expanded; that minor
355
+ // cosmetic residue is preferable to a corrupted `_autoExpanded`
356
+ // set that mis-collapses an unrelated file on the next exit.)
357
+ if (isStale()) return null;
358
+ // Record so unmountAll() can re-collapse on tour exit.
359
+ this._autoExpanded.add(filePath);
360
+ } catch (err) {
361
+ console.warn(
362
+ '[TourRenderer] toggleFileCollapse failed; skipping stop',
363
+ err
364
+ );
365
+ return null;
366
+ }
367
+ } else {
368
+ console.warn(
369
+ '[TourRenderer] prManager.toggleFileCollapse missing; ' +
370
+ 'refusing to strip collapsed class — stop skipped'
371
+ );
372
+ return null;
373
+ }
374
+ }
375
+
376
+ const row = this._buildAnnotationRow(index, stop, anchorRow);
377
+ anchorRow.parentNode.insertBefore(row, anchorRow);
378
+ this._mounted.set(index, row);
379
+
380
+ // Now that the row is in the live DOM, measure the description and
381
+ // append a "Show more" toggle if it actually overflows the clamp.
382
+ // Defer one frame (rAF when available, microtask in jsdom) so the
383
+ // browser has time to apply layout to the just-inserted node before
384
+ // we read `scrollHeight`. The helper is idempotent against re-mounts.
385
+ this._scheduleOverflowCheck(index);
386
+ return row;
387
+ }
388
+
389
+ /**
390
+ * Remove the mounted annotation for `index`. Returns true if a row was
391
+ * removed.
392
+ * @param {number} index
393
+ * @returns {boolean}
394
+ */
395
+ unmountStop(index) {
396
+ const row = this._mounted.get(index);
397
+ if (!row) return false;
398
+ if (row.isConnected) row.remove();
399
+ this._mounted.delete(index);
400
+ return true;
401
+ }
402
+
403
+ /**
404
+ * Remove every mounted annotation. Call on tour exit.
405
+ *
406
+ * Also restores pre-tour state:
407
+ * - Re-collapses any files in `_autoExpanded` (via toggleFileCollapse).
408
+ * - Deletes any context files in `_autoAddedContextFileIds` (via
409
+ * removeContextFile) so transient tour-injected files don't
410
+ * persist in the user's context-files list.
411
+ *
412
+ * Both restorations are best-effort — failures are logged and ignored
413
+ * so a partially-cleaned exit still tears down the tour UI.
414
+ *
415
+ * Returns a Promise that resolves once every issued `removeContextFile`
416
+ * call (and its `loadContextFiles` reload) has settled. Callers that
417
+ * need to observe a clean DOM before reading wrappers — restart, reopen
418
+ * — should await it. The promise never rejects (errors are caught and
419
+ * logged), so `await` is safe without try/catch.
420
+ *
421
+ * @returns {Promise<void>}
422
+ */
423
+ unmountAll() {
424
+ for (const row of this._mounted.values()) {
425
+ if (row && row.isConnected) row.remove();
426
+ }
427
+ this._mounted.clear();
428
+
429
+ if (
430
+ this._autoExpanded.size > 0 &&
431
+ this.prManager &&
432
+ typeof this.prManager.toggleFileCollapse === 'function'
433
+ ) {
434
+ for (const filePath of this._autoExpanded) {
435
+ try {
436
+ // Only re-collapse if the file is still expanded — the user may
437
+ // have toggled it manually during the tour, in which case we
438
+ // honor their explicit action and leave it alone.
439
+ const wrapper = document.querySelector(
440
+ `.d2h-file-wrapper[data-file-name="${tourRendererEscapeAttr(filePath)}"]`
441
+ );
442
+ if (wrapper && !wrapper.classList.contains('collapsed')) {
443
+ this.prManager.toggleFileCollapse(filePath);
444
+ }
445
+ } catch (err) {
446
+ console.warn('[TourRenderer] re-collapse failed for', filePath, err);
447
+ }
448
+ }
449
+ }
450
+ this._autoExpanded.clear();
451
+
452
+ const pending = [];
453
+ if (
454
+ this._autoAddedContextFileIds.size > 0 &&
455
+ this.prManager &&
456
+ typeof this.prManager.removeContextFile === 'function'
457
+ ) {
458
+ // Snapshot + clear before iterating so a re-entrant unmountAll
459
+ // (e.g. if removeContextFile triggers a re-render hook that loops
460
+ // back) doesn't try to delete the same ids twice.
461
+ const ids = Array.from(this._autoAddedContextFileIds);
462
+ this._autoAddedContextFileIds.clear();
463
+ for (const id of ids) {
464
+ try {
465
+ const result = this.prManager.removeContextFile(id);
466
+ if (result && typeof result.then === 'function') {
467
+ // Attach a catch so rejections don't leak as unhandled, and
468
+ // collect the wrapped promise so the caller's drain await
469
+ // observes settlement (success OR failure).
470
+ pending.push(result.catch((err) => {
471
+ console.warn('[TourRenderer] removeContextFile rejected for', id, err);
472
+ }));
473
+ }
474
+ } catch (err) {
475
+ console.warn('[TourRenderer] removeContextFile failed for', id, err);
476
+ }
477
+ }
478
+ }
479
+ // allSettled never rejects; safe to await without try/catch.
480
+ return Promise.allSettled(pending);
481
+ }
482
+
483
+ /**
484
+ * Smoothly scroll the mounted row for `index` into view, centering it.
485
+ * No-op if the row isn't mounted.
486
+ * @param {number} index
487
+ */
488
+ scrollToStop(index) {
489
+ const row = this._mounted.get(index);
490
+ if (!row || !row.isConnected) return;
491
+ row.scrollIntoView({
492
+ behavior: this._reduceMotion ? 'auto' : 'smooth',
493
+ block: 'center'
494
+ });
495
+ }
496
+
497
+ /**
498
+ * Move the `active-stop` class to the stop at `index`. Removes it from
499
+ * every other mounted row.
500
+ * @param {number} index
501
+ */
502
+ highlightActive(index) {
503
+ for (const [i, row] of this._mounted.entries()) {
504
+ if (!row) continue;
505
+ row.classList.toggle('active-stop', i === index);
506
+ }
507
+ }
508
+
509
+ // --- private ------------------------------------------------------------
510
+
511
+ _buildAnnotationRow(index, stop, anchorRow) {
512
+ const row = document.createElement('tr');
513
+ row.className = 'tour-annotation-row';
514
+ row.dataset.stopIndex = String(index);
515
+
516
+ const cell = document.createElement('td');
517
+ cell.colSpan = 2;
518
+ cell.className = 'tour-annotation-cell';
519
+
520
+ const annotation = document.createElement('div');
521
+ annotation.className = 'tour-annotation';
522
+
523
+ const header = document.createElement('div');
524
+ header.className = 'tour-annotation-header';
525
+
526
+ const marker = document.createElement('span');
527
+ marker.className = 'tour-stop-marker';
528
+ marker.innerHTML =
529
+ `<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">` +
530
+ `<path d="${TOUR_RENDERER_LOCATION_PATH}"/></svg>` +
531
+ `<span>Stop ${index + 1} of ${this._stops.length}</span>`;
532
+ header.appendChild(marker);
533
+
534
+ // Chat-about button lives in the header, pinned right of the marker.
535
+ // Icon-only — the title attribute / aria-label carry the meaning. The
536
+ // header CSS uses `justify-content: space-between` so the button hugs
537
+ // the right edge regardless of marker width. Mirrors the comment /
538
+ // suggestion pattern (.ai-action / .ai-action-chat) so the user can
539
+ // pivot from passive read to an interactive conversation about THIS
540
+ // stop.
541
+ const chatBtn = document.createElement('button');
542
+ chatBtn.type = 'button';
543
+ chatBtn.className = 'ai-action ai-action-chat tour-annotation-chat-btn';
544
+ chatBtn.title = 'Chat about this tour stop';
545
+ chatBtn.setAttribute('aria-label', 'Chat about this tour stop');
546
+ chatBtn.dataset.stopIndex = String(index);
547
+ chatBtn.innerHTML =
548
+ '<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">' +
549
+ '<path d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"/>' +
550
+ '</svg>';
551
+ chatBtn.addEventListener('click', (e) => {
552
+ e.stopPropagation();
553
+ this._openChatForStop(index);
554
+ });
555
+ header.appendChild(chatBtn);
556
+
557
+ const title = document.createElement('h4');
558
+ title.className = 'tour-annotation-title';
559
+ title.textContent = stop.title || '';
560
+
561
+ // The wrapper carries the CSS line-clamp so we can measure overflow
562
+ // (scrollHeight > clientHeight) on the SAME element that hosts the clamp.
563
+ // The inner <p> stays so existing selectors (.tour-annotation-description)
564
+ // — including tests and the e2e spec — keep working.
565
+ const descriptionWrap = document.createElement('div');
566
+ descriptionWrap.className = 'tour-annotation-description-wrap';
567
+ if (this._expandedDescriptions.has(index)) {
568
+ descriptionWrap.classList.add('expanded');
569
+ }
570
+
571
+ const description = document.createElement('p');
572
+ description.className = 'tour-annotation-description';
573
+ description.textContent = stop.description || '';
574
+ descriptionWrap.appendChild(description);
575
+
576
+ annotation.appendChild(header);
577
+ annotation.appendChild(title);
578
+ annotation.appendChild(descriptionWrap);
579
+
580
+ // Footer is reserved for the "Show more"/"Show less" toggle that the
581
+ // overflow check appends when the description is clamped. Empty when
582
+ // the description fits inline.
583
+ const footer = document.createElement('div');
584
+ footer.className = 'tour-annotation-footer';
585
+ annotation.appendChild(footer);
586
+
587
+ cell.appendChild(annotation);
588
+ row.appendChild(cell);
589
+
590
+ // Suppress the anchorRow parameter being marked unused by linters that
591
+ // care; it's only here for future enhancements (e.g. computing colspan
592
+ // from the anchor row's siblings).
593
+ void anchorRow;
594
+
595
+ return row;
596
+ }
597
+
598
+ /**
599
+ * Defer one frame, then evaluate description overflow for `index`. Real
600
+ * browsers need a layout pass before `scrollHeight` is meaningful on a
601
+ * just-inserted node; jsdom returns 0 either way, which is fine — there
602
+ * is no real overflow to detect.
603
+ *
604
+ * Uses `requestAnimationFrame` when present (real browsers), otherwise
605
+ * falls back to a 0ms timer (jsdom). Tests that want to force the
606
+ * overflow path can stub `scrollHeight` / `clientHeight` on the wrapper
607
+ * and call `_evaluateDescriptionOverflow(index)` directly without
608
+ * waiting for the timer.
609
+ *
610
+ * @param {number} index
611
+ */
612
+ _scheduleOverflowCheck(index) {
613
+ const run = () => this._evaluateDescriptionOverflow(index);
614
+ if (typeof requestAnimationFrame === 'function') {
615
+ requestAnimationFrame(run);
616
+ } else if (typeof setTimeout === 'function') {
617
+ setTimeout(run, 0);
618
+ } else {
619
+ run();
620
+ }
621
+ }
622
+
623
+ /**
624
+ * Synchronously read the description wrapper's overflow state and, if
625
+ * it overflows the line-clamp, append a "Show more" toggle to the stop's
626
+ * footer. Idempotent — calling it twice on the same row does NOT add
627
+ * two buttons.
628
+ *
629
+ * Overflow check: `scrollHeight > clientHeight + 1`. The 1px fudge
630
+ * dampens sub-pixel rounding noise on retina displays.
631
+ *
632
+ * Exported (as a regular method) so tests can stub scrollHeight /
633
+ * clientHeight on the wrapper before triggering the check, sidestepping
634
+ * jsdom's zero-layout default.
635
+ *
636
+ * @param {number} index
637
+ */
638
+ _evaluateDescriptionOverflow(index) {
639
+ const row = this._mounted.get(index);
640
+ if (!row || !row.isConnected) return;
641
+ const wrap = row.querySelector('.tour-annotation-description-wrap');
642
+ if (!wrap) return;
643
+ const footer = row.querySelector('.tour-annotation-footer');
644
+ if (!footer) return;
645
+ // Idempotency guard — re-running after a remount must not add a
646
+ // second button.
647
+ if (footer.querySelector('.tour-annotation-show-more-btn')) return;
648
+ // Don't show the toggle when the user already expanded this stop;
649
+ // the wrapper has no overflow to detect in that state.
650
+ if (this._expandedDescriptions.has(index)) {
651
+ this._appendShowMoreButton(index, row, /* expanded */ true);
652
+ return;
653
+ }
654
+ const overflows = wrap.scrollHeight > wrap.clientHeight + 1;
655
+ if (!overflows) return;
656
+ this._appendShowMoreButton(index, row, /* expanded */ false);
657
+ }
658
+
659
+ /**
660
+ * Build and insert the "Show more"/"Show less" button into the stop's
661
+ * footer. The Chat about button now lives in the header, so the footer
662
+ * is dedicated to the show-more toggle.
663
+ *
664
+ * @param {number} index
665
+ * @param {HTMLElement} row
666
+ * @param {boolean} expanded
667
+ */
668
+ _appendShowMoreButton(index, row, expanded) {
669
+ const footer = row.querySelector('.tour-annotation-footer');
670
+ if (!footer) return;
671
+ const wrap = row.querySelector('.tour-annotation-description-wrap');
672
+ if (!wrap) return;
673
+ const btn = document.createElement('button');
674
+ btn.type = 'button';
675
+ btn.className = 'tour-annotation-show-more-btn';
676
+ btn.dataset.stopIndex = String(index);
677
+ btn.textContent = expanded ? 'Show less' : 'Show more';
678
+ btn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
679
+ btn.addEventListener('click', (e) => {
680
+ e.stopPropagation();
681
+ this._toggleDescriptionExpansion(index);
682
+ });
683
+ footer.appendChild(btn);
684
+ }
685
+
686
+ /**
687
+ * Flip the expanded state for stop `index`: toggles the wrapper's
688
+ * `.expanded` class and the button label. Persists the index into
689
+ * `_expandedDescriptions` so a remount restores the expanded state.
690
+ *
691
+ * @param {number} index
692
+ */
693
+ _toggleDescriptionExpansion(index) {
694
+ const row = this._mounted.get(index);
695
+ if (!row) return;
696
+ const wrap = row.querySelector('.tour-annotation-description-wrap');
697
+ const btn = row.querySelector('.tour-annotation-show-more-btn');
698
+ if (!wrap || !btn) return;
699
+ const willExpand = !wrap.classList.contains('expanded');
700
+ wrap.classList.toggle('expanded', willExpand);
701
+ btn.textContent = willExpand ? 'Show less' : 'Show more';
702
+ btn.setAttribute('aria-expanded', willExpand ? 'true' : 'false');
703
+ if (willExpand) {
704
+ this._expandedDescriptions.add(index);
705
+ } else {
706
+ this._expandedDescriptions.delete(index);
707
+ }
708
+ }
709
+
710
+ /**
711
+ * Open the chat panel pre-focused on the given tour stop. The chat panel
712
+ * builds its own pending-context card; the renderer just supplies the stop
713
+ * metadata (title, description, file/line range, side) and the
714
+ * reviewId from the owning PRManager.
715
+ *
716
+ * No-op when the chat panel isn't mounted (e.g. unit tests without
717
+ * PanelGroup) or when the stop index is out of range. Stays defensive
718
+ * about missing fields — every callsite for chat-open in the codebase
719
+ * tolerates partial context.
720
+ *
721
+ * @param {number} index
722
+ */
723
+ _openChatForStop(index) {
724
+ const stop = this._stops[index];
725
+ if (!stop) return;
726
+ if (typeof window === 'undefined' || !window.chatPanel || typeof window.chatPanel.open !== 'function') {
727
+ return;
728
+ }
729
+ const lineStart = typeof stop.line_start === 'number' ? stop.line_start : null;
730
+ const lineEnd = (typeof stop.line_end === 'number' && stop.line_end >= (lineStart || 0))
731
+ ? stop.line_end
732
+ : lineStart;
733
+ window.chatPanel.open({
734
+ reviewId: this.prManager?.currentPR?.id,
735
+ tourContext: {
736
+ stopIndex: index,
737
+ totalStops: this._stops.length,
738
+ title: stop.title || '',
739
+ description: stop.description || '',
740
+ file: stop.file_path || '',
741
+ line_start: lineStart,
742
+ line_end: lineEnd,
743
+ side: stop.side || 'RIGHT'
744
+ }
745
+ });
746
+ }
747
+ }
748
+
749
+ if (typeof window !== 'undefined') {
750
+ window.TourRenderer = TourRenderer;
751
+ }
752
+
753
+ if (typeof module !== 'undefined' && module.exports) {
754
+ module.exports = { TourRenderer };
755
+ }