@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/index.html
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
26
26
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
27
27
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
28
|
+
<link rel="stylesheet" href="/css/analysis-config.css">
|
|
28
29
|
|
|
29
30
|
<style>
|
|
30
31
|
:root {
|
|
@@ -1577,8 +1578,18 @@
|
|
|
1577
1578
|
</div>
|
|
1578
1579
|
|
|
1579
1580
|
<script src="/js/ws-client.js"></script>
|
|
1581
|
+
<script src="/js/utils/markdown.js"></script>
|
|
1582
|
+
<script src="/js/utils/tier-icons.js"></script>
|
|
1583
|
+
<script src="/js/utils/storage-keys.js"></script>
|
|
1584
|
+
<script src="/js/utils/provider-model.js"></script>
|
|
1580
1585
|
<script src="/js/components/Toast.js"></script>
|
|
1581
1586
|
<script src="/js/components/UpdateBanner.js"></script>
|
|
1587
|
+
<script src="/js/components/ConfirmDialog.js"></script>
|
|
1588
|
+
<script src="/js/components/TextInputDialog.js"></script>
|
|
1589
|
+
<script src="/js/components/TimeoutSelect.js"></script>
|
|
1590
|
+
<script src="/js/components/AnalysisConfigModal.js"></script>
|
|
1591
|
+
<script src="/js/components/VoiceCentricConfigTab.js"></script>
|
|
1592
|
+
<script src="/js/components/AdvancedConfigTab.js"></script>
|
|
1582
1593
|
<script src="/js/index.js"></script>
|
|
1583
1594
|
</body>
|
|
1584
1595
|
</html>
|
|
@@ -1130,9 +1130,19 @@ class AIPanel {
|
|
|
1130
1130
|
}
|
|
1131
1131
|
|
|
1132
1132
|
/**
|
|
1133
|
-
* Expand a file if it is collapsed
|
|
1133
|
+
* Expand a file if it is collapsed.
|
|
1134
|
+
*
|
|
1135
|
+
* Return contract (callers must handle both):
|
|
1136
|
+
* - `false` when nothing was expanded (no file, no wrapper, or already
|
|
1137
|
+
* expanded), OR a truthy non-thenable for the DOM-only fallback.
|
|
1138
|
+
* - a Promise when it routed through `prManager.toggleFileCollapse`,
|
|
1139
|
+
* which is async (it renders the lazy file body before revealing it).
|
|
1140
|
+
* Scroll callers await the Promise so the row lookup runs against a
|
|
1141
|
+
* rendered, visible body; the synchronous fast path is preserved when no
|
|
1142
|
+
* expansion is needed.
|
|
1143
|
+
*
|
|
1134
1144
|
* @param {string} file - The file path
|
|
1135
|
-
* @returns {boolean}
|
|
1145
|
+
* @returns {boolean|Promise<*>} See contract above.
|
|
1136
1146
|
*/
|
|
1137
1147
|
expandFileIfCollapsed(file) {
|
|
1138
1148
|
if (!file) return false;
|
|
@@ -1145,17 +1155,19 @@ class AIPanel {
|
|
|
1145
1155
|
|
|
1146
1156
|
// Check if collapsed
|
|
1147
1157
|
if (fileWrapper.classList.contains('collapsed')) {
|
|
1148
|
-
// Use prManager's toggle method if available (keeps state in sync)
|
|
1158
|
+
// Use prManager's toggle method if available (keeps state in sync).
|
|
1149
1159
|
const filePath = fileWrapper.dataset.fileName;
|
|
1150
1160
|
if (window.prManager?.toggleFileCollapse) {
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
//
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1161
|
+
// Async: renders the lazy body + removes `collapsed`. Hand the
|
|
1162
|
+
// Promise back so the caller can await render completion rather
|
|
1163
|
+
// than guessing with a fixed timeout.
|
|
1164
|
+
return window.prManager.toggleFileCollapse(filePath);
|
|
1165
|
+
}
|
|
1166
|
+
// Fallback: directly manipulate the DOM (no lazy render path).
|
|
1167
|
+
fileWrapper.classList.remove('collapsed');
|
|
1168
|
+
const header = fileWrapper.querySelector('.d2h-file-header');
|
|
1169
|
+
if (header && window.DiffRenderer) {
|
|
1170
|
+
window.DiffRenderer.updateFileHeaderState(header, true);
|
|
1159
1171
|
}
|
|
1160
1172
|
return true;
|
|
1161
1173
|
}
|
|
@@ -1168,9 +1180,8 @@ class AIPanel {
|
|
|
1168
1180
|
*/
|
|
1169
1181
|
scrollToFinding(findingId, file, line) {
|
|
1170
1182
|
// Expand the file first if it's collapsed
|
|
1171
|
-
const
|
|
1183
|
+
const expansion = this.expandFileIfCollapsed(file);
|
|
1172
1184
|
|
|
1173
|
-
// Small delay if we expanded to allow DOM to update
|
|
1174
1185
|
const doScroll = () => {
|
|
1175
1186
|
let targetSuggestion = null;
|
|
1176
1187
|
|
|
@@ -1219,9 +1230,11 @@ class AIPanel {
|
|
|
1219
1230
|
}
|
|
1220
1231
|
};
|
|
1221
1232
|
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1233
|
+
// When expansion routed through the async lazy-body render, wait for it
|
|
1234
|
+
// to settle so the row lookup runs against a rendered, visible body.
|
|
1235
|
+
// Otherwise scroll synchronously (fast path: file already expanded).
|
|
1236
|
+
if (expansion && typeof expansion.then === 'function') {
|
|
1237
|
+
expansion.then(doScroll);
|
|
1225
1238
|
} else {
|
|
1226
1239
|
doScroll();
|
|
1227
1240
|
}
|
|
@@ -1232,7 +1245,7 @@ class AIPanel {
|
|
|
1232
1245
|
*/
|
|
1233
1246
|
scrollToComment(commentId, file, line) {
|
|
1234
1247
|
// Expand the file first if it's collapsed
|
|
1235
|
-
const
|
|
1248
|
+
const expansion = this.expandFileIfCollapsed(file);
|
|
1236
1249
|
|
|
1237
1250
|
const doScroll = () => {
|
|
1238
1251
|
let targetElement = null;
|
|
@@ -1291,9 +1304,10 @@ class AIPanel {
|
|
|
1291
1304
|
}
|
|
1292
1305
|
};
|
|
1293
1306
|
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1307
|
+
// Await the async lazy-body render when expansion triggered one;
|
|
1308
|
+
// scroll synchronously otherwise.
|
|
1309
|
+
if (expansion && typeof expansion.then === 'function') {
|
|
1310
|
+
expansion.then(doScroll);
|
|
1297
1311
|
} else {
|
|
1298
1312
|
doScroll();
|
|
1299
1313
|
}
|
|
@@ -1313,7 +1327,7 @@ class AIPanel {
|
|
|
1313
1327
|
*/
|
|
1314
1328
|
scrollToExternalThread(threadId, source, file, line) {
|
|
1315
1329
|
// Expand the file first if it's collapsed
|
|
1316
|
-
const
|
|
1330
|
+
const expansion = this.expandFileIfCollapsed(file);
|
|
1317
1331
|
|
|
1318
1332
|
const doScroll = () => {
|
|
1319
1333
|
let target = null;
|
|
@@ -1370,8 +1384,10 @@ class AIPanel {
|
|
|
1370
1384
|
}
|
|
1371
1385
|
};
|
|
1372
1386
|
|
|
1373
|
-
|
|
1374
|
-
|
|
1387
|
+
// Await the async lazy-body render when expansion triggered one;
|
|
1388
|
+
// scroll synchronously otherwise.
|
|
1389
|
+
if (expansion && typeof expansion.then === 'function') {
|
|
1390
|
+
expansion.then(doScroll);
|
|
1375
1391
|
} else {
|
|
1376
1392
|
doScroll();
|
|
1377
1393
|
}
|
|
@@ -53,6 +53,12 @@ class AdvancedConfigTab {
|
|
|
53
53
|
this._injected = false;
|
|
54
54
|
this._councilsLoaded = false;
|
|
55
55
|
|
|
56
|
+
// Default orchestration provider/model for new councils, updated via
|
|
57
|
+
// setDefaultOrchestration(). Seeded here so _defaultConfig() is coherent
|
|
58
|
+
// even if reset() runs before setDefaultOrchestration().
|
|
59
|
+
this._defaultProvider = 'claude';
|
|
60
|
+
this._defaultModel = 'sonnet';
|
|
61
|
+
|
|
56
62
|
// Dirty state tracking
|
|
57
63
|
this._isDirty = false;
|
|
58
64
|
|
|
@@ -179,6 +185,37 @@ class AdvancedConfigTab {
|
|
|
179
185
|
*/
|
|
180
186
|
setDefaultCouncilId(councilId) {
|
|
181
187
|
this._pendingDefaultCouncilId = councilId;
|
|
188
|
+
// On a cached reopen the councils are already loaded, so loadCouncils() —
|
|
189
|
+
// and the _renderCouncilSelector() call that applies the pending default —
|
|
190
|
+
// will not run again (the modal instance is reused; see AnalysisConfigModal
|
|
191
|
+
// caching on window.analysisConfigModal). Apply it now so the saved/default
|
|
192
|
+
// council is restored instead of being silently dropped onto a blank
|
|
193
|
+
// "+ New Council" selection.
|
|
194
|
+
if (this._councilsLoaded && this._injected) {
|
|
195
|
+
this._renderCouncilSelector();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Reset selection and editor state for a fresh modal open.
|
|
201
|
+
*
|
|
202
|
+
* The AnalysisConfigModal (and therefore this tab) is reused across runs — and
|
|
203
|
+
* in the index/bulk flow, across different repositories. Without this reset a
|
|
204
|
+
* council selected in a previous run (or its pending default / dirty edits)
|
|
205
|
+
* would carry over and could be displayed or submitted for the next batch.
|
|
206
|
+
*/
|
|
207
|
+
reset() {
|
|
208
|
+
this.selectedCouncilId = null;
|
|
209
|
+
this._pendingDefaultCouncilId = null;
|
|
210
|
+
this._isDirty = false;
|
|
211
|
+
if (!this._injected) return;
|
|
212
|
+
const selector = this.modal.querySelector('#council-selector');
|
|
213
|
+
if (selector) {
|
|
214
|
+
selector.value = '';
|
|
215
|
+
selector.classList.add('new-council-selected');
|
|
216
|
+
}
|
|
217
|
+
this._applyConfigToUI(this._defaultConfig());
|
|
218
|
+
this._markClean();
|
|
182
219
|
}
|
|
183
220
|
|
|
184
221
|
/**
|
|
@@ -283,13 +320,23 @@ class AdvancedConfigTab {
|
|
|
283
320
|
}
|
|
284
321
|
|
|
285
322
|
_defaultConfig() {
|
|
323
|
+
const provider = this._defaultProvider || 'claude';
|
|
324
|
+
const model = this._defaultModel || 'sonnet';
|
|
325
|
+
const timeout = this._getProviderDefaultTimeout(provider);
|
|
326
|
+
// Seed one reviewer voice per enabled level. The server validator rejects an
|
|
327
|
+
// enabled level with an empty voices array (councils.js: "voices must be a
|
|
328
|
+
// non-empty array when enabled"), and _applyConfigToUI() wipes a level's row
|
|
329
|
+
// list when voices is empty — so an empty default would leave every level
|
|
330
|
+
// enabled with zero reviewer rows and fail at analysis kickoff. A fresh voice
|
|
331
|
+
// object per level avoids shared-reference aliasing.
|
|
332
|
+
const seedVoice = () => ({ provider, model, tier: 'balanced', timeout });
|
|
286
333
|
return {
|
|
287
334
|
levels: {
|
|
288
|
-
'1': { enabled: true, voices: [] },
|
|
289
|
-
'2': { enabled: true, voices: [] },
|
|
290
|
-
'3': { enabled: true, voices: [] }
|
|
335
|
+
'1': { enabled: true, voices: [seedVoice()] },
|
|
336
|
+
'2': { enabled: true, voices: [seedVoice()] },
|
|
337
|
+
'3': { enabled: true, voices: [seedVoice()] }
|
|
291
338
|
},
|
|
292
|
-
consolidation: { provider
|
|
339
|
+
consolidation: { provider, model, tier: 'balanced', timeout }
|
|
293
340
|
};
|
|
294
341
|
}
|
|
295
342
|
|
|
@@ -1432,3 +1479,8 @@ class AdvancedConfigTab {
|
|
|
1432
1479
|
if (typeof window !== 'undefined') {
|
|
1433
1480
|
window.AdvancedConfigTab = AdvancedConfigTab;
|
|
1434
1481
|
}
|
|
1482
|
+
|
|
1483
|
+
// Export for unit testing (Node/CommonJS environment)
|
|
1484
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
1485
|
+
module.exports = { AdvancedConfigTab };
|
|
1486
|
+
}
|
|
@@ -625,6 +625,15 @@ class AnalysisConfigModal {
|
|
|
625
625
|
* Select a model
|
|
626
626
|
*/
|
|
627
627
|
selectModel(modelId) {
|
|
628
|
+
// Guard: never store a model that doesn't belong to the current provider.
|
|
629
|
+
// Callers can pass a model resolved from a different scope (e.g. 'opus'
|
|
630
|
+
// while the provider is 'gemini'); fall back to the provider's default so
|
|
631
|
+
// the UI always shows a selected card and submissions carry a valid pair.
|
|
632
|
+
if (modelId && this.models.length && !this.models.some(m => m.id === modelId)) {
|
|
633
|
+
const fallback = this.models.find(m => m.default) || this.models[0];
|
|
634
|
+
modelId = fallback ? fallback.id : modelId;
|
|
635
|
+
}
|
|
636
|
+
|
|
628
637
|
this.selectedModel = modelId;
|
|
629
638
|
|
|
630
639
|
// Update UI
|
|
@@ -897,42 +906,41 @@ class AnalysisConfigModal {
|
|
|
897
906
|
// Build three-tab layout
|
|
898
907
|
this._injectTabLayout(options);
|
|
899
908
|
|
|
900
|
-
//
|
|
909
|
+
// Re-baseline council-tab state every open. The modal is reused across runs
|
|
910
|
+
// (and across repositories in the bulk flow), so always reset selection and
|
|
911
|
+
// pass normalized values — including empties — so a previous run's repo
|
|
912
|
+
// instructions, council selection, or dirty edits cannot bleed into the next.
|
|
913
|
+
const councilDefault = options.lastCouncilId || options.defaultCouncilId || null;
|
|
914
|
+
|
|
915
|
+
// Initialize voice-centric council tab.
|
|
916
|
+
//
|
|
917
|
+
// Order matters: setProviders() and setDefaultOrchestration() must run BEFORE
|
|
918
|
+
// reset(), because reset() repaints the UI from _defaultConfig(), which reads
|
|
919
|
+
// _defaultProvider/_defaultModel and the provider dropdown data. Calling reset()
|
|
920
|
+
// first would paint stale (previous-open) provider/model values on the reused
|
|
921
|
+
// modal. setDefaultCouncilId() runs last so any saved/default council is applied
|
|
922
|
+
// on top of the freshly reset defaults — including on cached reopens.
|
|
901
923
|
if (this.councilTab) {
|
|
902
924
|
const councilPanel = this.modal.querySelector('#tab-panel-council');
|
|
903
925
|
this.councilTab.inject(councilPanel);
|
|
904
926
|
this.councilTab.setProviders(this.providers);
|
|
905
|
-
|
|
906
|
-
if (options.repoInstructions) {
|
|
907
|
-
this.councilTab.setRepoInstructions(options.repoInstructions);
|
|
908
|
-
}
|
|
909
|
-
if (options.lastInstructions) {
|
|
910
|
-
this.councilTab.setLastInstructions(options.lastInstructions);
|
|
911
|
-
}
|
|
912
927
|
this.councilTab.setDefaultOrchestration(options.currentProvider, options.currentModel);
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
928
|
+
this.councilTab.reset();
|
|
929
|
+
this.councilTab.setRepoInstructions(options.repoInstructions || '');
|
|
930
|
+
this.councilTab.setLastInstructions(options.lastInstructions || '');
|
|
931
|
+
this.councilTab.setDefaultCouncilId(councilDefault);
|
|
917
932
|
}
|
|
918
933
|
|
|
919
|
-
// Initialize advanced (level-centric) tab
|
|
934
|
+
// Initialize advanced (level-centric) tab — same ordering rationale as above.
|
|
920
935
|
if (this.advancedTab) {
|
|
921
936
|
const advancedPanel = this.modal.querySelector('#tab-panel-advanced');
|
|
922
937
|
this.advancedTab.inject(advancedPanel);
|
|
923
938
|
this.advancedTab.setProviders(this.providers);
|
|
924
|
-
|
|
925
|
-
if (options.repoInstructions) {
|
|
926
|
-
this.advancedTab.setRepoInstructions(options.repoInstructions);
|
|
927
|
-
}
|
|
928
|
-
if (options.lastInstructions) {
|
|
929
|
-
this.advancedTab.setLastInstructions(options.lastInstructions);
|
|
930
|
-
}
|
|
931
939
|
this.advancedTab.setDefaultOrchestration(options.currentProvider, options.currentModel);
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
940
|
+
this.advancedTab.reset();
|
|
941
|
+
this.advancedTab.setRepoInstructions(options.repoInstructions || '');
|
|
942
|
+
this.advancedTab.setLastInstructions(options.lastInstructions || '');
|
|
943
|
+
this.advancedTab.setDefaultCouncilId(councilDefault);
|
|
936
944
|
}
|
|
937
945
|
|
|
938
946
|
// Set initial provider and model
|
|
@@ -942,7 +950,11 @@ class AnalysisConfigModal {
|
|
|
942
950
|
// Default to first available provider
|
|
943
951
|
this.selectProvider(Object.keys(this.providers)[0]);
|
|
944
952
|
}
|
|
945
|
-
|
|
953
|
+
// Only honour currentModel when it belongs to the selected provider.
|
|
954
|
+
// selectProvider() has already chosen a valid model for the provider (via
|
|
955
|
+
// tier matching); applying a foreign model here (e.g. 'opus' under 'gemini')
|
|
956
|
+
// would leave no model card selected and submit an invalid pair.
|
|
957
|
+
if (options.currentModel && this.models.some(m => m.id === options.currentModel)) {
|
|
946
958
|
this.selectModel(options.currentModel);
|
|
947
959
|
}
|
|
948
960
|
|
|
@@ -1434,3 +1446,7 @@ class AnalysisConfigModal {
|
|
|
1434
1446
|
if (typeof window !== 'undefined') {
|
|
1435
1447
|
window.AnalysisConfigModal = AnalysisConfigModal;
|
|
1436
1448
|
}
|
|
1449
|
+
|
|
1450
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
1451
|
+
module.exports = { AnalysisConfigModal };
|
|
1452
|
+
}
|
|
@@ -1275,13 +1275,15 @@ class ChatPanel {
|
|
|
1275
1275
|
// attached as tabs are created/restored.
|
|
1276
1276
|
this._ensureSubscriptions();
|
|
1277
1277
|
|
|
1278
|
-
// Recognise thread context (external systems)
|
|
1279
|
-
// comment / file when deciding whether to open with
|
|
1278
|
+
// Recognise thread context (external systems) and tour context alongside
|
|
1279
|
+
// suggestion / user comment / file when deciding whether to open with
|
|
1280
|
+
// explicit context.
|
|
1280
1281
|
const hasExplicitContext = !!(
|
|
1281
1282
|
options.suggestionContext ||
|
|
1282
1283
|
options.commentContext ||
|
|
1283
1284
|
options.threadContext ||
|
|
1284
|
-
options.fileContext
|
|
1285
|
+
options.fileContext ||
|
|
1286
|
+
options.tourContext
|
|
1285
1287
|
);
|
|
1286
1288
|
|
|
1287
1289
|
// First-time open behaviour:
|
|
@@ -1355,6 +1357,15 @@ class ChatPanel {
|
|
|
1355
1357
|
this._sendFileContextMessage(options.fileContext);
|
|
1356
1358
|
this._contextSource = 'file';
|
|
1357
1359
|
this._contextItemId = null;
|
|
1360
|
+
} else if (options.tourContext) {
|
|
1361
|
+
// If opening with tour-stop context, inject it as a context card.
|
|
1362
|
+
// Awaited because tour stops on context files (outside the PR diff)
|
|
1363
|
+
// need an async file-content fetch to populate the snippet.
|
|
1364
|
+
await this._sendTourContextMessage(options.tourContext);
|
|
1365
|
+
this._contextSource = 'tour';
|
|
1366
|
+
this._contextItemId = options.tourContext.stopIndex != null
|
|
1367
|
+
? String(options.tourContext.stopIndex)
|
|
1368
|
+
: null;
|
|
1358
1369
|
}
|
|
1359
1370
|
|
|
1360
1371
|
// Gate input when reviewId is not yet available (PanelGroup auto-restore race)
|
|
@@ -2789,6 +2800,155 @@ class ChatPanel {
|
|
|
2789
2800
|
this._addFileContextCard(contextData, { removable: true });
|
|
2790
2801
|
}
|
|
2791
2802
|
|
|
2803
|
+
/**
|
|
2804
|
+
* Store pending context and render a compact context card for a tour stop.
|
|
2805
|
+
* Called when the user clicks "Chat about" on a tour-stop annotation.
|
|
2806
|
+
* The context is NOT sent to the agent immediately — it is prepended to
|
|
2807
|
+
* the next user message so the agent receives question + context together.
|
|
2808
|
+
*
|
|
2809
|
+
* Async because tour stops on context files (files NOT in the PR diff) need
|
|
2810
|
+
* to fetch a code snippet via the file-content API so the agent receives a
|
|
2811
|
+
* meaningful snippet — same shape as the diff-hunk enrichment used for stops
|
|
2812
|
+
* inside the diff.
|
|
2813
|
+
*
|
|
2814
|
+
* @param {Object} ctx - Tour stop context
|
|
2815
|
+
* {stopIndex, totalStops, title, description, file, line_start, line_end, side}
|
|
2816
|
+
* @returns {Promise<void>}
|
|
2817
|
+
*/
|
|
2818
|
+
async _sendTourContextMessage(ctx) {
|
|
2819
|
+
const tab = this._getActiveTab();
|
|
2820
|
+
if (!tab) return;
|
|
2821
|
+
|
|
2822
|
+
// Remove empty state if present
|
|
2823
|
+
const emptyState = this.messagesEl.querySelector('.chat-panel__empty');
|
|
2824
|
+
if (emptyState) emptyState.remove();
|
|
2825
|
+
|
|
2826
|
+
const stopLabel = (typeof ctx.stopIndex === 'number' && typeof ctx.totalStops === 'number')
|
|
2827
|
+
? `Stop ${ctx.stopIndex + 1} of ${ctx.totalStops}`
|
|
2828
|
+
: 'Tour stop';
|
|
2829
|
+
|
|
2830
|
+
// Store structured context data for DB persistence (session resumption).
|
|
2831
|
+
const contextData = {
|
|
2832
|
+
type: 'tour stop',
|
|
2833
|
+
title: ctx.title || stopLabel,
|
|
2834
|
+
file: ctx.file || null,
|
|
2835
|
+
line_start: ctx.line_start || null,
|
|
2836
|
+
line_end: ctx.line_end || null,
|
|
2837
|
+
side: ctx.side || null,
|
|
2838
|
+
body: ctx.description || null,
|
|
2839
|
+
stopIndex: typeof ctx.stopIndex === 'number' ? ctx.stopIndex : null,
|
|
2840
|
+
totalStops: typeof ctx.totalStops === 'number' ? ctx.totalStops : null
|
|
2841
|
+
};
|
|
2842
|
+
tab.pendingContextData.push(contextData);
|
|
2843
|
+
|
|
2844
|
+
// Build the plain-text context for the agent.
|
|
2845
|
+
const lines = [`The user wants to discuss this tour stop (${stopLabel}):`];
|
|
2846
|
+
if (contextData.title) {
|
|
2847
|
+
lines.push(`- Title: ${contextData.title}`);
|
|
2848
|
+
}
|
|
2849
|
+
if (contextData.file) {
|
|
2850
|
+
let fileLine = `- File: ${contextData.file}`;
|
|
2851
|
+
if (contextData.line_start) {
|
|
2852
|
+
fileLine += ` (line ${contextData.line_start}${contextData.line_end && contextData.line_end !== contextData.line_start ? '-' + contextData.line_end : ''})`;
|
|
2853
|
+
}
|
|
2854
|
+
lines.push(fileLine);
|
|
2855
|
+
}
|
|
2856
|
+
if (contextData.body) {
|
|
2857
|
+
lines.push(`- Description: ${contextData.body}`);
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
// Enrich with code snippet.
|
|
2861
|
+
//
|
|
2862
|
+
// Primary path: pull from the in-memory PR diff via DiffContext. This is
|
|
2863
|
+
// synchronous and matches the shape used by suggestion/comment context.
|
|
2864
|
+
//
|
|
2865
|
+
// Fallback path: tour-renderer.prepareStop auto-adds context files (files
|
|
2866
|
+
// outside the PR diff) via prManager.ensureContextFile. Those files have
|
|
2867
|
+
// no entry in filePatches, so the lookup misses — but the agent benefits
|
|
2868
|
+
// most from a snippet in exactly that case (file isn't visible in the
|
|
2869
|
+
// diff). Fetch the file content and slice [line_start-5, line_end+5].
|
|
2870
|
+
//
|
|
2871
|
+
// Both paths render as a fenced code block so the agent sees a consistent
|
|
2872
|
+
// shape. A failed fetch logs a warning and falls through to no snippet.
|
|
2873
|
+
const patch = window.prManager?.filePatches?.get(contextData.file);
|
|
2874
|
+
let snippet = null;
|
|
2875
|
+
if (patch && window.DiffContext && contextData.line_start) {
|
|
2876
|
+
const hunk = window.DiffContext.extractHunkForLines(
|
|
2877
|
+
patch,
|
|
2878
|
+
contextData.line_start,
|
|
2879
|
+
contextData.line_end || contextData.line_start,
|
|
2880
|
+
contextData.side
|
|
2881
|
+
);
|
|
2882
|
+
if (hunk) {
|
|
2883
|
+
snippet = `- Diff hunk:\n\`\`\`\n${hunk}\n\`\`\``;
|
|
2884
|
+
}
|
|
2885
|
+
} else if (!patch && contextData.file && contextData.line_start) {
|
|
2886
|
+
const sliced = await this._fetchContextFileSnippet(
|
|
2887
|
+
contextData.file,
|
|
2888
|
+
contextData.line_start,
|
|
2889
|
+
contextData.line_end || contextData.line_start
|
|
2890
|
+
);
|
|
2891
|
+
if (sliced) {
|
|
2892
|
+
snippet = `- File snippet:\n\`\`\`\n${sliced}\n\`\`\``;
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
if (snippet) {
|
|
2896
|
+
lines.push(snippet);
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2899
|
+
tab.pendingContext.push(lines.join('\n'));
|
|
2900
|
+
|
|
2901
|
+
// Render the compact context card in the UI (reuses suggestion card shape).
|
|
2902
|
+
this._addContextCard(contextData, { removable: true });
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
/**
|
|
2906
|
+
* Fetch a small slice of file content for tour stops on context files
|
|
2907
|
+
* (files outside the PR diff). Returns the slice with line numbers prefixed
|
|
2908
|
+
* so the agent can correlate with the stop's line range, or null on failure.
|
|
2909
|
+
*
|
|
2910
|
+
* @param {string} file - File path (will be URI-encoded)
|
|
2911
|
+
* @param {number} lineStart - 1-based first line of the stop range
|
|
2912
|
+
* @param {number} lineEnd - 1-based last line of the stop range (inclusive)
|
|
2913
|
+
* @param {number} [padding=5] - Extra lines to include on each side
|
|
2914
|
+
* @returns {Promise<string|null>}
|
|
2915
|
+
*/
|
|
2916
|
+
async _fetchContextFileSnippet(file, lineStart, lineEnd, padding = 5) {
|
|
2917
|
+
const reviewId = this.reviewId || window.prManager?.currentPR?.id;
|
|
2918
|
+
if (!reviewId || !file || !lineStart) return null;
|
|
2919
|
+
|
|
2920
|
+
try {
|
|
2921
|
+
const resp = await fetch(
|
|
2922
|
+
`/api/reviews/${reviewId}/file-content/${encodeURIComponent(file)}`
|
|
2923
|
+
);
|
|
2924
|
+
if (!resp || !resp.ok) {
|
|
2925
|
+
console.warn(
|
|
2926
|
+
'[ChatPanel] context-file snippet fetch failed',
|
|
2927
|
+
{ file, status: resp && resp.status }
|
|
2928
|
+
);
|
|
2929
|
+
return null;
|
|
2930
|
+
}
|
|
2931
|
+
const data = await resp.json();
|
|
2932
|
+
const allLines = Array.isArray(data?.lines) ? data.lines : null;
|
|
2933
|
+
if (!allLines || allLines.length === 0) return null;
|
|
2934
|
+
|
|
2935
|
+
// Clamp to file bounds; convert to 0-based slice indices.
|
|
2936
|
+
const startIdx = Math.max(0, lineStart - 1 - padding);
|
|
2937
|
+
const endIdx = Math.min(allLines.length, lineEnd + padding);
|
|
2938
|
+
if (endIdx <= startIdx) return null;
|
|
2939
|
+
|
|
2940
|
+
const out = [];
|
|
2941
|
+
const pad = String(endIdx).length;
|
|
2942
|
+
for (let i = startIdx; i < endIdx; i++) {
|
|
2943
|
+
out.push(`${String(i + 1).padStart(pad, ' ')}: ${allLines[i]}`);
|
|
2944
|
+
}
|
|
2945
|
+
return out.join('\n');
|
|
2946
|
+
} catch (err) {
|
|
2947
|
+
console.warn('[ChatPanel] context-file snippet fetch threw', { file, err });
|
|
2948
|
+
return null;
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2792
2952
|
/**
|
|
2793
2953
|
* Add an analysis run as context for the chat conversation.
|
|
2794
2954
|
* Fetches run metadata from the backend and creates a removable context card
|
|
@@ -299,45 +299,29 @@ class KeyboardShortcuts {
|
|
|
299
299
|
}
|
|
300
300
|
|
|
301
301
|
/**
|
|
302
|
-
* Check if a modal is currently open (excluding our help overlay)
|
|
302
|
+
* Check if a modal is currently open (excluding our help overlay).
|
|
303
|
+
* Delegates to the shared ModalDetection utility so the selector list
|
|
304
|
+
* and visibility check stay in sync with PRManager's tour handler.
|
|
303
305
|
* @returns {boolean} True if a modal is open
|
|
304
306
|
*/
|
|
305
307
|
isModalOpen() {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
'.modal-overlay:not(#keyboard-shortcuts-help)',
|
|
309
|
-
'.review-modal-overlay',
|
|
310
|
-
'.preview-modal-overlay',
|
|
311
|
-
'.confirm-dialog-overlay',
|
|
312
|
-
'.analysis-config-overlay',
|
|
313
|
-
'.ai-summary-modal-overlay',
|
|
314
|
-
'[role="dialog"]:not(#keyboard-shortcuts-help)'
|
|
315
|
-
];
|
|
316
|
-
|
|
317
|
-
for (const selector of modalSelectors) {
|
|
318
|
-
const modal = document.querySelector(selector);
|
|
319
|
-
if (modal && this.isElementVisible(modal)) {
|
|
320
|
-
return true;
|
|
321
|
-
}
|
|
308
|
+
if (window.ModalDetection && typeof window.ModalDetection.isModalOpen === 'function') {
|
|
309
|
+
return window.ModalDetection.isModalOpen();
|
|
322
310
|
}
|
|
323
|
-
|
|
324
311
|
return false;
|
|
325
312
|
}
|
|
326
313
|
|
|
327
314
|
/**
|
|
328
|
-
* Check if an element is visible
|
|
315
|
+
* Check if an element is visible. Delegates to the shared
|
|
316
|
+
* ModalDetection utility.
|
|
329
317
|
* @param {HTMLElement} element - The element to check
|
|
330
318
|
* @returns {boolean} True if element is visible
|
|
331
319
|
*/
|
|
332
320
|
isElementVisible(element) {
|
|
333
|
-
if (
|
|
334
|
-
|
|
335
|
-
const style = window.getComputedStyle(element);
|
|
336
|
-
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
|
337
|
-
return false;
|
|
321
|
+
if (window.ModalDetection && typeof window.ModalDetection.isElementVisible === 'function') {
|
|
322
|
+
return window.ModalDetection.isElementVisible(element);
|
|
338
323
|
}
|
|
339
|
-
|
|
340
|
-
return true;
|
|
324
|
+
return Boolean(element);
|
|
341
325
|
}
|
|
342
326
|
|
|
343
327
|
/**
|