@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
package/public/js/pr.js
CHANGED
|
@@ -80,18 +80,17 @@ class PRManager {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
|
-
* Generate a safe localStorage key for repository-specific settings
|
|
84
|
-
*
|
|
83
|
+
* Generate a safe localStorage key for repository-specific settings.
|
|
84
|
+
* Delegates to the shared helper in public/js/utils/storage-keys.js
|
|
85
|
+
* (loaded before pr.js) so keys stay byte-identical with the index/bulk page,
|
|
86
|
+
* which writes the same per-repo keys this page reads.
|
|
85
87
|
* @param {string} prefix - Key prefix (e.g., 'pair-review-model')
|
|
86
88
|
* @param {string} owner - Repository owner
|
|
87
89
|
* @param {string} repo - Repository name
|
|
88
90
|
* @returns {string} Safe localStorage key
|
|
89
91
|
*/
|
|
90
92
|
static getRepoStorageKey(prefix, owner, repo) {
|
|
91
|
-
|
|
92
|
-
// btoa() only accepts Latin1, so we encode Unicode first
|
|
93
|
-
const repoId = btoa(unescape(encodeURIComponent(`${owner}/${repo}`))).replace(/=/g, '');
|
|
94
|
-
return `${prefix}:${repoId}`;
|
|
93
|
+
return window.getRepoStorageKey(prefix, owner, repo);
|
|
95
94
|
}
|
|
96
95
|
|
|
97
96
|
constructor() {
|
|
@@ -138,6 +137,126 @@ class PRManager {
|
|
|
138
137
|
this.diffOptionsDropdown = null;
|
|
139
138
|
// Comment minimizer — manages minimize mode indicators
|
|
140
139
|
this.commentMinimizer = window.CommentMinimizer ? new window.CommentMinimizer() : null;
|
|
140
|
+
// Hunk summary renderer (Phase 5) — inline natural-language summaries
|
|
141
|
+
this.hunkSummaryRenderer = window.HunkSummaryRenderer ? new window.HunkSummaryRenderer(this) : null;
|
|
142
|
+
// Per-render anchor map, file-scoped so a content-hash shared across two
|
|
143
|
+
// files (renamed-with-tiny-edits, copy-pasted boilerplate, identical
|
|
144
|
+
// stubs) can't let the later file's anchor overwrite the earlier one's:
|
|
145
|
+
// Map<filePath, Map<contentHash, anchorRow>>
|
|
146
|
+
// where anchorRow is the first code-line <tr> of the hunk. Symmetric with
|
|
147
|
+
// `_pendingSummariesByHash` so both sides of the queue/anchor handshake
|
|
148
|
+
// key on (filePath, hash).
|
|
149
|
+
this._summaryAnchorsByHash = new Map();
|
|
150
|
+
// Per-render file map: filePath -> Set<contentHash>
|
|
151
|
+
this._summaryHashesByFile = new Map();
|
|
152
|
+
// Summaries that arrived (via WS or fetch) before their hunk had been
|
|
153
|
+
// hashed, scoped by file path so a content-hash collision across files
|
|
154
|
+
// can't let one file's anchor consume another file's queued summary:
|
|
155
|
+
// Map<filePath, Map<contentHash, summary>>
|
|
156
|
+
// The '' bucket holds summaries queued without a file path (the legacy
|
|
157
|
+
// ungrouped-fetch fallback in _fetchHunkSummaryMap).
|
|
158
|
+
this._pendingSummariesByHash = new Map();
|
|
159
|
+
// Per-file summary visibility (persisted in localStorage per-review)
|
|
160
|
+
this.summariesHiddenFiles = new Set();
|
|
161
|
+
// Render-generation token — incremented at the top of renderDiff() so any
|
|
162
|
+
// fire-and-forget _fetchHunkSummaryMap() from a prior render can detect
|
|
163
|
+
// it's stale and bail (refresh / whitespace toggle / scope change race).
|
|
164
|
+
this._renderGen = 0;
|
|
165
|
+
// ---- Lazy diff-body rendering (perf for very large PRs) -----------
|
|
166
|
+
// Each file's wrapper + header render eagerly, but the <tbody> of diff
|
|
167
|
+
// lines is built on demand: when the body scrolls near the viewport
|
|
168
|
+
// (IntersectionObserver), when the file is expanded, or when a code path
|
|
169
|
+
// needs to anchor into it (see ensureFileBodyRendered). Collapsed bodies
|
|
170
|
+
// are `display:none`, so they never intersect and stay unrendered until
|
|
171
|
+
// expanded. Key = file path; value = lazy entry (see renderFileDiff).
|
|
172
|
+
this._lazyFileBodies = new Map();
|
|
173
|
+
// The IntersectionObserver watching `.d2h-file-body` elements for the
|
|
174
|
+
// current render generation. Recreated on every renderDiff().
|
|
175
|
+
this._fileBodyObserver = null;
|
|
176
|
+
// Cached /api/config response (lazy-loaded)
|
|
177
|
+
this._appConfigPromise = null;
|
|
178
|
+
// Review-level summary visibility (persisted in localStorage per-review)
|
|
179
|
+
this._summariesHidden = false;
|
|
180
|
+
// Whether the background summary job is currently running for this review.
|
|
181
|
+
// Cleared by the `review:background_job_finished` event for jobType=summaries.
|
|
182
|
+
this._summariesGenerating = false;
|
|
183
|
+
// Whether any hunk summaries exist for this review (loaded from the server
|
|
184
|
+
// or mounted live during generation). Gates the toolbar button's `.active`
|
|
185
|
+
// (blue) state: before any summary exists the button stays colorless so the
|
|
186
|
+
// user can tell nothing has been generated yet. Set true by
|
|
187
|
+
// `_applyHunkSummaries` and the initial existing-summaries fetch.
|
|
188
|
+
this._summariesGenerated = false;
|
|
189
|
+
// Whether any non-trivial hunk summary EXISTS for this review — mounted
|
|
190
|
+
// OR merely queued because its lazy file body hasn't rendered yet. This is
|
|
191
|
+
// deliberately separate from `_summariesGenerated` (which gates the
|
|
192
|
+
// `.active` blue styling and tracks summaries actually in the DOM): with
|
|
193
|
+
// lazy bodies a valid summary can arrive before its anchor exists, and the
|
|
194
|
+
// toolbar must still treat the feature as "has data" so a click toggles
|
|
195
|
+
// visibility instead of dispatching a duplicate generation job. Set by
|
|
196
|
+
// `_applyHunkSummaries` / `_fetchHunkSummaryMap`; reset in renderDiff.
|
|
197
|
+
this._summariesAvailable = false;
|
|
198
|
+
// Tri-state: true when /api/config reports summaries.enabled, false when
|
|
199
|
+
// disabled, null until /api/config resolves. Per-file toggle buttons are
|
|
200
|
+
// gated on this so users on disabled deployments don't see them flicker
|
|
201
|
+
// in.
|
|
202
|
+
this._summariesEnabled = null;
|
|
203
|
+
// Tri-state mirror of `summaries.auto_generate` in /api/config. When
|
|
204
|
+
// false, the click handler hits the manual-start endpoint instead of
|
|
205
|
+
// expecting a server-initiated kickoff. Null until /api/config resolves.
|
|
206
|
+
this._summariesAutoGenerate = null;
|
|
207
|
+
// ---- Tour state (Phase 8) -----------------------------------------
|
|
208
|
+
// Lazy-instantiated TourBar / TourRenderer; populated on first open.
|
|
209
|
+
this._tourBar = null;
|
|
210
|
+
this._tourRenderer = null;
|
|
211
|
+
// Tri-state mirror of `tours.enabled` in /api/config. Tours are
|
|
212
|
+
// independent of summaries on both the server and the client (see
|
|
213
|
+
// the explanatory comment in setupEventHandlers()).
|
|
214
|
+
this._toursEnabled = null;
|
|
215
|
+
// Tri-state mirror of `tours.auto_generate` in /api/config. When false,
|
|
216
|
+
// the click handler hits the manual-start endpoint instead of expecting
|
|
217
|
+
// a server-initiated kickoff. Null until /api/config resolves.
|
|
218
|
+
this._toursAutoGenerate = null;
|
|
219
|
+
// Cached stops from the most recent /api/reviews/:id/tour fetch, or null
|
|
220
|
+
// when nothing has been loaded for this review yet.
|
|
221
|
+
this._tourStops = null;
|
|
222
|
+
// 0-based index of the current stop while a tour is open; -1 when no
|
|
223
|
+
// tour is mounted.
|
|
224
|
+
this._tourActiveIndex = -1;
|
|
225
|
+
// Whether the background tour-generation job is currently running; drives
|
|
226
|
+
// the pulse on the toolbar button.
|
|
227
|
+
this._tourGenerating = false;
|
|
228
|
+
// When a `review:tour_ready` event fires while a tour is already mounted,
|
|
229
|
+
// we stash the new stops here rather than yank the current tour out from
|
|
230
|
+
// under the user. The pending stops are applied on the next exit or
|
|
231
|
+
// restart. Cleared once consumed.
|
|
232
|
+
this._tourStopsPendingRestart = null;
|
|
233
|
+
// Bound keydown handler; tracked so it can be removed on tour exit.
|
|
234
|
+
this._tourKeydownHandler = null;
|
|
235
|
+
// Re-entry guard for `_promptCancelJob`. The cancel-confirm dialog
|
|
236
|
+
// opens off the same pulsing toolbar button that triggered it, and
|
|
237
|
+
// the button stays clickable while the dialog is up — `ConfirmDialog`
|
|
238
|
+
// is a singleton, so a second click would overwrite the first
|
|
239
|
+
// invocation's callbacks and leave its Promise dangling. Held true
|
|
240
|
+
// for the lifetime of the dialog; cleared in a `finally`.
|
|
241
|
+
this._cancelPromptOpen = false;
|
|
242
|
+
// Re-entrance latch for the async `_advanceTour` probe loop. Holds the
|
|
243
|
+
// `_tourGen` value the in-flight call belongs to (or -1 when no call
|
|
244
|
+
// is in flight). Generation-scoped — not a plain boolean — so a
|
|
245
|
+
// teardown that bumps `_tourGen` auto-invalidates the holder and the
|
|
246
|
+
// next reopen passes the latch check without any teardown path having
|
|
247
|
+
// to remember to reset the flag explicitly.
|
|
248
|
+
this._advanceInFlightGen = -1;
|
|
249
|
+
// Tour-open generation. Bumped on every open and exit so an in-flight
|
|
250
|
+
// async `_advanceTour` can detect that the tour it started navigating
|
|
251
|
+
// has since been torn down (Escape, exit button, toolbar toggle) and
|
|
252
|
+
// bail instead of mutating a dead tour's state.
|
|
253
|
+
this._tourGen = 0;
|
|
254
|
+
// Drain promise stashed by `_exitTour`. Resolves once every async
|
|
255
|
+
// teardown step from the prior tour (fire-and-forget context-file
|
|
256
|
+
// DELETEs + their loadContextFiles reloads) has settled. The next
|
|
257
|
+
// open awaits this before reading wrappers so a stale DELETE can't
|
|
258
|
+
// rip the newly-mounted tour's wrapper out from under it.
|
|
259
|
+
this._tourCleanupPending = null;
|
|
141
260
|
// Cached staleness check promise — shared between on-load and triggerAIAnalysis
|
|
142
261
|
this._stalenessPromise = null;
|
|
143
262
|
// Unique client ID for self-echo suppression on WebSocket review events.
|
|
@@ -320,6 +439,67 @@ class PRManager {
|
|
|
320
439
|
}
|
|
321
440
|
}
|
|
322
441
|
|
|
442
|
+
// Hunk summary toolbar toggle (Phase 5).
|
|
443
|
+
// Hidden by default in HTML; revealed asynchronously when /api/config
|
|
444
|
+
// reports summaries.enabled. The same config check also controls the
|
|
445
|
+
// per-file `.file-header-summary-toggle` buttons (which are created
|
|
446
|
+
// hidden by createFileHeader and revealed/removed once config resolves).
|
|
447
|
+
const summaryToggle = document.getElementById('summary-toggle-btn');
|
|
448
|
+
if (summaryToggle) {
|
|
449
|
+
summaryToggle.addEventListener('click', () => this._handleSummaryToggleClick());
|
|
450
|
+
}
|
|
451
|
+
this._getAppConfig().then((cfg) => {
|
|
452
|
+
const summariesCfg = (cfg && cfg.summaries) || {};
|
|
453
|
+
this._summariesEnabled = summariesCfg.enabled === true;
|
|
454
|
+
this._summariesAutoGenerate = summariesCfg.auto_generate !== false;
|
|
455
|
+
if (this._summariesEnabled) {
|
|
456
|
+
if (summaryToggle) {
|
|
457
|
+
summaryToggle.style.display = '';
|
|
458
|
+
this._syncSummaryToolbarButton();
|
|
459
|
+
}
|
|
460
|
+
document
|
|
461
|
+
.querySelectorAll('.file-header-summary-toggle.summary-toggle-pending')
|
|
462
|
+
.forEach((btn) => {
|
|
463
|
+
btn.classList.remove('summary-toggle-pending');
|
|
464
|
+
btn.style.display = '';
|
|
465
|
+
});
|
|
466
|
+
} else {
|
|
467
|
+
document
|
|
468
|
+
.querySelectorAll('.file-header-summary-toggle')
|
|
469
|
+
.forEach((btn) => btn.remove());
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// Tour toolbar toggle (Phase 8). Hidden by default in HTML; revealed
|
|
474
|
+
// asynchronously once /api/config confirms `tours.enabled` is on.
|
|
475
|
+
// Tours are independent of `summaries.enabled` — both server- and
|
|
476
|
+
// client-side gates check only `tours.enabled`.
|
|
477
|
+
const tourToggle = document.getElementById('tour-toggle-btn');
|
|
478
|
+
if (tourToggle) {
|
|
479
|
+
tourToggle.addEventListener('click', () => this._handleTourToggleClick());
|
|
480
|
+
}
|
|
481
|
+
this._getAppConfig().then((cfg) => {
|
|
482
|
+
const toursCfg = (cfg && cfg.tours) || {};
|
|
483
|
+
this._toursEnabled = toursCfg.enabled === true;
|
|
484
|
+
this._toursAutoGenerate = toursCfg.auto_generate !== false;
|
|
485
|
+
if (this._toursEnabled && tourToggle) {
|
|
486
|
+
tourToggle.style.display = '';
|
|
487
|
+
this._syncTourToolbarButton();
|
|
488
|
+
}
|
|
489
|
+
// NOTE: do NOT probe /api/reviews/:id/tour here when no diff has
|
|
490
|
+
// rendered yet — `currentPR.id` is not populated until
|
|
491
|
+
// init()/LocalManager loads the review. The probe is normally
|
|
492
|
+
// deferred to renderDiff() (which fires after currentPR is set).
|
|
493
|
+
//
|
|
494
|
+
// RACE: if renderDiff() has ALREADY run by the time /api/config
|
|
495
|
+
// resolves, its `_toursEnabled === true` check failed (was still
|
|
496
|
+
// null) and the probe was skipped. Catch that case here so the
|
|
497
|
+
// tour toolbar still surfaces a generated tour.
|
|
498
|
+
if (this._toursEnabled && this._renderGen > 0) {
|
|
499
|
+
this._loadAndStashTour({ cancelOnRender: false }).catch(() => {});
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
|
|
323
503
|
// PR description popover
|
|
324
504
|
this.setupPRDescriptionPopover();
|
|
325
505
|
|
|
@@ -438,7 +618,7 @@ class PRManager {
|
|
|
438
618
|
* @param {Object} reviewSettings - Review settings from fetchLastReviewSettings()
|
|
439
619
|
* @returns {Promise<Object>} Config object suitable for startAnalysis / startLocalAnalysis
|
|
440
620
|
*/
|
|
441
|
-
async _buildDefaultAnalysisConfig(repoSettings, reviewSettings) {
|
|
621
|
+
async _buildDefaultAnalysisConfig(repoSettings, reviewSettings, appConfig = {}, providersInfo = null) {
|
|
442
622
|
const defaultTab = repoSettings?.default_tab || 'single';
|
|
443
623
|
const councilId = repoSettings?.default_council_id || reviewSettings?.last_council_id || null;
|
|
444
624
|
|
|
@@ -467,13 +647,44 @@ class PRManager {
|
|
|
467
647
|
};
|
|
468
648
|
}
|
|
469
649
|
|
|
650
|
+
// Resolve provider and model as a MATCHED pair. Resolving each half
|
|
651
|
+
// independently (repo || app || hardcoded) can mix a provider from one
|
|
652
|
+
// scope with a model from another, yielding an invalid pair (e.g.
|
|
653
|
+
// gemini/opus) that startAnalysis would forward to the backend as-is.
|
|
654
|
+
const providers = providersInfo || await this._getProvidersInfo();
|
|
655
|
+
const { provider, model } = window.resolveProviderModelPair([
|
|
656
|
+
{ provider: repoSettings?.default_provider, model: repoSettings?.default_model },
|
|
657
|
+
{ provider: appConfig.default_provider, model: appConfig.default_model }
|
|
658
|
+
], providers);
|
|
659
|
+
|
|
470
660
|
return {
|
|
471
|
-
provider
|
|
472
|
-
model
|
|
661
|
+
provider,
|
|
662
|
+
model,
|
|
473
663
|
customInstructions: null
|
|
474
664
|
};
|
|
475
665
|
}
|
|
476
666
|
|
|
667
|
+
async _fetchAutoAnalysisConfigFromUrl() {
|
|
668
|
+
const params = new URLSearchParams(window.location.search);
|
|
669
|
+
const configId = params.get('analysisConfigId');
|
|
670
|
+
if (!configId) return { requested: false, config: null, error: null };
|
|
671
|
+
|
|
672
|
+
try {
|
|
673
|
+
const response = await fetch(`/api/bulk-analysis-configs/${encodeURIComponent(configId)}`);
|
|
674
|
+
if (!response.ok) {
|
|
675
|
+
throw new Error('Stored analysis settings were not found');
|
|
676
|
+
}
|
|
677
|
+
const data = await response.json();
|
|
678
|
+
if (!data.analysisConfig) {
|
|
679
|
+
throw new Error('Stored analysis settings response was empty');
|
|
680
|
+
}
|
|
681
|
+
return { requested: true, config: data.analysisConfig, error: null };
|
|
682
|
+
} catch (error) {
|
|
683
|
+
console.warn('Failed to fetch bulk analysis config:', error);
|
|
684
|
+
return { requested: true, config: null, error };
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
477
688
|
/**
|
|
478
689
|
* Auto-trigger analysis if ?analyze=true is present in the URL.
|
|
479
690
|
* Skips refresh if data was just loaded fresh by loadPR (to avoid redundant fetches).
|
|
@@ -488,6 +699,7 @@ class PRManager {
|
|
|
488
699
|
const autoAnalyze = new URLSearchParams(window.location.search).get('analyze');
|
|
489
700
|
if (autoAnalyze === 'true' && !this.isAnalyzing) {
|
|
490
701
|
this._autoAnalyzeRequested = true;
|
|
702
|
+
let shouldCleanUrl = true;
|
|
491
703
|
try {
|
|
492
704
|
// Skip refresh if we just loaded fresh data (loadPR sets _justLoaded = true).
|
|
493
705
|
// Otherwise, refresh to ensure we have the latest PR data in case the worktree
|
|
@@ -504,19 +716,39 @@ class PRManager {
|
|
|
504
716
|
}
|
|
505
717
|
}
|
|
506
718
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
719
|
+
const storedConfig = await this._fetchAutoAnalysisConfigFromUrl();
|
|
720
|
+
let config;
|
|
721
|
+
if (storedConfig.requested) {
|
|
722
|
+
if (!storedConfig.config) {
|
|
723
|
+
// The stored bulk-analysis config expired (TTL/eviction/restart).
|
|
724
|
+
// The PR diff has already rendered, so don't replace it with a
|
|
725
|
+
// full-screen error whose Retry button would just re-trigger the
|
|
726
|
+
// same failed lookup. Warn, strip the stale params (so a refresh
|
|
727
|
+
// won't re-trigger), and leave the PR usable for manual analysis.
|
|
728
|
+
const message = 'Could not load the selected bulk analysis settings. Start analysis manually to choose new settings.';
|
|
729
|
+
if (window.toast) window.toast.showWarning(message);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
config = storedConfig.config;
|
|
733
|
+
} else {
|
|
734
|
+
// Fetch repo settings so we honour the repository's default provider/council
|
|
735
|
+
const [repoSettings, reviewSettings, appConfig] = await Promise.all([
|
|
736
|
+
this.fetchRepoSettings().catch(() => null),
|
|
737
|
+
this.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null })),
|
|
738
|
+
this._getAppConfig()
|
|
739
|
+
]);
|
|
740
|
+
config = await this._buildDefaultAnalysisConfig(repoSettings, reviewSettings, appConfig);
|
|
741
|
+
}
|
|
513
742
|
|
|
514
743
|
await this.startAnalysis(owner, repo, prNumber, null, config);
|
|
515
744
|
} finally {
|
|
516
745
|
this._autoAnalyzeRequested = false;
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
746
|
+
if (shouldCleanUrl) {
|
|
747
|
+
const cleanUrl = new URL(window.location);
|
|
748
|
+
cleanUrl.searchParams.delete('analyze');
|
|
749
|
+
cleanUrl.searchParams.delete('analysisConfigId');
|
|
750
|
+
history.replaceState(null, '', cleanUrl);
|
|
751
|
+
}
|
|
520
752
|
}
|
|
521
753
|
}
|
|
522
754
|
}
|
|
@@ -903,6 +1135,1103 @@ class PRManager {
|
|
|
903
1135
|
return this._rerenderAllOverlays();
|
|
904
1136
|
}
|
|
905
1137
|
|
|
1138
|
+
/**
|
|
1139
|
+
* Resolve the cached `/api/config` payload, fetching it on first use.
|
|
1140
|
+
* @returns {Promise<Object>}
|
|
1141
|
+
*/
|
|
1142
|
+
_getAppConfig() {
|
|
1143
|
+
if (!this._appConfigPromise) {
|
|
1144
|
+
this._appConfigPromise = fetch('/api/config')
|
|
1145
|
+
.then((r) => (r.ok ? r.json() : {}))
|
|
1146
|
+
.catch(() => ({}));
|
|
1147
|
+
}
|
|
1148
|
+
return this._appConfigPromise;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Fetch and cache the provider/model metadata from /api/providers. Used to
|
|
1153
|
+
* resolve a coherent provider/model pair for the non-modal auto-analyze path
|
|
1154
|
+
* (the modal loads its own copy). Resolves to the `providers` array, or [] on
|
|
1155
|
+
* failure so callers can fall back to provider-agnostic defaults.
|
|
1156
|
+
*/
|
|
1157
|
+
_getProvidersInfo() {
|
|
1158
|
+
if (!this._providersInfoPromise) {
|
|
1159
|
+
this._providersInfoPromise = fetch('/api/providers')
|
|
1160
|
+
.then((r) => (r.ok ? r.json() : {}))
|
|
1161
|
+
.then((data) => (Array.isArray(data.providers) ? data.providers : []))
|
|
1162
|
+
.catch(() => []);
|
|
1163
|
+
}
|
|
1164
|
+
return this._providersInfoPromise;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Wire one file's hunk anchor rows to their server-supplied content hashes
|
|
1169
|
+
* as that file's body renders (called from _renderFileBodyNow), then mount
|
|
1170
|
+
* any summary that already arrived for those hunks.
|
|
1171
|
+
*
|
|
1172
|
+
* Pre-lazy-render this happened once globally in _kickOffHunkSummaries;
|
|
1173
|
+
* with lazy bodies a file's anchor rows don't exist until it renders, so
|
|
1174
|
+
* anchoring is now incremental. The server computes hashes from the
|
|
1175
|
+
* canonical (non-whitespace-filtered) diff so they stay aligned with
|
|
1176
|
+
* persisted summary keys regardless of `?w=1`; records lacking a
|
|
1177
|
+
* `contentHash` are logged-and-skipped.
|
|
1178
|
+
*
|
|
1179
|
+
* The bridge for "summary arrived before its anchor existed" is the existing
|
|
1180
|
+
* `_pendingSummariesByHash` map: _renderOneSummary / _applyHunkSummaries
|
|
1181
|
+
* queue there when no anchor is found, and we drain matching entries here.
|
|
1182
|
+
* @param {Array<{file,header,anchorRow,contentHash}>} records
|
|
1183
|
+
*/
|
|
1184
|
+
_registerHunkAnchorsForFile(records) {
|
|
1185
|
+
if (!Array.isArray(records) || records.length === 0) return;
|
|
1186
|
+
const filesWithMounts = new Set();
|
|
1187
|
+
for (const rec of records) {
|
|
1188
|
+
if (!rec.anchorRow || !rec.anchorRow.isConnected) continue;
|
|
1189
|
+
const hex = rec.contentHash;
|
|
1190
|
+
if (!hex) {
|
|
1191
|
+
console.warn(
|
|
1192
|
+
`[HunkSummary] no server contentHash for ${rec.file} ` +
|
|
1193
|
+
`hunk ${rec.header}; skipping (summaries will not anchor here).`
|
|
1194
|
+
);
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1197
|
+
rec.anchorRow.dataset.hunkStart = hex;
|
|
1198
|
+
// Scope the anchor by file so a hash collision across files can't let
|
|
1199
|
+
// the later file's anchor clobber the earlier file's (which would mount
|
|
1200
|
+
// a summary against the wrong hunk). Symmetric with the file-scoped
|
|
1201
|
+
// pending-summary queue.
|
|
1202
|
+
let anchors = this._summaryAnchorsByHash.get(rec.file);
|
|
1203
|
+
if (!anchors) {
|
|
1204
|
+
anchors = new Map();
|
|
1205
|
+
this._summaryAnchorsByHash.set(rec.file, anchors);
|
|
1206
|
+
}
|
|
1207
|
+
anchors.set(hex, rec.anchorRow);
|
|
1208
|
+
let bucket = this._summaryHashesByFile.get(rec.file);
|
|
1209
|
+
if (!bucket) {
|
|
1210
|
+
bucket = new Set();
|
|
1211
|
+
this._summaryHashesByFile.set(rec.file, bucket);
|
|
1212
|
+
}
|
|
1213
|
+
bucket.add(hex);
|
|
1214
|
+
|
|
1215
|
+
// Mount a summary that arrived before this anchor existed. Look it up by
|
|
1216
|
+
// THIS file + hash so a content-hash that also appears in another file
|
|
1217
|
+
// (renamed-with-tiny-edits, copy-pasted boilerplate, identical stubs)
|
|
1218
|
+
// can't let this file's anchor consume the other file's queued summary.
|
|
1219
|
+
const pending = this._takePendingSummary(rec.file, hex);
|
|
1220
|
+
if (pending) {
|
|
1221
|
+
const row = this._renderOneSummary(pending, rec.file);
|
|
1222
|
+
if (row) {
|
|
1223
|
+
this._summariesGenerated = true;
|
|
1224
|
+
this._summariesAvailable = true;
|
|
1225
|
+
filesWithMounts.add(rec.file);
|
|
1226
|
+
}
|
|
1227
|
+
// If it couldn't mount (anchor not connected after all),
|
|
1228
|
+
// _renderOneSummary re-queued it scoped to rec.file for a later pass.
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
if (this._summariesGenerated) this._syncSummaryToolbarButton();
|
|
1232
|
+
for (const filePath of filesWithMounts) this._refreshFileSummaryToggle(filePath);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* Load the review's hunk summaries from the server and apply them to the
|
|
1237
|
+
* anchors that exist so far (gated by `summaries.enabled` in `/api/config`).
|
|
1238
|
+
*
|
|
1239
|
+
* With lazy bodies, anchor wiring is incremental (_registerHunkAnchorsForFile
|
|
1240
|
+
* runs as each body renders), so this method no longer walks render records.
|
|
1241
|
+
* Summaries whose file hasn't rendered yet are queued in
|
|
1242
|
+
* `_pendingSummariesByHash` (via _applyHunkSummaries / _renderOneSummary) and
|
|
1243
|
+
* drained when that body renders.
|
|
1244
|
+
*
|
|
1245
|
+
* Order:
|
|
1246
|
+
* 1. Config gate first — bail before paying any cost when the feature is off.
|
|
1247
|
+
* 2. Restore localStorage visibility state.
|
|
1248
|
+
* 3. Fetch existing summaries and apply/queue them.
|
|
1249
|
+
*
|
|
1250
|
+
* Race-safety: `_renderGen` is captured at entry and rechecked after every
|
|
1251
|
+
* `await`. If `renderDiff()` ran again mid-flight, we stop touching the (now
|
|
1252
|
+
* stale) maps and DOM.
|
|
1253
|
+
* @returns {Promise<void>}
|
|
1254
|
+
*/
|
|
1255
|
+
async _fetchHunkSummaryMap() {
|
|
1256
|
+
const gen = this._renderGen;
|
|
1257
|
+
|
|
1258
|
+
// 1. Config gate — bail before doing any work when the feature is off.
|
|
1259
|
+
const cfg = await this._getAppConfig();
|
|
1260
|
+
if (gen !== this._renderGen) return;
|
|
1261
|
+
if (!(cfg.summaries && cfg.summaries.enabled)) return;
|
|
1262
|
+
|
|
1263
|
+
// 2. Restore localStorage visibility state.
|
|
1264
|
+
if (this.currentPR?.id) {
|
|
1265
|
+
const hidden = window.localStorage.getItem(`pair-review:summaries-hidden:${this.currentPR.id}`) === '1';
|
|
1266
|
+
this._summariesHidden = hidden;
|
|
1267
|
+
document.body.classList.toggle('summaries-hidden', hidden);
|
|
1268
|
+
this._syncSummaryToolbarButton();
|
|
1269
|
+
this._restoreSummariesHiddenFiles();
|
|
1270
|
+
// Apply per-file hidden state to wrappers already in the DOM, syncing
|
|
1271
|
+
// both the wrapper class AND the toggle button so its visible state and
|
|
1272
|
+
// aria-pressed match the persisted hidden flag.
|
|
1273
|
+
for (const filePath of this.summariesHiddenFiles) {
|
|
1274
|
+
const wrapper = document.querySelector(
|
|
1275
|
+
`.d2h-file-wrapper[data-file-name="${CSS.escape(filePath)}"]`
|
|
1276
|
+
);
|
|
1277
|
+
if (!wrapper) continue;
|
|
1278
|
+
wrapper.classList.add('summaries-hidden-file');
|
|
1279
|
+
const btn = wrapper.querySelector('.file-header-summary-toggle');
|
|
1280
|
+
if (btn) this._syncFileSummaryToggleButton(btn, filePath);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// 3. Load existing summaries from the server.
|
|
1285
|
+
if (!this.currentPR?.id) return;
|
|
1286
|
+
|
|
1287
|
+
try {
|
|
1288
|
+
const resp = await fetch(`/api/reviews/${this.currentPR.id}/hunk-summaries`);
|
|
1289
|
+
if (gen !== this._renderGen) return;
|
|
1290
|
+
if (!resp.ok) return;
|
|
1291
|
+
const data = await resp.json();
|
|
1292
|
+
if (gen !== this._renderGen) return;
|
|
1293
|
+
const summaries = Array.isArray(data.summaries) ? data.summaries : [];
|
|
1294
|
+
// Group by file path so we can refresh each file's toggle button once.
|
|
1295
|
+
const byFile = new Map();
|
|
1296
|
+
for (const summary of summaries) {
|
|
1297
|
+
const fp = summary.file_path;
|
|
1298
|
+
if (!fp) continue;
|
|
1299
|
+
if (!byFile.has(fp)) byFile.set(fp, []);
|
|
1300
|
+
byFile.get(fp).push(summary);
|
|
1301
|
+
}
|
|
1302
|
+
// If summaries lack file_path, fall back to ungrouped rendering. Set
|
|
1303
|
+
// `_summariesGenerated` only after a summary actually mounts against the
|
|
1304
|
+
// current render anchors — never from the raw fetch count — so stale-hash
|
|
1305
|
+
// rows the anchor filter rejects can't flip the toolbar into Hide/Show
|
|
1306
|
+
// mode with nothing in the DOM. The grouped path defers this to
|
|
1307
|
+
// `_applyHunkSummaries`, which sets the flag on a successful mount.
|
|
1308
|
+
if (byFile.size === 0 && summaries.length > 0) {
|
|
1309
|
+
let mountedAny = false;
|
|
1310
|
+
let availableAny = false;
|
|
1311
|
+
for (const summary of summaries) {
|
|
1312
|
+
if (summary.summary_text) availableAny = true;
|
|
1313
|
+
if (this._renderOneSummary(summary)) mountedAny = true;
|
|
1314
|
+
}
|
|
1315
|
+
if (availableAny) this._summariesAvailable = true;
|
|
1316
|
+
if (mountedAny) this._summariesGenerated = true;
|
|
1317
|
+
if (mountedAny || availableAny) this._syncSummaryToolbarButton();
|
|
1318
|
+
} else {
|
|
1319
|
+
for (const [filePath, fileSummaries] of byFile.entries()) {
|
|
1320
|
+
this._applyHunkSummaries(filePath, fileSummaries);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
// Mirror the queue's view of whether summaries are still being generated.
|
|
1324
|
+
// The `review:background_job_finished` WS event clears this when the job
|
|
1325
|
+
// completes mid-session.
|
|
1326
|
+
this._summariesGenerating = data.generating === true;
|
|
1327
|
+
this._syncSummaryToolbarButton();
|
|
1328
|
+
} catch (err) {
|
|
1329
|
+
console.warn('[HunkSummary] failed to load summaries:', err);
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* Apply a batch of summaries delivered via the WS
|
|
1335
|
+
* `review:hunk_summaries_ready` event for a single file. Validates each
|
|
1336
|
+
* summary's hash against the per-file hash bucket so a hash collision
|
|
1337
|
+
* across files can't pull a summary into the wrong file's view, and
|
|
1338
|
+
* re-enables the per-file toggle button once a file has at least one
|
|
1339
|
+
* summary mounted.
|
|
1340
|
+
* @param {string} filePath - File path the summaries belong to
|
|
1341
|
+
* @param {Array<Object>} summaries - Summary rows for that file
|
|
1342
|
+
*/
|
|
1343
|
+
_applyHunkSummaries(filePath, summaries) {
|
|
1344
|
+
if (!Array.isArray(summaries)) return;
|
|
1345
|
+
const allowedHashes = this._summaryHashesByFile.get(filePath) || new Set();
|
|
1346
|
+
let mountedAny = false;
|
|
1347
|
+
let availableAny = false;
|
|
1348
|
+
for (const summary of summaries) {
|
|
1349
|
+
if (!summary?.content_hash) continue;
|
|
1350
|
+
if (allowedHashes.size > 0 && !allowedHashes.has(summary.content_hash)) {
|
|
1351
|
+
if (!this._warnedCrossFileHashMismatch) {
|
|
1352
|
+
this._warnedCrossFileHashMismatch = true;
|
|
1353
|
+
console.warn(
|
|
1354
|
+
`[HunkSummary] dropping summary for ${filePath}: hash ${summary.content_hash} ` +
|
|
1355
|
+
'not present in file hash bucket. Likely cross-file collision or stale render.'
|
|
1356
|
+
);
|
|
1357
|
+
}
|
|
1358
|
+
continue;
|
|
1359
|
+
}
|
|
1360
|
+
// A non-trivial summary that belongs to this render (it passed the hash
|
|
1361
|
+
// filter) means the feature has data even if its body hasn't rendered
|
|
1362
|
+
// yet — _renderOneSummary will queue it rather than mount it in that
|
|
1363
|
+
// case. Track availability separately from mounted rows.
|
|
1364
|
+
if (summary.summary_text) availableAny = true;
|
|
1365
|
+
const row = this._renderOneSummary(summary, filePath);
|
|
1366
|
+
if (row) mountedAny = true;
|
|
1367
|
+
}
|
|
1368
|
+
if (availableAny) this._summariesAvailable = true;
|
|
1369
|
+
if (mountedAny) {
|
|
1370
|
+
// At least one summary mounted — the feature now has visible data, so
|
|
1371
|
+
// the toolbar button can show its `.active` (blue) state.
|
|
1372
|
+
this._summariesGenerated = true;
|
|
1373
|
+
}
|
|
1374
|
+
// Refresh the toolbar when either flag changed: `.active` tracks
|
|
1375
|
+
// `_summariesGenerated` (rows in the DOM) while the Generate-vs-Show/Hide
|
|
1376
|
+
// decision tracks `_summariesAvailable` (mounted OR queued). Refreshing on
|
|
1377
|
+
// availableAny prevents a not-yet-rendered file from leaving the toolbar
|
|
1378
|
+
// in "Generate" mode, where a click would start a duplicate job.
|
|
1379
|
+
if (mountedAny || availableAny) this._syncSummaryToolbarButton();
|
|
1380
|
+
if (mountedAny && filePath) this._refreshFileSummaryToggle(filePath);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/**
|
|
1384
|
+
* Refresh the per-file summary toggle button for `filePath` so it reflects
|
|
1385
|
+
* the current state: enabled iff there is at least one mounted summary in
|
|
1386
|
+
* that file's hash bucket.
|
|
1387
|
+
* @param {string} filePath
|
|
1388
|
+
*/
|
|
1389
|
+
_refreshFileSummaryToggle(filePath) {
|
|
1390
|
+
if (!filePath) return;
|
|
1391
|
+
const wrapper = document.querySelector(
|
|
1392
|
+
`.d2h-file-wrapper[data-file-name="${CSS.escape(filePath)}"]`
|
|
1393
|
+
);
|
|
1394
|
+
if (!wrapper) return;
|
|
1395
|
+
const btn = wrapper.querySelector('.file-header-summary-toggle');
|
|
1396
|
+
if (!btn) return;
|
|
1397
|
+
this._syncFileSummaryToggleButton(btn, filePath);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* Apply the canonical per-file summary toggle button state derived from
|
|
1402
|
+
* `_summaryHashesByFile` and `summariesHiddenFiles`. Sets `disabled`,
|
|
1403
|
+
* `summaries-off`, `aria-pressed`, and `title` on the button.
|
|
1404
|
+
*
|
|
1405
|
+
* Used by three call sites that must agree on the button's visible state:
|
|
1406
|
+
* - createFileHeader (initial render)
|
|
1407
|
+
* - _fetchHunkSummaryMap (rehydrate after localStorage restore)
|
|
1408
|
+
* - _refreshFileSummaryToggle (when summaries arrive late)
|
|
1409
|
+
* - toggleFileSummaries (user click)
|
|
1410
|
+
*
|
|
1411
|
+
* @param {HTMLButtonElement} btn
|
|
1412
|
+
* @param {string} filePath
|
|
1413
|
+
*/
|
|
1414
|
+
_syncFileSummaryToggleButton(btn, filePath) {
|
|
1415
|
+
if (!btn || !filePath) return;
|
|
1416
|
+
const hasSummaries = (this._summaryHashesByFile.get(filePath)?.size || 0) > 0;
|
|
1417
|
+
const isHidden = this.summariesHiddenFiles.has(filePath);
|
|
1418
|
+
btn.classList.toggle('summaries-off', isHidden);
|
|
1419
|
+
btn.setAttribute('aria-pressed', isHidden ? 'false' : 'true');
|
|
1420
|
+
if (!hasSummaries) {
|
|
1421
|
+
btn.disabled = true;
|
|
1422
|
+
btn.title = 'No summaries available';
|
|
1423
|
+
} else {
|
|
1424
|
+
btn.disabled = false;
|
|
1425
|
+
btn.title = isHidden ? 'Show file summaries' : 'Hide file summaries';
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/**
|
|
1430
|
+
* Queue a summary that arrived before its hunk anchor existed, scoped by
|
|
1431
|
+
* file path so a content-hash collision across files can't let one file's
|
|
1432
|
+
* anchor consume another file's queued summary. Summaries arriving without
|
|
1433
|
+
* a file path (legacy ungrouped fetch) land in the '' bucket.
|
|
1434
|
+
* @param {string|undefined} filePath
|
|
1435
|
+
* @param {Object} summary - must have `content_hash`
|
|
1436
|
+
*/
|
|
1437
|
+
_queuePendingSummary(filePath, summary) {
|
|
1438
|
+
const key = filePath || '';
|
|
1439
|
+
let bucket = this._pendingSummariesByHash.get(key);
|
|
1440
|
+
if (!bucket) {
|
|
1441
|
+
bucket = new Map();
|
|
1442
|
+
this._pendingSummariesByHash.set(key, bucket);
|
|
1443
|
+
}
|
|
1444
|
+
bucket.set(summary.content_hash, summary);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/**
|
|
1448
|
+
* Pull (and remove) a queued summary for (filePath, hash). Checks the
|
|
1449
|
+
* file-scoped bucket first, then the '' bucket that holds summaries queued
|
|
1450
|
+
* without a file path (legacy ungrouped fetch). Returns null when nothing
|
|
1451
|
+
* is queued for that pair.
|
|
1452
|
+
* @param {string|undefined} filePath
|
|
1453
|
+
* @param {string} hash
|
|
1454
|
+
* @returns {Object|null}
|
|
1455
|
+
*/
|
|
1456
|
+
_takePendingSummary(filePath, hash) {
|
|
1457
|
+
for (const key of [filePath || '', '']) {
|
|
1458
|
+
const bucket = this._pendingSummariesByHash.get(key);
|
|
1459
|
+
if (bucket && bucket.has(hash)) {
|
|
1460
|
+
const summary = bucket.get(hash);
|
|
1461
|
+
bucket.delete(hash);
|
|
1462
|
+
if (bucket.size === 0) this._pendingSummariesByHash.delete(key);
|
|
1463
|
+
return summary;
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
return null;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/**
|
|
1470
|
+
* Resolve the anchor row for (filePath, hash) against the file-scoped
|
|
1471
|
+
* `_summaryAnchorsByHash` map.
|
|
1472
|
+
*
|
|
1473
|
+
* When `filePath` is known (the grouped path), the lookup is strictly
|
|
1474
|
+
* scoped to that file so a content-hash shared across files can't pull a
|
|
1475
|
+
* summary onto the wrong file's anchor. When it's absent — the legacy
|
|
1476
|
+
* ungrouped-fetch fallback where summaries arrive without a `file_path` —
|
|
1477
|
+
* there's no file to scope by, so we accept the first file whose bucket
|
|
1478
|
+
* carries the hash. This mirrors `_takePendingSummary`'s `''` fallback
|
|
1479
|
+
* bucket: file-scoped first, best-effort otherwise.
|
|
1480
|
+
* @param {string|undefined} filePath
|
|
1481
|
+
* @param {string} hash
|
|
1482
|
+
* @returns {HTMLTableRowElement|null}
|
|
1483
|
+
*/
|
|
1484
|
+
_findSummaryAnchor(filePath, hash) {
|
|
1485
|
+
if (filePath) {
|
|
1486
|
+
return this._summaryAnchorsByHash.get(filePath)?.get(hash) || null;
|
|
1487
|
+
}
|
|
1488
|
+
for (const anchors of this._summaryAnchorsByHash.values()) {
|
|
1489
|
+
const anchor = anchors.get(hash);
|
|
1490
|
+
if (anchor) return anchor;
|
|
1491
|
+
}
|
|
1492
|
+
return null;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* Render a single summary row, or queue it if the matching hunk hasn't
|
|
1497
|
+
* been hashed yet (race between WS broadcast and post-render hashing).
|
|
1498
|
+
* Trivial / model-skipped / model-malformed rows are ignored.
|
|
1499
|
+
* @param {Object} summary - { content_hash, summary_text, trivial_reason }
|
|
1500
|
+
* @param {string} [filePath] - File the summary belongs to. Used to scope
|
|
1501
|
+
* both the anchor lookup and the pending-queue key so a cross-file hash
|
|
1502
|
+
* collision can't misroute it to the wrong file's hunk.
|
|
1503
|
+
* @returns {HTMLTableRowElement|null} The mounted row, or null if queued/skipped.
|
|
1504
|
+
*/
|
|
1505
|
+
_renderOneSummary(summary, filePath) {
|
|
1506
|
+
if (!summary || !summary.content_hash) return null;
|
|
1507
|
+
if (!summary.summary_text) return null; // trivial / opt-out — nothing to show
|
|
1508
|
+
const hash = summary.content_hash;
|
|
1509
|
+
const anchor = this._findSummaryAnchor(filePath, hash);
|
|
1510
|
+
if (!anchor || !anchor.isConnected) {
|
|
1511
|
+
// Anchor missing or detached (stale render) → defer; the next render
|
|
1512
|
+
// pass that re-establishes the hash will drain this map.
|
|
1513
|
+
this._queuePendingSummary(filePath, summary);
|
|
1514
|
+
return null;
|
|
1515
|
+
}
|
|
1516
|
+
if (!this.hunkSummaryRenderer) return null;
|
|
1517
|
+
return this.hunkSummaryRenderer.renderInline(anchor, summary);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/**
|
|
1521
|
+
* Storage key for per-file summary visibility. Mirrors the
|
|
1522
|
+
* `pair-review:summaries-hidden:${reviewId}` review-level key.
|
|
1523
|
+
* @param {number|string} reviewId
|
|
1524
|
+
* @returns {string}
|
|
1525
|
+
*/
|
|
1526
|
+
static summariesHiddenFilesStorageKey(reviewId) {
|
|
1527
|
+
return `pair-review:summaries-hidden-files:${reviewId}`;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/**
|
|
1531
|
+
* Toggle the visibility of summaries for a single file. Updates the
|
|
1532
|
+
* `summariesHiddenFiles` set, the wrapper's CSS class, the per-file toggle
|
|
1533
|
+
* button's `summaries-off` class, and persists the set per-review.
|
|
1534
|
+
* @param {string} filePath
|
|
1535
|
+
* @param {HTMLElement} fileWrapper - The `.d2h-file-wrapper` element
|
|
1536
|
+
*/
|
|
1537
|
+
toggleFileSummaries(filePath, fileWrapper) {
|
|
1538
|
+
if (!filePath || !fileWrapper) return;
|
|
1539
|
+
const isHidden = this.summariesHiddenFiles.has(filePath);
|
|
1540
|
+
if (isHidden) {
|
|
1541
|
+
this.summariesHiddenFiles.delete(filePath);
|
|
1542
|
+
} else {
|
|
1543
|
+
this.summariesHiddenFiles.add(filePath);
|
|
1544
|
+
}
|
|
1545
|
+
fileWrapper.classList.toggle('summaries-hidden-file', !isHidden);
|
|
1546
|
+
const btn = fileWrapper.querySelector('.file-header-summary-toggle');
|
|
1547
|
+
if (btn) this._syncFileSummaryToggleButton(btn, filePath);
|
|
1548
|
+
if (this.currentPR?.id != null) {
|
|
1549
|
+
try {
|
|
1550
|
+
window.localStorage.setItem(
|
|
1551
|
+
PRManager.summariesHiddenFilesStorageKey(this.currentPR.id),
|
|
1552
|
+
JSON.stringify([...this.summariesHiddenFiles])
|
|
1553
|
+
);
|
|
1554
|
+
} catch {
|
|
1555
|
+
// localStorage unavailable; in-session state still applies.
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
/**
|
|
1561
|
+
* Hydrate `summariesHiddenFiles` from localStorage for the current review.
|
|
1562
|
+
* Safe to call multiple times — the state always reflects what's in storage.
|
|
1563
|
+
*/
|
|
1564
|
+
_restoreSummariesHiddenFiles() {
|
|
1565
|
+
if (!this.currentPR?.id) return;
|
|
1566
|
+
try {
|
|
1567
|
+
const raw = window.localStorage.getItem(
|
|
1568
|
+
PRManager.summariesHiddenFilesStorageKey(this.currentPR.id)
|
|
1569
|
+
);
|
|
1570
|
+
if (!raw) {
|
|
1571
|
+
this.summariesHiddenFiles = new Set();
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
const arr = JSON.parse(raw);
|
|
1575
|
+
this.summariesHiddenFiles = new Set(Array.isArray(arr) ? arr : []);
|
|
1576
|
+
} catch {
|
|
1577
|
+
this.summariesHiddenFiles = new Set();
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
/**
|
|
1582
|
+
* Toggle review-level summary visibility. Persists per-review.
|
|
1583
|
+
*/
|
|
1584
|
+
toggleSummariesVisibility() {
|
|
1585
|
+
this._summariesHidden = !this._summariesHidden;
|
|
1586
|
+
document.body.classList.toggle('summaries-hidden', this._summariesHidden);
|
|
1587
|
+
if (this.currentPR?.id != null) {
|
|
1588
|
+
try {
|
|
1589
|
+
window.localStorage.setItem(
|
|
1590
|
+
`pair-review:summaries-hidden:${this.currentPR.id}`,
|
|
1591
|
+
this._summariesHidden ? '1' : '0'
|
|
1592
|
+
);
|
|
1593
|
+
} catch {
|
|
1594
|
+
// localStorage unavailable; in-session state still applies.
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
this._syncSummaryToolbarButton();
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
/**
|
|
1601
|
+
* Reflect the current state (visible / hidden / generating) on the
|
|
1602
|
+
* toolbar toggle button. The button gets:
|
|
1603
|
+
* - `.active` when summaries are visible
|
|
1604
|
+
* - `.generating` when a background summary job is in flight
|
|
1605
|
+
* - `title` + `aria-label` + `data-label` (CSS hover fallback) all kept
|
|
1606
|
+
* in sync so the user always knows what the button does.
|
|
1607
|
+
*/
|
|
1608
|
+
_syncSummaryToolbarButton() {
|
|
1609
|
+
const btn = document.getElementById('summary-toggle-btn');
|
|
1610
|
+
if (!btn) return;
|
|
1611
|
+
// `.active` (blue) only once summaries actually exist AND are visible.
|
|
1612
|
+
// Before any generation the button stays colorless so the pre-generated
|
|
1613
|
+
// state is visually distinct from "generated but hidden".
|
|
1614
|
+
btn.classList.toggle('active', this._summariesGenerated && !this._summariesHidden);
|
|
1615
|
+
btn.classList.toggle('generating', this._summariesGenerating === true);
|
|
1616
|
+
|
|
1617
|
+
let label;
|
|
1618
|
+
if (this._summariesGenerating) {
|
|
1619
|
+
// Hint at the cancel affordance — clicking the pulsing button now
|
|
1620
|
+
// opens a confirm dialog ("Cancel Summaries" / "OK") instead of
|
|
1621
|
+
// toggling visibility. See _handleSummaryToggleClick.
|
|
1622
|
+
label = 'Generating summaries… (click to cancel)';
|
|
1623
|
+
} else if (!this._summariesAvailable) {
|
|
1624
|
+
// Pre-generated state: nothing generated yet (mounted or queued).
|
|
1625
|
+
// Colorless button; a click kicks off generation. Gated on
|
|
1626
|
+
// `_summariesAvailable` (not `_summariesGenerated`) so a review whose
|
|
1627
|
+
// summaries exist but haven't mounted shows Show/Hide, matching the
|
|
1628
|
+
// click behavior in _handleSummaryToggleClick.
|
|
1629
|
+
label = 'Generate hunk summaries';
|
|
1630
|
+
} else {
|
|
1631
|
+
label = this._summariesHidden ? 'Show hunk summaries' : 'Hide hunk summaries';
|
|
1632
|
+
}
|
|
1633
|
+
btn.title = label;
|
|
1634
|
+
btn.setAttribute('aria-label', label);
|
|
1635
|
+
btn.dataset.label = label;
|
|
1636
|
+
btn.setAttribute(
|
|
1637
|
+
'aria-pressed',
|
|
1638
|
+
(this._summariesGenerated && !this._summariesHidden) ? 'true' : 'false'
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// ===== Tour (Phase 8) ===================================================
|
|
1643
|
+
|
|
1644
|
+
/**
|
|
1645
|
+
* Whether a tour is currently mounted in the UI.
|
|
1646
|
+
* @returns {boolean}
|
|
1647
|
+
*/
|
|
1648
|
+
_tourIsActive() {
|
|
1649
|
+
return this._tourActiveIndex >= 0 && !!this._tourRenderer;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* Reflect tour state on the toolbar toggle button. Mirrors the structure
|
|
1654
|
+
* of `_syncSummaryToolbarButton` so future tweaks stay in lockstep.
|
|
1655
|
+
*/
|
|
1656
|
+
_syncTourToolbarButton() {
|
|
1657
|
+
const btn = document.getElementById('tour-toggle-btn');
|
|
1658
|
+
if (!btn) return;
|
|
1659
|
+
const active = this._tourIsActive();
|
|
1660
|
+
const hasPending = active && Array.isArray(this._tourStopsPendingRestart);
|
|
1661
|
+
btn.classList.toggle('active', active);
|
|
1662
|
+
btn.classList.toggle('generating', this._tourGenerating === true);
|
|
1663
|
+
btn.classList.toggle('tour-updated-pending', hasPending);
|
|
1664
|
+
|
|
1665
|
+
let label;
|
|
1666
|
+
if (this._tourGenerating) {
|
|
1667
|
+
// Hint at the cancel affordance — see _handleTourToggleClick.
|
|
1668
|
+
label = active
|
|
1669
|
+
? 'Generating tour… (click to cancel)'
|
|
1670
|
+
: 'Generating guided tour… (click to cancel)';
|
|
1671
|
+
} else if (hasPending) {
|
|
1672
|
+
label = 'Tour updated — restart to apply new stops';
|
|
1673
|
+
} else if (active) {
|
|
1674
|
+
label = 'Exit guided tour';
|
|
1675
|
+
} else if (this._tourStops && this._tourStops.length > 0) {
|
|
1676
|
+
label = 'Start guided tour';
|
|
1677
|
+
} else {
|
|
1678
|
+
label = 'Start guided tour (none available yet)';
|
|
1679
|
+
}
|
|
1680
|
+
btn.title = label;
|
|
1681
|
+
btn.setAttribute('aria-label', label);
|
|
1682
|
+
btn.dataset.label = label;
|
|
1683
|
+
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// ===== Cancel flow (shared) =============================================
|
|
1687
|
+
|
|
1688
|
+
/**
|
|
1689
|
+
* Toolbar click handler for the summaries toggle. If a summary job is
|
|
1690
|
+
* in flight (`.generating` pulse), intercept and open the cancel-confirm
|
|
1691
|
+
* dialog instead of toggling visibility. Toggle visibility otherwise.
|
|
1692
|
+
*
|
|
1693
|
+
* Kept thin so `addEventListener` callers don't need to know about the
|
|
1694
|
+
* cancel flow — that lives in `_promptCancelJob`.
|
|
1695
|
+
* @returns {void}
|
|
1696
|
+
*/
|
|
1697
|
+
async _handleSummaryToggleClick() {
|
|
1698
|
+
if (this._summariesGenerating) {
|
|
1699
|
+
await this._promptCancelJob({
|
|
1700
|
+
kind: 'summaries',
|
|
1701
|
+
onCleared: () => {
|
|
1702
|
+
this._summariesGenerating = false;
|
|
1703
|
+
this._syncSummaryToolbarButton();
|
|
1704
|
+
},
|
|
1705
|
+
});
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
if (!this._summariesAvailable) {
|
|
1709
|
+
// Nothing generated yet (mounted OR queued): a click triggers generation
|
|
1710
|
+
// rather than toggling visibility. `_startGenerationJob` sets the pulsing
|
|
1711
|
+
// `.generating` state optimistically (there is no
|
|
1712
|
+
// `review:background_job_started` event). We gate on `_summariesAvailable`
|
|
1713
|
+
// rather than `_summariesGenerated` so summaries that exist server-side
|
|
1714
|
+
// but haven't mounted (their lazy file body isn't rendered yet) toggle
|
|
1715
|
+
// visibility instead of kicking off a duplicate generation job.
|
|
1716
|
+
await this._startGenerationJob('summary');
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
this.toggleSummariesVisibility();
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
/**
|
|
1723
|
+
* Toolbar click handler for the tour toggle. If a tour job is in flight
|
|
1724
|
+
* (`.generating` pulse), intercept and open the cancel-confirm dialog
|
|
1725
|
+
* instead of opening/exiting the tour. Defer to `startOrToggleTour`
|
|
1726
|
+
* otherwise.
|
|
1727
|
+
* @returns {Promise<void>}
|
|
1728
|
+
*/
|
|
1729
|
+
async _handleTourToggleClick() {
|
|
1730
|
+
if (this._tourGenerating) {
|
|
1731
|
+
await this._promptCancelJob({
|
|
1732
|
+
kind: 'tour',
|
|
1733
|
+
onCleared: () => {
|
|
1734
|
+
this._tourGenerating = false;
|
|
1735
|
+
this._syncTourToolbarButton();
|
|
1736
|
+
},
|
|
1737
|
+
});
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
await this.startOrToggleTour();
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
/**
|
|
1744
|
+
* Shared cancel-flow entrypoint: opens the right confirm dialog for the
|
|
1745
|
+
* given job kind, POSTs the cancel on confirm, and runs `onCleared` so
|
|
1746
|
+
* the caller can reset the pulse state. The corresponding broadcast
|
|
1747
|
+
* (`review:background_job_finished` with `cancelled: true`) will arrive
|
|
1748
|
+
* shortly after; that handler also clears the flag, so a double-clear
|
|
1749
|
+
* is harmless.
|
|
1750
|
+
*
|
|
1751
|
+
* @param {Object} opts
|
|
1752
|
+
* @param {'tour'|'summaries'} opts.kind
|
|
1753
|
+
* @param {Function} opts.onCleared - Called after the user confirms.
|
|
1754
|
+
* @returns {Promise<void>}
|
|
1755
|
+
*/
|
|
1756
|
+
async _promptCancelJob({ kind, onCleared }) {
|
|
1757
|
+
// Re-entry guard: the pulsing toolbar button stays clickable while the
|
|
1758
|
+
// confirm dialog is up. ConfirmDialog is a singleton — a second call to
|
|
1759
|
+
// .show() overwrites the first invocation's callbacks and orphans its
|
|
1760
|
+
// Promise. Drop the second click instead.
|
|
1761
|
+
if (this._cancelPromptOpen) return;
|
|
1762
|
+
const helper = typeof window !== 'undefined' ? window.CancelBackgroundJob : null;
|
|
1763
|
+
if (!helper) return;
|
|
1764
|
+
const reviewId = this.currentPR && this.currentPR.id;
|
|
1765
|
+
if (!reviewId) return;
|
|
1766
|
+
const show = kind === 'tour'
|
|
1767
|
+
? helper.showCancelTourDialog
|
|
1768
|
+
: helper.showCancelSummariesDialog;
|
|
1769
|
+
this._cancelPromptOpen = true;
|
|
1770
|
+
try {
|
|
1771
|
+
await show({ reviewId, onCancelled: onCleared });
|
|
1772
|
+
} finally {
|
|
1773
|
+
this._cancelPromptOpen = false;
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
/**
|
|
1778
|
+
* Manually trigger a summary or tour generation job for the current review.
|
|
1779
|
+
* Used when `auto_generate` is off so generation does not kick off on load;
|
|
1780
|
+
* the user clicks the toolbar button to start it.
|
|
1781
|
+
*
|
|
1782
|
+
* Mode-aware: PR reviews POST to `/api/pr/...`, local reviews to
|
|
1783
|
+
* `/api/local/...`. The server enqueues the job with `trigger: 'manual'`
|
|
1784
|
+
* (bypassing the `auto_generate` gate) and responds with `{ started }` /
|
|
1785
|
+
* `{ alreadyRunning }`. There is no `review:background_job_started`
|
|
1786
|
+
* broadcast, so this method optimistically sets the matching `*Generating`
|
|
1787
|
+
* flag and pulses the button itself; `review:background_job_finished`
|
|
1788
|
+
* clears the flag when the job ends.
|
|
1789
|
+
*
|
|
1790
|
+
* @param {'summary'|'tour'} jobKey
|
|
1791
|
+
* @returns {Promise<void>}
|
|
1792
|
+
*/
|
|
1793
|
+
async _startGenerationJob(jobKey) {
|
|
1794
|
+
const pr = this.currentPR;
|
|
1795
|
+
if (!pr || pr.id == null) return;
|
|
1796
|
+
const isLocal = pr.reviewType === 'local'
|
|
1797
|
+
|| (typeof window !== 'undefined' && window.PAIR_REVIEW_LOCAL_MODE === true);
|
|
1798
|
+
const url = isLocal
|
|
1799
|
+
? `/api/local/${encodeURIComponent(pr.id)}/jobs/${encodeURIComponent(jobKey)}/start`
|
|
1800
|
+
: `/api/pr/${encodeURIComponent(pr.owner)}/${encodeURIComponent(pr.repo)}/${encodeURIComponent(pr.number)}/jobs/${encodeURIComponent(jobKey)}/start`;
|
|
1801
|
+
// Phrasing varies by job kind. `featureLabel` goes into the HTTP/decline
|
|
1802
|
+
// error messages; `noDiffMessage` is the dedicated "nothing to do" line.
|
|
1803
|
+
// NOTE: the toast singleton is lowercase `window.toast` (see
|
|
1804
|
+
// cancel-background-job.js); `window.Toast` does not exist.
|
|
1805
|
+
const featureLabel = jobKey === 'tour' ? 'tour' : 'summary';
|
|
1806
|
+
const noDiffMessage = jobKey === 'tour' ? 'No tour to generate.' : 'No summaries to generate.';
|
|
1807
|
+
try {
|
|
1808
|
+
const resp = await fetch(url, { method: 'POST' });
|
|
1809
|
+
if (resp.status === 409) {
|
|
1810
|
+
// Feature disabled in config — shouldn't happen (the button is hidden
|
|
1811
|
+
// when disabled) but surface it rather than failing silently.
|
|
1812
|
+
window.toast?.showError?.('This feature is disabled in config.');
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1815
|
+
if (!resp.ok) {
|
|
1816
|
+
console.warn(`[StartJob] ${jobKey} start POST failed: ${resp.status}`);
|
|
1817
|
+
window.toast?.showError?.(`Failed to start ${featureLabel} generation (HTTP ${resp.status}).`);
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
// Optimistic UI: there is no `review:background_job_started` broadcast,
|
|
1821
|
+
// so set the generating flag now — when the server enqueued a job
|
|
1822
|
+
// (`started`) or one was already running (`alreadyRunning`) — to start
|
|
1823
|
+
// the pulse immediately. Results arrive via `review:hunk_summaries_ready`
|
|
1824
|
+
// / `review:tour_ready`; `review:background_job_finished` clears the flag
|
|
1825
|
+
// when the job ends.
|
|
1826
|
+
const payload = await resp.json().catch(() => ({}));
|
|
1827
|
+
if (payload.started || payload.alreadyRunning) {
|
|
1828
|
+
if (jobKey === 'summary') {
|
|
1829
|
+
this._summariesGenerating = true;
|
|
1830
|
+
this._syncSummaryToolbarButton();
|
|
1831
|
+
} else if (jobKey === 'tour') {
|
|
1832
|
+
this._tourGenerating = true;
|
|
1833
|
+
this._syncTourToolbarButton();
|
|
1834
|
+
}
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
// Server accepted the request but declined to enqueue. The known
|
|
1838
|
+
// reason today is `'no-diff'` — review has no changes to act on. Tell
|
|
1839
|
+
// the user so the button doesn't appear inert. Unknown decline
|
|
1840
|
+
// reasons fall through to a generic message rather than silence.
|
|
1841
|
+
if (payload.reason === 'no-diff') {
|
|
1842
|
+
window.toast?.showInfo?.(noDiffMessage);
|
|
1843
|
+
} else {
|
|
1844
|
+
window.toast?.showError?.(`Could not start ${featureLabel} generation.`);
|
|
1845
|
+
}
|
|
1846
|
+
} catch (err) {
|
|
1847
|
+
console.warn(`[StartJob] ${jobKey} start POST error:`, err.message);
|
|
1848
|
+
window.toast?.showError?.(`Failed to start ${featureLabel} generation: ${err.message}`);
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
/**
|
|
1853
|
+
* Fetch /api/reviews/:reviewId/tour and stash the result in `_tourStops`
|
|
1854
|
+
* / `_tourGenerating`. Does NOT open the tour.
|
|
1855
|
+
*
|
|
1856
|
+
* If `deferIfActive` is true and a tour is currently mounted, the fetched
|
|
1857
|
+
* stops are stashed on `_tourStopsPendingRestart` instead of replacing
|
|
1858
|
+
* the active tour's stops. The pending stops apply on the next exit or
|
|
1859
|
+
* restart. This is the v1 simple approach — replacing the running tour
|
|
1860
|
+
* mid-flight is doable but adds complexity (mounted refs keyed by old
|
|
1861
|
+
* indices, current-stop drift, etc.) without a clear UX win.
|
|
1862
|
+
*
|
|
1863
|
+
* @param {Object} [opts]
|
|
1864
|
+
* @param {boolean} [opts.deferIfActive=false]
|
|
1865
|
+
* @param {boolean} [opts.cancelOnRender=true] - When true (default),
|
|
1866
|
+
* the probe captures `_renderGen` and aborts before mutating state
|
|
1867
|
+
* if a later render bumps the generation. Render-triggered probes
|
|
1868
|
+
* want this so a stale fetch can't clobber a fresh reset. One-shot
|
|
1869
|
+
* recovery callers (e.g. the deferred config-probe) pass `false`
|
|
1870
|
+
* so they don't self-cancel.
|
|
1871
|
+
* @returns {Promise<Array<Object>|null>} resolved stops, or null on miss.
|
|
1872
|
+
*/
|
|
1873
|
+
async _loadAndStashTour({ deferIfActive = false, cancelOnRender = true } = {}) {
|
|
1874
|
+
if (!this.currentPR?.id) return null;
|
|
1875
|
+
if (this._toursEnabled === false) return null;
|
|
1876
|
+
// Capture the current render generation; if a later renderDiff bumps
|
|
1877
|
+
// _renderGen between our awaits, bail before mutating state. Only
|
|
1878
|
+
// applied when cancelOnRender is true — the deferred config probe
|
|
1879
|
+
// and other one-shot recovery callers pass `cancelOnRender: false`.
|
|
1880
|
+
const gen = this._renderGen;
|
|
1881
|
+
const guardStale = () => cancelOnRender && gen !== this._renderGen;
|
|
1882
|
+
try {
|
|
1883
|
+
const resp = await fetch(`/api/reviews/${this.currentPR.id}/tour`);
|
|
1884
|
+
if (guardStale()) return null;
|
|
1885
|
+
if (!resp.ok) return null;
|
|
1886
|
+
const data = await resp.json();
|
|
1887
|
+
if (guardStale()) return null;
|
|
1888
|
+
this._tourGenerating = data.generating === true;
|
|
1889
|
+
const stops = Array.isArray(data.tour?.stops) ? data.tour.stops : null;
|
|
1890
|
+
if (deferIfActive && this._tourIsActive()) {
|
|
1891
|
+
this._tourStopsPendingRestart = stops;
|
|
1892
|
+
} else {
|
|
1893
|
+
this._tourStops = stops;
|
|
1894
|
+
this._tourStopsPendingRestart = null;
|
|
1895
|
+
}
|
|
1896
|
+
this._syncTourToolbarButton();
|
|
1897
|
+
return stops;
|
|
1898
|
+
} catch (err) {
|
|
1899
|
+
console.warn('[Tour] failed to load tour:', err);
|
|
1900
|
+
return null;
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
/**
|
|
1905
|
+
* Toolbar click entrypoint. If a tour is active, exit. Otherwise fetch
|
|
1906
|
+
* stops if needed, then open from the first stop. No-ops when no stops
|
|
1907
|
+
* exist (toolbar button stays inert with the "none available yet" label).
|
|
1908
|
+
* @returns {Promise<void>}
|
|
1909
|
+
*/
|
|
1910
|
+
async startOrToggleTour() {
|
|
1911
|
+
if (this._tourIsActive()) {
|
|
1912
|
+
this._exitTour();
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
if (!this._tourStops || this._tourStops.length === 0) {
|
|
1916
|
+
await this._loadAndStashTour();
|
|
1917
|
+
}
|
|
1918
|
+
if (!this._tourStops || this._tourStops.length === 0) {
|
|
1919
|
+
// No tour stops available. When auto-generation is off and nothing is
|
|
1920
|
+
// already in flight, a click triggers manual generation (mirrors the
|
|
1921
|
+
// summaries button). `_startGenerationJob` sets the pulsing state
|
|
1922
|
+
// optimistically (there is no `review:background_job_started` event).
|
|
1923
|
+
// When `review:tour_ready` arrives the stops load and the button becomes
|
|
1924
|
+
// "Start guided tour" — the user clicks again to open it (no auto-open).
|
|
1925
|
+
if (this._toursAutoGenerate === false && !this._tourGenerating) {
|
|
1926
|
+
await this._startGenerationJob('tour');
|
|
1927
|
+
}
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
await this._openTourAtStart();
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
/**
|
|
1934
|
+
* Open the tour UI starting at stop 0. Lazy-creates the TourBar and
|
|
1935
|
+
* TourRenderer on first call so we pay zero cost for users who never
|
|
1936
|
+
* trigger a tour.
|
|
1937
|
+
*/
|
|
1938
|
+
async _openTourAtStart() {
|
|
1939
|
+
if (!this._tourStops || this._tourStops.length === 0) return;
|
|
1940
|
+
|
|
1941
|
+
// Drain any pending teardown from the previous tour BEFORE we read
|
|
1942
|
+
// wrappers below. Otherwise a fire-and-forget DELETE + its
|
|
1943
|
+
// loadContextFiles reload landing mid-open can rip the wrapper the
|
|
1944
|
+
// first stop is about to mount against. allSettled-wrapped so it
|
|
1945
|
+
// never rejects.
|
|
1946
|
+
if (this._tourCleanupPending) {
|
|
1947
|
+
const pending = this._tourCleanupPending;
|
|
1948
|
+
this._tourCleanupPending = null;
|
|
1949
|
+
await pending;
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
if (!this._tourRenderer && typeof window !== 'undefined' && window.TourRenderer) {
|
|
1953
|
+
this._tourRenderer = new window.TourRenderer(this);
|
|
1954
|
+
}
|
|
1955
|
+
if (!this._tourBar && typeof window !== 'undefined' && window.TourBar) {
|
|
1956
|
+
this._tourBar = new window.TourBar({
|
|
1957
|
+
onPrev: () => this._advanceTour(-1),
|
|
1958
|
+
onNext: () => this._advanceTour(1),
|
|
1959
|
+
onExit: () => this._exitTour(),
|
|
1960
|
+
onRestart: () => this._restartTour(),
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1963
|
+
if (!this._tourRenderer || !this._tourBar) {
|
|
1964
|
+
console.warn('[Tour] TourRenderer/TourBar not available; cannot open tour');
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
this._tourRenderer.setStops(this._tourStops);
|
|
1969
|
+
this._tourRenderer.setActive(true);
|
|
1970
|
+
// Mount inside the diff-view scroll container so the bar (position:
|
|
1971
|
+
// sticky) spans only the diff width — the file-tree sidebar and its
|
|
1972
|
+
// controls stay visible.
|
|
1973
|
+
const diffView = document.querySelector('.main-layout .diff-view');
|
|
1974
|
+
this._tourBar.mount(diffView || undefined);
|
|
1975
|
+
this._tourBar.setStops(this._tourStops);
|
|
1976
|
+
this._tourBar.setCompleted(false);
|
|
1977
|
+
|
|
1978
|
+
this._tourActiveIndex = -1;
|
|
1979
|
+
// Bump the generation BEFORE the first _advanceTour call so it sees
|
|
1980
|
+
// the fresh value as its baseline. Subsequent exits bump it again,
|
|
1981
|
+
// making in-flight probes from this open detect the mismatch and bail.
|
|
1982
|
+
this._tourGen += 1;
|
|
1983
|
+
this._registerTourKeyboardHandlers();
|
|
1984
|
+
this._advanceTour(1);
|
|
1985
|
+
this._syncTourToolbarButton();
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
/**
|
|
1989
|
+
* Advance (or rewind) the active stop by `delta`. Going past the end of
|
|
1990
|
+
* the tour flips the bar into completion state; going before the start
|
|
1991
|
+
* clamps at 0.
|
|
1992
|
+
*
|
|
1993
|
+
* Async because each probe candidate is run through
|
|
1994
|
+
* `TourRenderer.prepareStop` first, which may need to await a file
|
|
1995
|
+
* fetch (adding a non-diff file as a context file) and/or a gap-expand
|
|
1996
|
+
* to surface folded rows the stop anchors on. Re-entrant calls (rapid
|
|
1997
|
+
* Next presses, keyboard mashing) are dropped via `_advanceInFlight`
|
|
1998
|
+
* so we never have two probe loops mutating tour state concurrently.
|
|
1999
|
+
*
|
|
2000
|
+
* @param {number} delta - Typically +1 (next) or -1 (prev).
|
|
2001
|
+
* @returns {Promise<void>}
|
|
2002
|
+
*/
|
|
2003
|
+
async _advanceTour(delta) {
|
|
2004
|
+
if (!this._tourRenderer || !this._tourBar || !this._tourStops) return;
|
|
2005
|
+
const total = this._tourStops.length;
|
|
2006
|
+
if (total === 0) return;
|
|
2007
|
+
|
|
2008
|
+
// Drop overlapping nav requests. The keyboard / button callbacks all
|
|
2009
|
+
// fire-and-forget, so a fast Next-Next-Next while a file fetch is in
|
|
2010
|
+
// flight would otherwise interleave probe loops on shared mutable
|
|
2011
|
+
// state (`_tourActiveIndex`, the renderer's `_mounted` map).
|
|
2012
|
+
//
|
|
2013
|
+
// Latch is generation-scoped: an in-flight call from a torn-down
|
|
2014
|
+
// generation no longer matches `_tourGen`, so a fresh reopen passes
|
|
2015
|
+
// the check without any teardown path having to remember to clear
|
|
2016
|
+
// the slot. Fixes the exit-then-reopen wedge where the boolean
|
|
2017
|
+
// latch survived `_exitTour` and silently dropped the next open's
|
|
2018
|
+
// first `_advanceTour`.
|
|
2019
|
+
if (this._advanceInFlightGen === this._tourGen) return;
|
|
2020
|
+
this._advanceInFlightGen = this._tourGen;
|
|
2021
|
+
// Capture the open-generation so we can detect a teardown (exit /
|
|
2022
|
+
// reopen) that happened while we were sitting on an await below.
|
|
2023
|
+
const startGen = this._tourGen;
|
|
2024
|
+
const isStale = () => this._tourGen !== startGen;
|
|
2025
|
+
try {
|
|
2026
|
+
const startIndex = this._tourActiveIndex + delta;
|
|
2027
|
+
const dir = delta >= 0 ? 1 : -1;
|
|
2028
|
+
|
|
2029
|
+
// Forward past the end (initial open uses delta=1 from -1, so this only
|
|
2030
|
+
// fires once we've actually reached the last stop and pressed Next again).
|
|
2031
|
+
if (startIndex >= total) {
|
|
2032
|
+
this._tourBar.setCompleted(true);
|
|
2033
|
+
this._tourBar.setActiveIndex(total - 1);
|
|
2034
|
+
this._syncTourToolbarButton();
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
// Probe-then-mount: locate the next mountable index WITHOUT unmounting
|
|
2039
|
+
// the current one. Only swap once we have a confirmed replacement. This
|
|
2040
|
+
// avoids the wedge where the current stop is torn down and no successor
|
|
2041
|
+
// mounts (file filtered out, scope change, etc.).
|
|
2042
|
+
let probe = Math.max(0, startIndex);
|
|
2043
|
+
let nextRow = null;
|
|
2044
|
+
let nextIndex = -1;
|
|
2045
|
+
while (probe >= 0 && probe < total) {
|
|
2046
|
+
// Skip re-probing the index that's already mounted — `mountStop` is
|
|
2047
|
+
// idempotent and returns the existing row, but we want to keep going
|
|
2048
|
+
// past the current active when delta moves us off it.
|
|
2049
|
+
if (probe !== this._tourActiveIndex) {
|
|
2050
|
+
// Prepare the stop first: add the file as a context file if it
|
|
2051
|
+
// isn't in the diff, and unfold any gap covering its line range.
|
|
2052
|
+
// prepareStop returning true is no guarantee mountStop will
|
|
2053
|
+
// succeed — genuinely missing data still falls through.
|
|
2054
|
+
await this._tourRenderer.prepareStop(probe);
|
|
2055
|
+
// Tour could have been exited (or re-opened) while prepareStop
|
|
2056
|
+
// was awaiting a file fetch / loadContextFiles. Bail before
|
|
2057
|
+
// mounting against a torn-down tour.
|
|
2058
|
+
if (isStale()) return;
|
|
2059
|
+
// mountStop is async (it awaits toggleFileCollapse so a collapsed
|
|
2060
|
+
// file is rendered + visibly expanded before we scroll to it).
|
|
2061
|
+
const row = await this._tourRenderer.mountStop(probe);
|
|
2062
|
+
// Re-check staleness: the await above is a suspension window.
|
|
2063
|
+
if (isStale()) return;
|
|
2064
|
+
if (row) {
|
|
2065
|
+
nextRow = row;
|
|
2066
|
+
nextIndex = probe;
|
|
2067
|
+
break;
|
|
2068
|
+
}
|
|
2069
|
+
} else if (dir > 0) {
|
|
2070
|
+
// Already-active probe under forward motion shouldn't count as a hit;
|
|
2071
|
+
// we want to advance past it.
|
|
2072
|
+
} else {
|
|
2073
|
+
// Backward delta landing on the current stop: nothing earlier mounted.
|
|
2074
|
+
break;
|
|
2075
|
+
}
|
|
2076
|
+
probe += dir;
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
if (!nextRow) {
|
|
2080
|
+
if (dir > 0) {
|
|
2081
|
+
// Forward exhaustion: flip to completion using the last successfully
|
|
2082
|
+
// mounted index. If we never mounted anything (initial open found no
|
|
2083
|
+
// mountable stops), bail out cleanly so the toolbar resets.
|
|
2084
|
+
if (this._tourActiveIndex < 0) {
|
|
2085
|
+
console.warn('[Tour] no mountable stops found; exiting');
|
|
2086
|
+
this._exitTour();
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
2089
|
+
this._tourBar.setCompleted(true);
|
|
2090
|
+
this._tourBar.setActiveIndex(this._tourActiveIndex);
|
|
2091
|
+
this._syncTourToolbarButton();
|
|
2092
|
+
return;
|
|
2093
|
+
}
|
|
2094
|
+
// Backward exhaustion: leave the current stop mounted/active untouched.
|
|
2095
|
+
console.debug('[Tour] no earlier mountable stop; staying put');
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
// Successful candidate — only now unmount the previous stop.
|
|
2100
|
+
if (this._tourActiveIndex >= 0 && this._tourActiveIndex !== nextIndex) {
|
|
2101
|
+
this._tourRenderer.unmountStop(this._tourActiveIndex);
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
this._tourActiveIndex = nextIndex;
|
|
2105
|
+
this._tourRenderer.highlightActive(nextIndex);
|
|
2106
|
+
this._tourRenderer.scrollToStop(nextIndex);
|
|
2107
|
+
this._tourBar.setCompleted(false);
|
|
2108
|
+
this._tourBar.setActiveIndex(nextIndex);
|
|
2109
|
+
this._syncTourToolbarButton();
|
|
2110
|
+
// Suppress unused-var lints; nextRow exists for symmetry with future
|
|
2111
|
+
// post-mount work (focus management, telemetry).
|
|
2112
|
+
void nextRow;
|
|
2113
|
+
} finally {
|
|
2114
|
+
// Only release the latch if we still own the slot. A teardown that
|
|
2115
|
+
// bumped `_tourGen` between entry and now has already invalidated
|
|
2116
|
+
// our holder — and a fresh generation may have taken the slot for
|
|
2117
|
+
// its own call. Clobbering it with `-1` would let two _advanceTour
|
|
2118
|
+
// calls run concurrently on the new generation.
|
|
2119
|
+
if (this._advanceInFlightGen === startGen) {
|
|
2120
|
+
this._advanceInFlightGen = -1;
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
/**
|
|
2126
|
+
* Tear down the tour: unmount every annotation, unmount the bar, drop the
|
|
2127
|
+
* body class, and unregister keyboard handlers.
|
|
2128
|
+
*/
|
|
2129
|
+
_exitTour() {
|
|
2130
|
+
// Bump generation FIRST so any in-flight `_advanceTour` (sitting on an
|
|
2131
|
+
// ensureContextFile / ensureLinesVisible await) sees the mismatch on
|
|
2132
|
+
// resume and bails instead of mutating state for a torn-down tour.
|
|
2133
|
+
this._tourGen += 1;
|
|
2134
|
+
let drain = Promise.resolve();
|
|
2135
|
+
if (this._tourRenderer) {
|
|
2136
|
+
// unmountAll fires-and-forgets context-file DELETEs but returns a
|
|
2137
|
+
// drain promise. Stash it on `_tourCleanupPending` so the next open
|
|
2138
|
+
// can await it before reading wrappers — otherwise the DELETE's
|
|
2139
|
+
// loadContextFiles reload can rip the new tour's wrapper out from
|
|
2140
|
+
// under an active stop.
|
|
2141
|
+
drain = this._tourRenderer.unmountAll();
|
|
2142
|
+
this._tourRenderer.setActive(false);
|
|
2143
|
+
}
|
|
2144
|
+
this._tourCleanupPending = drain;
|
|
2145
|
+
if (this._tourBar) {
|
|
2146
|
+
this._tourBar.unmount();
|
|
2147
|
+
}
|
|
2148
|
+
this._unregisterTourKeyboardHandlers();
|
|
2149
|
+
this._tourActiveIndex = -1;
|
|
2150
|
+
// Consume any pending tour stashed by `review:tour_ready` while we
|
|
2151
|
+
// were running. Next open uses the fresh stops.
|
|
2152
|
+
if (Array.isArray(this._tourStopsPendingRestart)) {
|
|
2153
|
+
this._tourStops = this._tourStopsPendingRestart;
|
|
2154
|
+
this._tourStopsPendingRestart = null;
|
|
2155
|
+
}
|
|
2156
|
+
this._syncTourToolbarButton();
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
/**
|
|
2160
|
+
* Exit, then re-open from stop 0. Async because `_openTourAtStart`
|
|
2161
|
+
* drains the prior tour's pending teardown (context-file DELETEs +
|
|
2162
|
+
* their loadContextFiles reloads) before reading wrappers, so the
|
|
2163
|
+
* fresh tour can't mount against a wrapper the old DELETE is about to
|
|
2164
|
+
* tear down. If a newer tour was stashed via `review:tour_ready`,
|
|
2165
|
+
* `_exitTour` swaps it in before we reopen.
|
|
2166
|
+
*
|
|
2167
|
+
* Caller (`onRestart` toolbar callback) is fire-and-forget; the
|
|
2168
|
+
* returned promise is for symmetry / testability.
|
|
2169
|
+
*
|
|
2170
|
+
* @returns {Promise<void>}
|
|
2171
|
+
*/
|
|
2172
|
+
async _restartTour() {
|
|
2173
|
+
this._exitTour();
|
|
2174
|
+
await this._openTourAtStart();
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
/**
|
|
2178
|
+
* Install the keyboard shortcut handler. Bound to `document` so it fires
|
|
2179
|
+
* regardless of focus, with a guard that skips when the user is typing
|
|
2180
|
+
* in a text field.
|
|
2181
|
+
*/
|
|
2182
|
+
_registerTourKeyboardHandlers() {
|
|
2183
|
+
if (this._tourKeydownHandler) return;
|
|
2184
|
+
const handler = (e) => {
|
|
2185
|
+
if (!this._tourIsActive()) return;
|
|
2186
|
+
|
|
2187
|
+
// Skip when the user is typing — text fields, contenteditable, etc.
|
|
2188
|
+
// Arrow keys move the caret; Escape is owned by the surrounding form.
|
|
2189
|
+
const target = e.target;
|
|
2190
|
+
const tag = target && target.tagName;
|
|
2191
|
+
const isEditable = tag === 'TEXTAREA' || tag === 'INPUT' || tag === 'SELECT' ||
|
|
2192
|
+
(target && (target.isContentEditable || target.contentEditable === 'true'));
|
|
2193
|
+
if (isEditable) return;
|
|
2194
|
+
|
|
2195
|
+
// Skip when a modal is open. Modals own their own Escape ladder
|
|
2196
|
+
// (close dropdown, blur, dismiss); we don't want to compete. Defer
|
|
2197
|
+
// to the shared ModalDetection utility so the selector list stays
|
|
2198
|
+
// in sync with KeyboardShortcuts.
|
|
2199
|
+
if (window.ModalDetection?.isModalOpen()) return;
|
|
2200
|
+
|
|
2201
|
+
// Skip ALL tour shortcuts when the chat panel is open. ChatPanel
|
|
2202
|
+
// binds its own document-level Escape handler with a ladder of
|
|
2203
|
+
// states (provider/session dropdown, streaming stop, blur input,
|
|
2204
|
+
// close panel). Arrow keys may also be in use by chat surfaces.
|
|
2205
|
+
// Yanking the tour out from under the user — by advancing OR
|
|
2206
|
+
// exiting — when they have the chat panel open would be surprising.
|
|
2207
|
+
const chatPanel = document.querySelector('.chat-panel.chat-panel--open');
|
|
2208
|
+
if (chatPanel) return;
|
|
2209
|
+
|
|
2210
|
+
if (e.key === 'ArrowRight') {
|
|
2211
|
+
e.preventDefault();
|
|
2212
|
+
this._advanceTour(1);
|
|
2213
|
+
} else if (e.key === 'ArrowLeft') {
|
|
2214
|
+
e.preventDefault();
|
|
2215
|
+
this._advanceTour(-1);
|
|
2216
|
+
} else if (e.key === 'Escape') {
|
|
2217
|
+
e.preventDefault();
|
|
2218
|
+
// Stop propagation so other Escape-bound listeners (chat panel
|
|
2219
|
+
// when it's closed-but-bound, future keyboard shortcuts) don't
|
|
2220
|
+
// also fire for the same key event.
|
|
2221
|
+
e.stopImmediatePropagation();
|
|
2222
|
+
this._exitTour();
|
|
2223
|
+
}
|
|
2224
|
+
};
|
|
2225
|
+
document.addEventListener('keydown', handler);
|
|
2226
|
+
this._tourKeydownHandler = handler;
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
_unregisterTourKeyboardHandlers() {
|
|
2230
|
+
if (!this._tourKeydownHandler) return;
|
|
2231
|
+
document.removeEventListener('keydown', this._tourKeydownHandler);
|
|
2232
|
+
this._tourKeydownHandler = null;
|
|
2233
|
+
}
|
|
2234
|
+
|
|
906
2235
|
/**
|
|
907
2236
|
* Listen for review-scoped CustomEvents dispatched by ChatPanel's
|
|
908
2237
|
* WebSocket pub/sub connection.
|
|
@@ -984,6 +2313,50 @@ class PRManager {
|
|
|
984
2313
|
await this.ensureLinesVisible([{ file, line_start, line_end, side: side || 'right' }]);
|
|
985
2314
|
});
|
|
986
2315
|
|
|
2316
|
+
document.addEventListener('review:hunk_summaries_ready', (e) => {
|
|
2317
|
+
if (e.detail?.reviewId !== reviewId()) return;
|
|
2318
|
+
// Per-file completion implies the review-level job is still working,
|
|
2319
|
+
// so reflect "generating" until `background_job_finished` clears it.
|
|
2320
|
+
// (No-op when the toolbar button is already pulsing.)
|
|
2321
|
+
if (!this._summariesGenerating) {
|
|
2322
|
+
this._summariesGenerating = true;
|
|
2323
|
+
this._syncSummaryToolbarButton();
|
|
2324
|
+
}
|
|
2325
|
+
this._applyHunkSummaries(e.detail.filePath, e.detail.summaries || []);
|
|
2326
|
+
});
|
|
2327
|
+
|
|
2328
|
+
// Tour-ready broadcasts arrive after the tour-generation job persists a
|
|
2329
|
+
// new tour. We refresh the cached stops in the background but do NOT
|
|
2330
|
+
// auto-open the tour — user must click the toolbar button. If a tour is
|
|
2331
|
+
// already mounted, the new stops are stashed for restart so we don't
|
|
2332
|
+
// yank the active tour out from under the user (v1 simple approach).
|
|
2333
|
+
document.addEventListener('review:tour_ready', (e) => {
|
|
2334
|
+
if (e.detail?.reviewId !== reviewId()) return;
|
|
2335
|
+
this._loadAndStashTour({ deferIfActive: true }).catch(() => {});
|
|
2336
|
+
});
|
|
2337
|
+
|
|
2338
|
+
document.addEventListener('review:background_job_finished', (e) => {
|
|
2339
|
+
if (e.detail?.reviewId !== reviewId()) return;
|
|
2340
|
+
const jobType = e.detail?.jobType || '';
|
|
2341
|
+
const isSummaries = jobType === 'summaries' || jobType.startsWith('summaries:');
|
|
2342
|
+
const isTour = jobType === 'tour' || jobType.startsWith('tour:');
|
|
2343
|
+
if (!isSummaries && !isTour) return;
|
|
2344
|
+
// The queue can host multiple `${type}:${digest}` jobs back-to-back
|
|
2345
|
+
// (refresh, scope change, whitespace toggle). The broadcast payload
|
|
2346
|
+
// carries `hasActiveForType` from the queue's view AFTER this job's
|
|
2347
|
+
// key was deleted, so a sibling job still in flight keeps the pulse
|
|
2348
|
+
// visible.
|
|
2349
|
+
if (e.detail?.hasActiveForType === true) return;
|
|
2350
|
+
if (isSummaries) {
|
|
2351
|
+
this._summariesGenerating = false;
|
|
2352
|
+
this._syncSummaryToolbarButton();
|
|
2353
|
+
}
|
|
2354
|
+
if (isTour) {
|
|
2355
|
+
this._tourGenerating = false;
|
|
2356
|
+
this._syncTourToolbarButton();
|
|
2357
|
+
}
|
|
2358
|
+
});
|
|
2359
|
+
|
|
987
2360
|
document.addEventListener('visibilitychange', () => {
|
|
988
2361
|
if (document.hidden) return;
|
|
989
2362
|
if (this._dirtyComments) { this._dirtyComments = false; this.loadUserComments(); }
|
|
@@ -1288,13 +2661,32 @@ class PRManager {
|
|
|
1288
2661
|
|
|
1289
2662
|
// Update Graphite link (gated on enable_graphite config)
|
|
1290
2663
|
const graphiteLink = document.getElementById('graphite-link');
|
|
1291
|
-
if (graphiteLink && pr.html_url && window.__pairReview?.enableGraphite
|
|
2664
|
+
if (graphiteLink && pr.html_url && window.__pairReview?.enableGraphite
|
|
2665
|
+
&& graphiteLink.dataset.suppressed !== 'true') {
|
|
1292
2666
|
// Derive from html_url to preserve GitHub's original casing (Graphite URLs are case-sensitive)
|
|
1293
2667
|
const graphiteUrl = window.__pairReview.toGraphiteUrl(pr.html_url);
|
|
1294
2668
|
graphiteLink.href = graphiteUrl;
|
|
1295
2669
|
graphiteLink.style.display = '';
|
|
1296
2670
|
}
|
|
1297
2671
|
|
|
2672
|
+
if (window.RepoLinks && pr.owner && pr.repo) {
|
|
2673
|
+
const linksApplied = window.RepoLinks.fetchAndApplyRepoLinks(pr.owner, pr.repo, {
|
|
2674
|
+
owner: pr.owner,
|
|
2675
|
+
repo: pr.repo,
|
|
2676
|
+
number: pr.number,
|
|
2677
|
+
branch: pr.head_branch,
|
|
2678
|
+
base_branch: pr.base_branch,
|
|
2679
|
+
head_sha: pr.head_sha,
|
|
2680
|
+
});
|
|
2681
|
+
// The repo links (incl. the configured host name) resolve asynchronously
|
|
2682
|
+
// after this synchronous render. Re-render the pending-draft indicator
|
|
2683
|
+
// once they land so it shows the configured host name (e.g. "Meteorite")
|
|
2684
|
+
// instead of the "GitHub" fallback it would otherwise bake in below.
|
|
2685
|
+
if (linksApplied && typeof linksApplied.then === 'function') {
|
|
2686
|
+
linksApplied.then(() => this.updatePendingDraftIndicator(pr.pendingDraft));
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
|
|
1298
2690
|
// Update settings link
|
|
1299
2691
|
const settingsLink = document.getElementById('settings-link');
|
|
1300
2692
|
if (settingsLink && pr.owner && pr.repo) {
|
|
@@ -1595,13 +2987,20 @@ class PRManager {
|
|
|
1595
2987
|
}
|
|
1596
2988
|
|
|
1597
2989
|
// Fetch settings in parallel
|
|
1598
|
-
const [repoSettings, reviewSettings] = await Promise.all([
|
|
2990
|
+
const [repoSettings, reviewSettings, appConfig] = await Promise.all([
|
|
1599
2991
|
this.fetchRepoSettings().catch(() => null),
|
|
1600
|
-
this.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null }))
|
|
2992
|
+
this.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null })),
|
|
2993
|
+
this._getAppConfig()
|
|
1601
2994
|
]);
|
|
1602
2995
|
|
|
1603
|
-
|
|
1604
|
-
|
|
2996
|
+
// Resolve provider and model as a MATCHED pair so the council/advanced tabs
|
|
2997
|
+
// are never seeded with a cross-provider model (e.g. gemini + opus), which
|
|
2998
|
+
// would blank the model <select> and be rejected by the backend.
|
|
2999
|
+
const providersInfo = await this._getProvidersInfo();
|
|
3000
|
+
const { provider: currentProvider, model: currentModel } = window.resolveProviderModelPair([
|
|
3001
|
+
{ provider: repoSettings?.default_provider, model: repoSettings?.default_model },
|
|
3002
|
+
{ provider: appConfig.default_provider, model: appConfig.default_model }
|
|
3003
|
+
], providersInfo);
|
|
1605
3004
|
const tabStorageKey = PRManager.getRepoStorageKey('pair-review-tab', owner, repo);
|
|
1606
3005
|
const rememberedTab = localStorage.getItem(tabStorageKey);
|
|
1607
3006
|
const defaultTab = rememberedTab || repoSettings?.default_tab || 'single';
|
|
@@ -1808,14 +3207,22 @@ class PRManager {
|
|
|
1808
3207
|
// Don't show if no pending draft
|
|
1809
3208
|
if (!pendingDraft) return;
|
|
1810
3209
|
|
|
3210
|
+
// Resolve the configured host name + URL (alt-host aware). Prefer the
|
|
3211
|
+
// URL built from the repo's url_template over the server-reported
|
|
3212
|
+
// github_url, which some alt-hosts return as a wrong-host github.com URL.
|
|
3213
|
+
const hostName = (window.RepoLinks && typeof window.RepoLinks.hostName === 'function')
|
|
3214
|
+
? window.RepoLinks.hostName() : 'GitHub';
|
|
3215
|
+
const externalUrl = (window.RepoLinks && typeof window.RepoLinks.externalUrl === 'function')
|
|
3216
|
+
? window.RepoLinks.externalUrl() : null;
|
|
3217
|
+
|
|
1811
3218
|
// Create the indicator
|
|
1812
3219
|
const indicator = document.createElement('a');
|
|
1813
3220
|
indicator.id = 'pending-draft-indicator';
|
|
1814
3221
|
indicator.className = 'pending-draft-indicator';
|
|
1815
|
-
indicator.href = pendingDraft.github_url || '#';
|
|
3222
|
+
indicator.href = externalUrl || pendingDraft.github_url || '#';
|
|
1816
3223
|
indicator.target = '_blank';
|
|
1817
3224
|
indicator.rel = 'noopener noreferrer';
|
|
1818
|
-
indicator.title =
|
|
3225
|
+
indicator.title = `View your pending draft review on ${hostName}`;
|
|
1819
3226
|
|
|
1820
3227
|
const commentCount = pendingDraft.comments_count || 0;
|
|
1821
3228
|
const commentText = commentCount === 1 ? '1 comment' : `${commentCount} comments`;
|
|
@@ -1824,7 +3231,7 @@ class PRManager {
|
|
|
1824
3231
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
|
1825
3232
|
<path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm5.03 2.22a.75.75 0 0 1 0 1.06L5.31 6.25l1.47 1.47a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-2-2a.75.75 0 0 1 0-1.06l2-2a.75.75 0 0 1 1.06 0Zm2.44 0a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l1.47-1.47-1.47-1.47a.75.75 0 0 1 0-1.06Z"/>
|
|
1826
3233
|
</svg>
|
|
1827
|
-
<span class="pending-draft-text">Draft on
|
|
3234
|
+
<span class="pending-draft-text">Draft on ${this.escapeHtml(hostName)} (${commentText})</span>
|
|
1828
3235
|
`;
|
|
1829
3236
|
|
|
1830
3237
|
// Insert after the commit element (or at the end of toolbar-meta)
|
|
@@ -1844,8 +3251,52 @@ class PRManager {
|
|
|
1844
3251
|
const diffContainer = document.getElementById('diff-container');
|
|
1845
3252
|
if (!diffContainer) return;
|
|
1846
3253
|
|
|
3254
|
+
// Tear down any active tour BEFORE wiping the diff DOM: unmountAll()
|
|
3255
|
+
// re-collapses files the tour auto-expanded by looking them up via
|
|
3256
|
+
// `.d2h-file-wrapper[data-file-name=...]`. If we cleared innerHTML
|
|
3257
|
+
// first those lookups would all miss, and the user's pre-tour
|
|
3258
|
+
// collapse state would be silently lost. (Mirrors the rationale for
|
|
3259
|
+
// hunkSummaryRenderer.reset below — anchor-based DOM state cannot
|
|
3260
|
+
// survive a re-render.)
|
|
3261
|
+
if (this._tourIsActive && this._tourIsActive()) {
|
|
3262
|
+
this._exitTour();
|
|
3263
|
+
}
|
|
3264
|
+
|
|
1847
3265
|
diffContainer.innerHTML = '';
|
|
1848
3266
|
|
|
3267
|
+
// Reset hunk-summary tracking — `renderPatch` will populate this as it
|
|
3268
|
+
// walks each block, and we hash the records once render finishes.
|
|
3269
|
+
this._pendingHunkRecords = [];
|
|
3270
|
+
if (this.hunkSummaryRenderer) {
|
|
3271
|
+
this.hunkSummaryRenderer.reset();
|
|
3272
|
+
}
|
|
3273
|
+
this._tourStops = null;
|
|
3274
|
+
this._summaryAnchorsByHash = new Map();
|
|
3275
|
+
this._summaryHashesByFile = new Map();
|
|
3276
|
+
this._pendingSummariesByHash = new Map();
|
|
3277
|
+
// Reset alongside the other per-render summary state. Set true again only
|
|
3278
|
+
// when a summary actually mounts (see _applyHunkSummaries / the existing-
|
|
3279
|
+
// summary fetch). Without this, a re-render whose subsequent fetch returns
|
|
3280
|
+
// no matching rows keeps the stale `true`, leaving the toolbar stuck in
|
|
3281
|
+
// Hide/Show mode with nothing in the DOM and blocking click-to-generate.
|
|
3282
|
+
this._summariesGenerated = false;
|
|
3283
|
+
// Reset alongside `_summariesGenerated`. Set true again only when a fetch
|
|
3284
|
+
// (or WS event) accepts/queues a non-trivial summary for this render.
|
|
3285
|
+
this._summariesAvailable = false;
|
|
3286
|
+
// Bump generation so any in-flight `_fetchHunkSummaryMap` from the
|
|
3287
|
+
// previous render bails out instead of mutating maps we just reset.
|
|
3288
|
+
this._renderGen = (this._renderGen || 0) + 1;
|
|
3289
|
+
|
|
3290
|
+
// Reset lazy-body state and (re)create the IntersectionObserver. The
|
|
3291
|
+
// `innerHTML = ''` above detached every previously-observed body, so a
|
|
3292
|
+
// stale observer would hold dead references and never fire — tear it down
|
|
3293
|
+
// and start fresh for this render generation. This runs for every render
|
|
3294
|
+
// path (initial load, whitespace toggle, scope change) since they all
|
|
3295
|
+
// funnel through renderDiff().
|
|
3296
|
+
this._teardownFileBodyObserver();
|
|
3297
|
+
this._lazyFileBodies = new Map();
|
|
3298
|
+
this._fileBodyObserver = this._createFileBodyObserver();
|
|
3299
|
+
|
|
1849
3300
|
// Use changed_files array from API
|
|
1850
3301
|
const files = pr.changed_files || pr.files || [];
|
|
1851
3302
|
|
|
@@ -1870,9 +3321,8 @@ class PRManager {
|
|
|
1870
3321
|
}
|
|
1871
3322
|
});
|
|
1872
3323
|
|
|
1873
|
-
//
|
|
1874
|
-
//
|
|
1875
|
-
this.validatePendingEofGaps();
|
|
3324
|
+
// NOTE: end-of-file gap validation runs per-file inside _renderFileBodyNow
|
|
3325
|
+
// now (bodies render lazily), not once globally here.
|
|
1876
3326
|
} else {
|
|
1877
3327
|
diffContainer.innerHTML = '<div class="no-diff">No files changed</div>';
|
|
1878
3328
|
}
|
|
@@ -1880,6 +3330,23 @@ class PRManager {
|
|
|
1880
3330
|
// Load context files after diff is rendered
|
|
1881
3331
|
this.contextFiles = [];
|
|
1882
3332
|
this.loadContextFiles();
|
|
3333
|
+
|
|
3334
|
+
// Fetch hunk summaries (Phase 5). Fire-and-forget — the diff is fully
|
|
3335
|
+
// usable while summaries arrive asynchronously. Anchors are wired lazily
|
|
3336
|
+
// as each file body renders (_registerHunkAnchorsForFile); this just loads
|
|
3337
|
+
// the server's summary map and applies it to whatever has rendered so far.
|
|
3338
|
+
if (this.hunkSummaryRenderer) {
|
|
3339
|
+
this._fetchHunkSummaryMap().catch((err) => {
|
|
3340
|
+
console.warn('[HunkSummary] summary fetch failed:', err);
|
|
3341
|
+
});
|
|
3342
|
+
}
|
|
3343
|
+
|
|
3344
|
+
// Probe tour endpoint after diff is rendered. `currentPR.id` is now
|
|
3345
|
+
// set (init()/LocalManager populates it before calling renderDiff),
|
|
3346
|
+
// so the toolbar button can reflect the right state.
|
|
3347
|
+
if (this._toursEnabled === true) {
|
|
3348
|
+
this._loadAndStashTour().catch(() => {});
|
|
3349
|
+
}
|
|
1883
3350
|
}
|
|
1884
3351
|
|
|
1885
3352
|
/**
|
|
@@ -1976,23 +3443,52 @@ class PRManager {
|
|
|
1976
3443
|
}
|
|
1977
3444
|
});
|
|
1978
3445
|
header.appendChild(fileChatBtn);
|
|
1979
|
-
}
|
|
1980
3446
|
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
3447
|
+
// Per-file hunk-summary toggle. Mirrors the toolbar toggle but scoped to
|
|
3448
|
+
// one file. Disabled (greyed) when no summaries exist yet for this file;
|
|
3449
|
+
// _applyHunkSummaries / _registerHunkAnchorsForFile re-enable it as soon
|
|
3450
|
+
// as hashes for this file are recorded (so a summary that arrives later
|
|
3451
|
+
// doesn't get hidden behind a permanently-disabled button).
|
|
3452
|
+
// Gated on `_summariesEnabled`: skipped entirely when /api/config has
|
|
3453
|
+
// already reported the feature is off; created hidden + revealed later
|
|
3454
|
+
// when config has not yet resolved.
|
|
3455
|
+
if (this._summariesEnabled !== false) {
|
|
3456
|
+
const summaryToggleBtn = document.createElement('button');
|
|
3457
|
+
summaryToggleBtn.className = 'file-header-summary-toggle';
|
|
3458
|
+
summaryToggleBtn.dataset.file = file.file;
|
|
3459
|
+
summaryToggleBtn.innerHTML = `
|
|
3460
|
+
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
|
|
3461
|
+
<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"/>
|
|
3462
|
+
</svg>
|
|
3463
|
+
`;
|
|
3464
|
+
|
|
3465
|
+
if (this._summariesEnabled !== true) {
|
|
3466
|
+
// Config still pending; hide until the gate resolves.
|
|
3467
|
+
summaryToggleBtn.classList.add('summary-toggle-pending');
|
|
3468
|
+
summaryToggleBtn.style.display = 'none';
|
|
3469
|
+
}
|
|
1984
3470
|
|
|
1985
|
-
|
|
3471
|
+
const fileIsHidden = this.summariesHiddenFiles?.has(file.file) || false;
|
|
3472
|
+
if (fileIsHidden) {
|
|
3473
|
+
wrapper.classList.add('summaries-hidden-file');
|
|
3474
|
+
}
|
|
3475
|
+
this._syncFileSummaryToggleButton(summaryToggleBtn, file.file);
|
|
1986
3476
|
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
tbody.appendChild(row);
|
|
3477
|
+
summaryToggleBtn.addEventListener('click', (e) => {
|
|
3478
|
+
e.stopPropagation();
|
|
3479
|
+
this.toggleFileSummaries(file.file, wrapper);
|
|
3480
|
+
});
|
|
3481
|
+
header.appendChild(summaryToggleBtn);
|
|
3482
|
+
}
|
|
1994
3483
|
}
|
|
1995
3484
|
|
|
3485
|
+
// Create diff table with an EMPTY tbody. The rows are NOT rendered here —
|
|
3486
|
+
// see the lazy-body machinery below. This is the core large-PR perf fix:
|
|
3487
|
+
// building + syntax-highlighting every line of every (often collapsed)
|
|
3488
|
+
// file up front froze the browser on big diffs.
|
|
3489
|
+
const table = document.createElement('table');
|
|
3490
|
+
table.className = 'd2h-diff-table';
|
|
3491
|
+
const tbody = document.createElement('tbody');
|
|
1996
3492
|
table.appendChild(tbody);
|
|
1997
3493
|
|
|
1998
3494
|
// Wrap table in a scrollable container for horizontal scroll of long code lines
|
|
@@ -2002,22 +3498,235 @@ class PRManager {
|
|
|
2002
3498
|
fileBody.appendChild(table);
|
|
2003
3499
|
wrapper.appendChild(fileBody);
|
|
2004
3500
|
|
|
3501
|
+
// Reserve approximate height for EXPANDED-but-not-yet-rendered bodies so
|
|
3502
|
+
// the scrollbar stays roughly stable as the user scrolls and bodies fill
|
|
3503
|
+
// in. Collapsed bodies are `display:none`, so they contribute no height
|
|
3504
|
+
// and need no placeholder. Cleared once the body actually renders.
|
|
3505
|
+
if (!isCollapsed && file.patch) {
|
|
3506
|
+
const approxLines = file.patch.split('\n').length;
|
|
3507
|
+
fileBody.style.minHeight = (approxLines * PRManager.APPROX_DIFF_LINE_PX) + 'px';
|
|
3508
|
+
}
|
|
3509
|
+
|
|
3510
|
+
// Register the lazy entry. `gen` lets _renderFileBodyNow detect a body
|
|
3511
|
+
// left over from a superseded render and skip anchor registration.
|
|
3512
|
+
this._lazyFileBodies.set(file.file, {
|
|
3513
|
+
fileName: file.file,
|
|
3514
|
+
patch: file.patch || null,
|
|
3515
|
+
binary: !!file.binary,
|
|
3516
|
+
hunkHashes: file.hunk_hashes || null,
|
|
3517
|
+
fileBody,
|
|
3518
|
+
wrapper,
|
|
3519
|
+
rendered: false,
|
|
3520
|
+
renderPromise: null,
|
|
3521
|
+
gen: this._renderGen
|
|
3522
|
+
});
|
|
3523
|
+
|
|
3524
|
+
// Observe the body so it renders as it nears the viewport. Collapsed
|
|
3525
|
+
// bodies (display:none) never intersect → stay unrendered until expanded.
|
|
3526
|
+
if ((file.patch || file.binary) && this._fileBodyObserver) {
|
|
3527
|
+
this._fileBodyObserver.observe(fileBody);
|
|
3528
|
+
}
|
|
3529
|
+
|
|
2005
3530
|
return wrapper;
|
|
2006
3531
|
}
|
|
2007
3532
|
|
|
3533
|
+
/**
|
|
3534
|
+
* Approximate rendered height (px) of one diff line row. Used only to
|
|
3535
|
+
* reserve placeholder height for expanded-but-unrendered file bodies so the
|
|
3536
|
+
* scrollbar doesn't jump as lazy bodies fill in. Errs slightly high.
|
|
3537
|
+
*/
|
|
3538
|
+
static get APPROX_DIFF_LINE_PX() { return 20; }
|
|
3539
|
+
|
|
3540
|
+
/**
|
|
3541
|
+
* Create the IntersectionObserver that renders file bodies as they approach
|
|
3542
|
+
* the viewport. One instance per render generation; recreated in renderDiff.
|
|
3543
|
+
* Returns null where IntersectionObserver is unavailable (e.g. jsdom unit
|
|
3544
|
+
* tests) — bodies then render only on demand via ensureFileBodyRendered().
|
|
3545
|
+
* @returns {IntersectionObserver|null}
|
|
3546
|
+
*/
|
|
3547
|
+
_createFileBodyObserver() {
|
|
3548
|
+
if (typeof IntersectionObserver === 'undefined') return null;
|
|
3549
|
+
// `.diff-view` is the vertical scroll container (see pr.css). Fall back to
|
|
3550
|
+
// the viewport when it can't be resolved.
|
|
3551
|
+
const root = document.querySelector('.diff-view') || null;
|
|
3552
|
+
return new IntersectionObserver((entries, observer) => {
|
|
3553
|
+
for (const ioEntry of entries) {
|
|
3554
|
+
if (!ioEntry.isIntersecting) continue;
|
|
3555
|
+
const lazyEntry = this._lazyFileBodyForElement(ioEntry.target);
|
|
3556
|
+
observer.unobserve(ioEntry.target);
|
|
3557
|
+
if (lazyEntry) this._renderFileBodyNow(lazyEntry);
|
|
3558
|
+
}
|
|
3559
|
+
}, { root, rootMargin: '800px 0px', threshold: 0 });
|
|
3560
|
+
}
|
|
3561
|
+
|
|
3562
|
+
/**
|
|
3563
|
+
* Disconnect and drop the current file-body observer, if any.
|
|
3564
|
+
*/
|
|
3565
|
+
_teardownFileBodyObserver() {
|
|
3566
|
+
if (this._fileBodyObserver) {
|
|
3567
|
+
this._fileBodyObserver.disconnect();
|
|
3568
|
+
this._fileBodyObserver = null;
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3572
|
+
/**
|
|
3573
|
+
* Resolve the lazy entry for a `.d2h-file-body` element via its wrapper's
|
|
3574
|
+
* data-file-name. Returns null if not a known lazy body.
|
|
3575
|
+
* @param {Element} bodyEl
|
|
3576
|
+
* @returns {object|null}
|
|
3577
|
+
*/
|
|
3578
|
+
_lazyFileBodyForElement(bodyEl) {
|
|
3579
|
+
if (!this._lazyFileBodies) return null;
|
|
3580
|
+
const wrapper = bodyEl?.closest?.('.d2h-file-wrapper');
|
|
3581
|
+
const filePath = wrapper?.dataset?.fileName;
|
|
3582
|
+
if (!filePath) return null;
|
|
3583
|
+
return this._lazyFileBodies.get(filePath) || null;
|
|
3584
|
+
}
|
|
3585
|
+
|
|
3586
|
+
/**
|
|
3587
|
+
* Ensure a file's diff-line body is rendered into the DOM, rendering it now
|
|
3588
|
+
* if it hasn't been. Idempotent and cheap when already rendered. Every code
|
|
3589
|
+
* path that scans a file's `<tr>` rows (comment/suggestion anchoring, gap
|
|
3590
|
+
* expansion, scroll-to-file, expand) must await this first, because a lazy
|
|
3591
|
+
* body has zero rows until rendered.
|
|
3592
|
+
* @param {string|Element} fileOrWrapper - file path, file body, or wrapper
|
|
3593
|
+
* @returns {Promise<HTMLElement|null>} the file body, or null if unknown
|
|
3594
|
+
*/
|
|
3595
|
+
async ensureFileBodyRendered(fileOrWrapper) {
|
|
3596
|
+
// No lazy map (e.g. called before renderDiff, or a non-lazy render path) →
|
|
3597
|
+
// treat the body as already present and let callers scan as before.
|
|
3598
|
+
if (!this._lazyFileBodies) return null;
|
|
3599
|
+
let entry = null;
|
|
3600
|
+
if (typeof fileOrWrapper === 'string') {
|
|
3601
|
+
entry = this._lazyFileBodies.get(fileOrWrapper) || null;
|
|
3602
|
+
if (!entry) {
|
|
3603
|
+
// The map is keyed by the canonical `file.file` value, but callers
|
|
3604
|
+
// (AI suggestions, external comments, tour stops) may pass a
|
|
3605
|
+
// non-canonical path form. findFileElement normalizes './', '/', and
|
|
3606
|
+
// git rename syntax ('{old => new}') against data-file-name, so
|
|
3607
|
+
// resolve the wrapper first and retry with its canonical name. This
|
|
3608
|
+
// keeps the strict Map.get fast path while tolerating the same path
|
|
3609
|
+
// variants the rest of the diff UI accepts — without it the body
|
|
3610
|
+
// stays unrendered and the downstream row scan sees zero <tr> rows.
|
|
3611
|
+
const wrapper = this.findFileElement?.(fileOrWrapper);
|
|
3612
|
+
const canonicalFile = wrapper?.dataset?.fileName;
|
|
3613
|
+
if (canonicalFile) entry = this._lazyFileBodies.get(canonicalFile) || null;
|
|
3614
|
+
}
|
|
3615
|
+
} else if (fileOrWrapper && fileOrWrapper.nodeType === 1) {
|
|
3616
|
+
entry = this._lazyFileBodyForElement(fileOrWrapper)
|
|
3617
|
+
|| (this._lazyFileBodies.get(fileOrWrapper.dataset?.fileName) || null);
|
|
3618
|
+
}
|
|
3619
|
+
if (!entry) return null;
|
|
3620
|
+
if (entry.rendered) return entry.fileBody;
|
|
3621
|
+
if (entry.renderPromise) return entry.renderPromise;
|
|
3622
|
+
// _renderFileBodyNow is synchronous; wrapping in a resolved promise keeps
|
|
3623
|
+
// the signature async and lets concurrent callers (observer + on-demand)
|
|
3624
|
+
// share one promise so renderPatch runs exactly once.
|
|
3625
|
+
entry.renderPromise = Promise.resolve().then(() => this._renderFileBodyNow(entry));
|
|
3626
|
+
return entry.renderPromise;
|
|
3627
|
+
}
|
|
3628
|
+
|
|
3629
|
+
/**
|
|
3630
|
+
* Synchronously build a file's diff-line body: run renderPatch (or the
|
|
3631
|
+
* binary placeholder), clear the height placeholder, wire hunk-summary
|
|
3632
|
+
* anchors, and validate this file's EOF gap. Idempotent.
|
|
3633
|
+
*
|
|
3634
|
+
* Invariant: there must be NO `await` between the `_pendingHunkRecords`
|
|
3635
|
+
* save and restore below — renderPatch pushes per-hunk anchor records into
|
|
3636
|
+
* the shared `_pendingHunkRecords`, and JS single-threading only guarantees
|
|
3637
|
+
* non-interleaving with other file renders while this stays synchronous.
|
|
3638
|
+
* @param {object} entry - a _lazyFileBodies value
|
|
3639
|
+
* @returns {HTMLElement} the file body element
|
|
3640
|
+
*/
|
|
3641
|
+
_renderFileBodyNow(entry) {
|
|
3642
|
+
if (entry.rendered) return entry.fileBody;
|
|
3643
|
+
if (this._fileBodyObserver) this._fileBodyObserver.unobserve(entry.fileBody);
|
|
3644
|
+
|
|
3645
|
+
const tbody = entry.fileBody.querySelector('tbody');
|
|
3646
|
+
|
|
3647
|
+
// Capture only THIS file's hunk anchor records (renderPatch appends to
|
|
3648
|
+
// this._pendingHunkRecords; see invariant above). The swap is wrapped in
|
|
3649
|
+
// try/finally so a renderPatch throw can't leak the temporary buffer: were
|
|
3650
|
+
// the restore skipped, every subsequent file render would append its
|
|
3651
|
+
// anchor records into the stale array, silently cross-wiring
|
|
3652
|
+
// _summaryAnchorsByHash / _summaryHashesByFile for the rest of the session.
|
|
3653
|
+
const prevPending = this._pendingHunkRecords;
|
|
3654
|
+
this._pendingHunkRecords = [];
|
|
3655
|
+
let records;
|
|
3656
|
+
try {
|
|
3657
|
+
if (entry.patch && tbody) {
|
|
3658
|
+
this.renderPatch(tbody, entry.patch, entry.fileName, entry.hunkHashes);
|
|
3659
|
+
} else if (entry.binary && tbody) {
|
|
3660
|
+
const row = document.createElement('tr');
|
|
3661
|
+
row.innerHTML = '<td colspan="2" class="binary-file">Binary file</td>';
|
|
3662
|
+
tbody.appendChild(row);
|
|
3663
|
+
}
|
|
3664
|
+
} finally {
|
|
3665
|
+
records = this._pendingHunkRecords;
|
|
3666
|
+
this._pendingHunkRecords = prevPending;
|
|
3667
|
+
}
|
|
3668
|
+
|
|
3669
|
+
entry.fileBody.style.minHeight = '';
|
|
3670
|
+
entry.rendered = true;
|
|
3671
|
+
entry.renderPromise = null;
|
|
3672
|
+
|
|
3673
|
+
// Skip post-render wiring for a body left over from a superseded render
|
|
3674
|
+
// (its maps were reset and the body is detached). Both anchor registration
|
|
3675
|
+
// and EOF-gap validation are pointless/wasteful for a stale body.
|
|
3676
|
+
if (entry.gen === this._renderGen) {
|
|
3677
|
+
this._registerHunkAnchorsForFile(records);
|
|
3678
|
+
// Validate this file's pending EOF gaps. Pre-lazy-render this was a
|
|
3679
|
+
// single global pass at the end of renderDiff; now it runs per-file as
|
|
3680
|
+
// bodies render. Cheap pre-check first: most files have no pending EOF
|
|
3681
|
+
// gap, so skip the async work (Array.from + Promise.all + per-gap
|
|
3682
|
+
// /file-content fetches) entirely when this body has none. The selector
|
|
3683
|
+
// mirrors the one validatePendingEofGaps scans for.
|
|
3684
|
+
if (entry.fileBody.querySelector('tr.context-expand-row[data-pending-eof-validation="true"]')) {
|
|
3685
|
+
this.validatePendingEofGaps(entry.fileBody);
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3688
|
+
|
|
3689
|
+
return entry.fileBody;
|
|
3690
|
+
}
|
|
3691
|
+
|
|
2008
3692
|
/**
|
|
2009
3693
|
* Parse and render a unified diff patch
|
|
2010
3694
|
* @param {HTMLElement} tbody - Table body element
|
|
2011
3695
|
* @param {string} patch - Unified diff patch string
|
|
2012
3696
|
* @param {string} fileName - File name
|
|
3697
|
+
* @param {string[]|null} [hunkHashes] - Per-hunk content hashes parallel
|
|
3698
|
+
* to the order `parseDiffIntoBlocks` returns hunks. When supplied, these
|
|
3699
|
+
* are used instead of computing client-side hashes. Computed by the
|
|
3700
|
+
* backend from the canonical (non-whitespace-filtered) diff so they
|
|
3701
|
+
* stay aligned with persisted summary keys.
|
|
2013
3702
|
*/
|
|
2014
|
-
renderPatch(tbody, patch, fileName) {
|
|
3703
|
+
renderPatch(tbody, patch, fileName, hunkHashes = null) {
|
|
2015
3704
|
let diffPosition = 0; // GitHub diff_position (1-indexed, consecutive)
|
|
2016
3705
|
let prevBlockEnd = { old: 0, new: 0 };
|
|
2017
3706
|
let isFirstHunk = true;
|
|
2018
3707
|
|
|
2019
3708
|
const blocks = window.HunkParser.parseDiffIntoBlocks(patch);
|
|
2020
3709
|
|
|
3710
|
+
// Defend against length drift between server-supplied (canonical) hashes
|
|
3711
|
+
// and the rendered (possibly whitespace-filtered) blocks: under `?w=1`,
|
|
3712
|
+
// `git diff -w` can drop or merge whitespace-only hunks so the canonical
|
|
3713
|
+
// and rendered hunk counts diverge. Misaligned hashes would write the
|
|
3714
|
+
// wrong canonical hash onto every block after the first dropped hunk,
|
|
3715
|
+
// anchoring summaries to the wrong rendered hunk. Fail closed: drop the
|
|
3716
|
+
// hashes for this file. Summaries then simply won't anchor — visibly
|
|
3717
|
+
// missing rather than visibly wrong.
|
|
3718
|
+
if (Array.isArray(hunkHashes) && hunkHashes.length !== blocks.length) {
|
|
3719
|
+
if (!this._warnedHunkHashLengthMismatch) {
|
|
3720
|
+
this._warnedHunkHashLengthMismatch = true;
|
|
3721
|
+
console.warn(
|
|
3722
|
+
`[HunkSummary] hunk_hashes length mismatch for ${fileName}: ` +
|
|
3723
|
+
`${hunkHashes.length} canonical hashes, ${blocks.length} rendered ` +
|
|
3724
|
+
'blocks. Dropping hashes for this file.'
|
|
3725
|
+
);
|
|
3726
|
+
}
|
|
3727
|
+
hunkHashes = null;
|
|
3728
|
+
}
|
|
3729
|
+
|
|
2021
3730
|
// Render blocks with gap sections
|
|
2022
3731
|
blocks.forEach((block, blockIndex) => {
|
|
2023
3732
|
diffPosition++; // Hunk header counts as a position
|
|
@@ -2097,6 +3806,7 @@ class PRManager {
|
|
|
2097
3806
|
let oldLineNum = block.oldStart;
|
|
2098
3807
|
let newLineNum = block.newStart;
|
|
2099
3808
|
|
|
3809
|
+
let firstLineRow = null;
|
|
2100
3810
|
block.lines.forEach(line => {
|
|
2101
3811
|
if (!line && line !== '') return; // Skip undefined
|
|
2102
3812
|
|
|
@@ -2126,8 +3836,24 @@ class PRManager {
|
|
|
2126
3836
|
};
|
|
2127
3837
|
|
|
2128
3838
|
this.renderDiffLine(tbody, lineData, fileName, diffPosition);
|
|
3839
|
+
if (!firstLineRow) firstLineRow = tbody.lastElementChild;
|
|
2129
3840
|
});
|
|
2130
3841
|
|
|
3842
|
+
// Record this hunk's first rendered code row as the anchor for any
|
|
3843
|
+
// inline summary annotation. The canonical hash comes from the
|
|
3844
|
+
// backend (`hunkHashes[blockIndex]`); _registerHunkAnchorsForFile mounts
|
|
3845
|
+
// it as `data-hunk-start` when this file's body renders so the summary
|
|
3846
|
+
// renderer can find the anchor and insert the annotation above it.
|
|
3847
|
+
if (this._pendingHunkRecords && firstLineRow) {
|
|
3848
|
+
const serverHash = Array.isArray(hunkHashes) ? hunkHashes[blockIndex] || null : null;
|
|
3849
|
+
this._pendingHunkRecords.push({
|
|
3850
|
+
file: fileName,
|
|
3851
|
+
header: block.header,
|
|
3852
|
+
anchorRow: firstLineRow,
|
|
3853
|
+
contentHash: serverHash
|
|
3854
|
+
});
|
|
3855
|
+
}
|
|
3856
|
+
|
|
2131
3857
|
// Update previous block end coordinates
|
|
2132
3858
|
const endBounds = window.HunkParser.getBlockCoordinateBounds(
|
|
2133
3859
|
{ lines: this.parseBlockLines(block) },
|
|
@@ -2300,7 +4026,7 @@ class PRManager {
|
|
|
2300
4026
|
* Toggle collapse state of a file diff
|
|
2301
4027
|
* @param {string} filePath - Path of the file
|
|
2302
4028
|
*/
|
|
2303
|
-
toggleFileCollapse(filePath) {
|
|
4029
|
+
async toggleFileCollapse(filePath) {
|
|
2304
4030
|
const wrapper = this.findFileElement(filePath);
|
|
2305
4031
|
if (!wrapper) return;
|
|
2306
4032
|
|
|
@@ -2308,6 +4034,8 @@ class PRManager {
|
|
|
2308
4034
|
const header = wrapper.querySelector('.d2h-file-header');
|
|
2309
4035
|
|
|
2310
4036
|
if (isCollapsed) {
|
|
4037
|
+
// Render the body before revealing it (lazy bodies are empty until now).
|
|
4038
|
+
await this.ensureFileBodyRendered(filePath);
|
|
2311
4039
|
wrapper.classList.remove('collapsed');
|
|
2312
4040
|
this.collapsedFiles.delete(filePath);
|
|
2313
4041
|
} else {
|
|
@@ -2326,7 +4054,7 @@ class PRManager {
|
|
|
2326
4054
|
* @param {string} filePath - Path of the file
|
|
2327
4055
|
* @param {boolean} isViewed - Whether the file is now viewed
|
|
2328
4056
|
*/
|
|
2329
|
-
toggleFileViewed(filePath, isViewed) {
|
|
4057
|
+
async toggleFileViewed(filePath, isViewed) {
|
|
2330
4058
|
const wrapper = this.findFileElement(filePath);
|
|
2331
4059
|
|
|
2332
4060
|
if (isViewed) {
|
|
@@ -2344,6 +4072,8 @@ class PRManager {
|
|
|
2344
4072
|
this.viewedFiles.delete(filePath);
|
|
2345
4073
|
// Auto-expand when unchecking viewed (match GitHub behavior)
|
|
2346
4074
|
if (wrapper && wrapper.classList.contains('collapsed')) {
|
|
4075
|
+
// Render the body before revealing it (lazy bodies are empty until now).
|
|
4076
|
+
await this.ensureFileBodyRendered(filePath);
|
|
2347
4077
|
wrapper.classList.remove('collapsed');
|
|
2348
4078
|
this.collapsedFiles.delete(filePath);
|
|
2349
4079
|
const header = wrapper.querySelector('.d2h-file-header');
|
|
@@ -2469,7 +4199,7 @@ class PRManager {
|
|
|
2469
4199
|
* @deprecated Use toggleFileCollapse instead - kept for backward compatibility
|
|
2470
4200
|
*/
|
|
2471
4201
|
toggleGeneratedFile(filePath) {
|
|
2472
|
-
this.toggleFileCollapse(filePath);
|
|
4202
|
+
return this.toggleFileCollapse(filePath);
|
|
2473
4203
|
}
|
|
2474
4204
|
|
|
2475
4205
|
/**
|
|
@@ -2498,9 +4228,12 @@ class PRManager {
|
|
|
2498
4228
|
* Validate pending end-of-file gaps asynchronously
|
|
2499
4229
|
* Removes gap rows where there are no trailing lines to expand
|
|
2500
4230
|
* This ensures users don't see expand buttons that do nothing
|
|
4231
|
+
* @param {ParentNode} [root=document] - Scope the search. With lazy bodies,
|
|
4232
|
+
* _renderFileBodyNow passes the just-rendered file body so each file's EOF
|
|
4233
|
+
* gap is validated as it renders (rather than one global post-render pass).
|
|
2501
4234
|
*/
|
|
2502
|
-
async validatePendingEofGaps() {
|
|
2503
|
-
const pendingGaps =
|
|
4235
|
+
async validatePendingEofGaps(root = document) {
|
|
4236
|
+
const pendingGaps = root.querySelectorAll('tr.context-expand-row[data-pending-eof-validation="true"]');
|
|
2504
4237
|
|
|
2505
4238
|
// Process all pending gaps in parallel for efficiency
|
|
2506
4239
|
const validationPromises = Array.from(pendingGaps).map(async (gapRow) => {
|
|
@@ -2865,11 +4598,14 @@ class PRManager {
|
|
|
2865
4598
|
return false;
|
|
2866
4599
|
}
|
|
2867
4600
|
|
|
4601
|
+
// Render the body first — gap rows (and code rows) only exist once the
|
|
4602
|
+
// lazy body has rendered. Without this the gap query below returns nothing.
|
|
4603
|
+
await this.ensureFileBodyRendered(file);
|
|
4604
|
+
|
|
2868
4605
|
// Check if file is collapsed (generated files)
|
|
2869
4606
|
if (fileElement.classList.contains('collapsed')) {
|
|
2870
4607
|
debugLog?.('expandForSuggestion', 'File is collapsed, expanding first');
|
|
2871
|
-
this.toggleGeneratedFile(file);
|
|
2872
|
-
await new Promise(resolve => setTimeout(resolve, 50));
|
|
4608
|
+
await this.toggleGeneratedFile(file);
|
|
2873
4609
|
}
|
|
2874
4610
|
|
|
2875
4611
|
// Find the gap section containing the target lines using the shared module
|
|
@@ -2953,6 +4689,12 @@ class PRManager {
|
|
|
2953
4689
|
const fileElement = this.findFileElement(file);
|
|
2954
4690
|
if (!fileElement) continue;
|
|
2955
4691
|
|
|
4692
|
+
// Render the file body first — with lazy rendering an unrendered file
|
|
4693
|
+
// has zero rows, so the visibility scan below would always miss and the
|
|
4694
|
+
// line would be treated as "hidden in a gap" (then gap-expanded against
|
|
4695
|
+
// zero gap rows → silent anchor failure).
|
|
4696
|
+
await this.ensureFileBodyRendered(file);
|
|
4697
|
+
|
|
2956
4698
|
// Check if any line in the range is already visible
|
|
2957
4699
|
let anyLineVisible = false;
|
|
2958
4700
|
const lineRows = fileElement.querySelectorAll('tr');
|
|
@@ -4627,9 +6369,20 @@ class PRManager {
|
|
|
4627
6369
|
collapsedBtn.addEventListener('click', () => toggleSidebar(false));
|
|
4628
6370
|
}
|
|
4629
6371
|
|
|
4630
|
-
scrollToFile(filePath) {
|
|
6372
|
+
async scrollToFile(filePath) {
|
|
4631
6373
|
const fileWrapper = this.findFileElement(filePath);
|
|
4632
6374
|
if (fileWrapper) {
|
|
6375
|
+
// Render the body so the scroll target has its real height (an empty
|
|
6376
|
+
// lazy body would land the scroll at the wrong offset for expanded
|
|
6377
|
+
// files). Skip it for collapsed files: their body is display:none
|
|
6378
|
+
// (zero height) and `block: 'start'` aligns the header regardless, so
|
|
6379
|
+
// force-rendering would only pay the full renderPatch + per-line
|
|
6380
|
+
// highlight cost for content that stays hidden. scrollToFile does no
|
|
6381
|
+
// post-render row scan, so gating on `!collapsed` is safe; expanding
|
|
6382
|
+
// later still renders on demand via toggleFileCollapse.
|
|
6383
|
+
if (!fileWrapper.classList.contains('collapsed')) {
|
|
6384
|
+
await this.ensureFileBodyRendered(filePath);
|
|
6385
|
+
}
|
|
4633
6386
|
fileWrapper.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
4634
6387
|
}
|
|
4635
6388
|
}
|
|
@@ -5054,11 +6807,15 @@ class PRManager {
|
|
|
5054
6807
|
: this._fetchStaleness(owner, repo, number);
|
|
5055
6808
|
this._stalenessPromise = null; // consume it
|
|
5056
6809
|
|
|
6810
|
+
// Pass owner+repo to /api/config so has_github_token reflects the
|
|
6811
|
+
// repo's actual auth (covers repo-scoped tokens, token_command, and
|
|
6812
|
+
// alt-host bindings — not just the global github_token).
|
|
6813
|
+
const configUrl = `/api/config?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`;
|
|
5057
6814
|
const [staleResult, repoSettings, reviewSettings, appConfig] = await Promise.all([
|
|
5058
6815
|
staleCheckWithTimeout,
|
|
5059
6816
|
this.fetchRepoSettings(),
|
|
5060
6817
|
this.fetchLastReviewSettings(),
|
|
5061
|
-
fetch(
|
|
6818
|
+
fetch(configUrl).then(r => r.ok ? r.json() : {}).catch(() => ({}))
|
|
5062
6819
|
]);
|
|
5063
6820
|
console.debug(`[Analyze] parallel-fetch (stale+settings): ${Math.round(performance.now() - _tParallel0)}ms`);
|
|
5064
6821
|
|
|
@@ -5104,9 +6861,14 @@ class PRManager {
|
|
|
5104
6861
|
|
|
5105
6862
|
const lastCouncilId = reviewSettings.last_council_id;
|
|
5106
6863
|
|
|
5107
|
-
//
|
|
5108
|
-
|
|
5109
|
-
|
|
6864
|
+
// Resolve provider and model as a MATCHED pair so the council/advanced tabs
|
|
6865
|
+
// are never seeded with a cross-provider model (e.g. gemini + opus), which
|
|
6866
|
+
// would blank the model <select> and be rejected by the backend.
|
|
6867
|
+
const providersInfo = await this._getProvidersInfo();
|
|
6868
|
+
const { provider: currentProvider, model: currentModel } = window.resolveProviderModelPair([
|
|
6869
|
+
{ provider: repoSettings?.default_provider, model: repoSettings?.default_model },
|
|
6870
|
+
{ provider: appConfig.default_provider, model: appConfig.default_model }
|
|
6871
|
+
], providersInfo);
|
|
5110
6872
|
|
|
5111
6873
|
// Determine default tab (priority: localStorage > repo settings > 'single')
|
|
5112
6874
|
const tabStorageKey = PRManager.getRepoStorageKey('pair-review-tab', owner, repo);
|
|
@@ -5134,7 +6896,12 @@ class PRManager {
|
|
|
5134
6896
|
lastCouncilId,
|
|
5135
6897
|
defaultCouncilId: repoSettings?.default_council_id || null,
|
|
5136
6898
|
hasPr: true,
|
|
5137
|
-
|
|
6899
|
+
// Use the repo-aware field (we passed owner+repo to /api/config).
|
|
6900
|
+
// Fall back to the global field only if the response was malformed
|
|
6901
|
+
// or the params were rejected — defensive, should not happen.
|
|
6902
|
+
hasGithubToken: Boolean(
|
|
6903
|
+
appConfig.has_github_token ?? appConfig.has_global_github_token
|
|
6904
|
+
)
|
|
5138
6905
|
});
|
|
5139
6906
|
|
|
5140
6907
|
// If user cancelled, do nothing
|
|
@@ -5280,7 +7047,10 @@ class PRManager {
|
|
|
5280
7047
|
showWorktreeNotFoundError(owner, repo, number) {
|
|
5281
7048
|
let setupUrl = `/pr/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(number)}`;
|
|
5282
7049
|
if (this._autoAnalyzeRequested) {
|
|
5283
|
-
|
|
7050
|
+
const params = new URLSearchParams({ analyze: 'true' });
|
|
7051
|
+
const analysisConfigId = new URLSearchParams(window.location.search).get('analysisConfigId');
|
|
7052
|
+
if (analysisConfigId) params.set('analysisConfigId', analysisConfigId);
|
|
7053
|
+
setupUrl += `?${params.toString()}`;
|
|
5284
7054
|
}
|
|
5285
7055
|
const container = document.getElementById('pr-container');
|
|
5286
7056
|
if (container) {
|