@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.
- package/README.md +4 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/analysis-config.css +1807 -0
- package/public/css/pr.css +1029 -2169
- package/public/index.html +11 -0
- package/public/js/components/AIPanel.js +39 -23
- package/public/js/components/AdvancedConfigTab.js +56 -4
- package/public/js/components/AnalysisConfigModal.js +41 -25
- package/public/js/components/ChatPanel.js +163 -3
- package/public/js/components/KeyboardShortcuts.js +10 -26
- package/public/js/components/ReviewModal.js +135 -13
- package/public/js/components/TourBar.js +248 -0
- package/public/js/components/VoiceCentricConfigTab.js +36 -0
- package/public/js/index.js +175 -16
- package/public/js/local.js +64 -8
- package/public/js/modules/cancel-background-job.js +183 -0
- package/public/js/modules/hunk-summary-renderer.js +116 -0
- package/public/js/modules/storage-cleanup.js +16 -0
- package/public/js/modules/suggestion-manager.js +25 -1
- package/public/js/modules/tour-renderer.js +755 -0
- package/public/js/pr.js +1826 -56
- package/public/js/repo-links.js +328 -0
- package/public/js/utils/modal-detection.js +77 -0
- package/public/js/utils/provider-model.js +88 -0
- package/public/js/utils/storage-keys.js +50 -0
- package/public/local.html +24 -0
- package/public/pr.html +24 -0
- package/public/repo-settings.html +1 -0
- package/public/setup.html +2 -0
- package/src/ai/abort-signal-wiring.js +130 -0
- package/src/ai/analyzer.js +125 -18
- package/src/ai/background-queue.js +290 -0
- package/src/ai/claude-cli.js +1 -1
- package/src/ai/claude-provider.js +50 -7
- package/src/ai/codex-provider.js +28 -5
- package/src/ai/copilot-provider.js +22 -3
- package/src/ai/cursor-agent-provider.js +22 -6
- package/src/ai/executable-provider.js +4 -19
- package/src/ai/gemini-provider.js +22 -5
- package/src/ai/hunk-hashing.js +161 -0
- package/src/ai/index.js +2 -0
- package/src/ai/opencode-provider.js +21 -5
- package/src/ai/pi-provider.js +21 -5
- package/src/ai/prompts/hunk-summary.js +199 -0
- package/src/ai/prompts/tour.js +232 -0
- package/src/ai/provider.js +21 -1
- package/src/ai/summary-generator.js +469 -0
- package/src/ai/tour-generator.js +568 -0
- package/src/config.js +778 -10
- package/src/database.js +282 -1
- package/src/external/github-adapter.js +114 -25
- package/src/git/base-branch.js +11 -4
- package/src/github/client.js +482 -588
- package/src/github/errors.js +55 -0
- package/src/github/impl/graphql/pending-review-comments.js +230 -0
- package/src/github/impl/graphql/pending-review.js +153 -0
- package/src/github/impl/graphql/review-lifecycle.js +161 -0
- package/src/github/impl/graphql/stack-walker.js +210 -0
- package/src/github/impl/host/pending-review-comments.js +338 -0
- package/src/github/impl/rest/pending-review.js +251 -0
- package/src/github/impl/rest/review-lifecycle.js +226 -0
- package/src/github/impl/rest/stack-walker.js +309 -0
- package/src/github/operations/pending-review-comments.js +79 -0
- package/src/github/operations/pending-review.js +89 -0
- package/src/github/operations/review-lifecycle.js +126 -0
- package/src/github/operations/stack-walker.js +87 -0
- package/src/github/parser.js +230 -4
- package/src/github/stack-walker.js +14 -189
- package/src/links/repo-links.js +230 -0
- package/src/local-review.js +201 -172
- package/src/main.js +133 -30
- package/src/routes/analyses.js +30 -7
- package/src/routes/bulk-analysis-configs.js +295 -0
- package/src/routes/config.js +118 -3
- package/src/routes/context-files.js +2 -29
- package/src/routes/external-comments.js +20 -10
- package/src/routes/github-collections.js +3 -1
- package/src/routes/local.js +410 -13
- package/src/routes/mcp.js +47 -4
- package/src/routes/middleware/validate-review-id.js +53 -0
- package/src/routes/pr.js +556 -71
- package/src/routes/reviews.js +145 -29
- package/src/routes/setup.js +8 -3
- package/src/routes/stack-analysis.js +33 -9
- package/src/routes/worktrees.js +3 -2
- package/src/server.js +2 -0
- package/src/setup/pr-setup.js +37 -11
- package/src/setup/stack-setup.js +13 -3
- package/src/single-port.js +6 -3
- package/src/utils/diff-hunks.js +65 -0
- 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
|
+
}
|