@in-the-loop-labs/pair-review 3.6.0 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +4 -0
  2. package/package.json +20 -15
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +0 -0
  6. package/public/css/analysis-config.css +1807 -0
  7. package/public/css/pr.css +0 -1737
  8. package/public/index.html +11 -0
  9. package/public/js/components/AIPanel.js +39 -23
  10. package/public/js/components/AdvancedConfigTab.js +56 -4
  11. package/public/js/components/AnalysisConfigModal.js +41 -25
  12. package/public/js/components/ReviewModal.js +135 -13
  13. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  14. package/public/js/index.js +175 -16
  15. package/public/js/local.js +58 -8
  16. package/public/js/modules/suggestion-manager.js +25 -1
  17. package/public/js/modules/tour-renderer.js +33 -3
  18. package/public/js/pr.js +653 -157
  19. package/public/js/repo-links.js +328 -0
  20. package/public/js/utils/provider-model.js +88 -0
  21. package/public/js/utils/storage-keys.js +50 -0
  22. package/public/local.html +7 -0
  23. package/public/pr.html +7 -0
  24. package/public/repo-settings.html +1 -0
  25. package/public/setup.html +2 -0
  26. package/src/ai/analyzer.js +125 -18
  27. package/src/config.js +664 -10
  28. package/src/external/github-adapter.js +114 -25
  29. package/src/git/base-branch.js +11 -4
  30. package/src/github/client.js +482 -588
  31. package/src/github/errors.js +55 -0
  32. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  33. package/src/github/impl/graphql/pending-review.js +153 -0
  34. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  35. package/src/github/impl/graphql/stack-walker.js +210 -0
  36. package/src/github/impl/host/pending-review-comments.js +338 -0
  37. package/src/github/impl/rest/pending-review.js +251 -0
  38. package/src/github/impl/rest/review-lifecycle.js +226 -0
  39. package/src/github/impl/rest/stack-walker.js +309 -0
  40. package/src/github/operations/pending-review-comments.js +79 -0
  41. package/src/github/operations/pending-review.js +89 -0
  42. package/src/github/operations/review-lifecycle.js +126 -0
  43. package/src/github/operations/stack-walker.js +87 -0
  44. package/src/github/parser.js +230 -4
  45. package/src/github/stack-walker.js +14 -189
  46. package/src/links/repo-links.js +230 -0
  47. package/src/local-review.js +13 -4
  48. package/src/main.js +133 -30
  49. package/src/routes/analyses.js +30 -7
  50. package/src/routes/bulk-analysis-configs.js +295 -0
  51. package/src/routes/config.js +102 -2
  52. package/src/routes/external-comments.js +20 -10
  53. package/src/routes/github-collections.js +3 -1
  54. package/src/routes/local.js +101 -11
  55. package/src/routes/mcp.js +47 -4
  56. package/src/routes/pr.js +298 -68
  57. package/src/routes/setup.js +8 -3
  58. package/src/routes/stack-analysis.js +33 -9
  59. package/src/routes/worktrees.js +3 -2
  60. package/src/server.js +2 -0
  61. package/src/setup/pr-setup.js +37 -11
  62. package/src/setup/stack-setup.js +13 -3
  63. package/src/single-port.js +6 -3
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} True if the file was expanded
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
- window.prManager.toggleFileCollapse(filePath);
1152
- } else {
1153
- // Fallback: directly manipulate the DOM
1154
- fileWrapper.classList.remove('collapsed');
1155
- const header = fileWrapper.querySelector('.d2h-file-header');
1156
- if (header && window.DiffRenderer) {
1157
- window.DiffRenderer.updateFileHeaderState(header, true);
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 wasExpanded = this.expandFileIfCollapsed(file);
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
- if (wasExpanded) {
1223
- // Give the DOM a moment to update after expanding
1224
- setTimeout(doScroll, 50);
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 wasExpanded = this.expandFileIfCollapsed(file);
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
- if (wasExpanded) {
1295
- // Give the DOM a moment to update after expanding
1296
- setTimeout(doScroll, 50);
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 wasExpanded = this.expandFileIfCollapsed(file);
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
- if (wasExpanded) {
1374
- setTimeout(doScroll, 50);
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: this._defaultProvider || 'claude', model: this._defaultModel || 'sonnet', tier: 'balanced', timeout: this._getProviderDefaultTimeout(this._defaultProvider || 'claude') }
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
- // Initialize voice-centric council tab
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
- const councilDefault = options.lastCouncilId || options.defaultCouncilId || null;
914
- if (councilDefault) {
915
- this.councilTab.setDefaultCouncilId(councilDefault);
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
- const councilDefault = options.lastCouncilId || options.defaultCouncilId || null;
933
- if (councilDefault) {
934
- this.advancedTab.setDefaultCouncilId(councilDefault);
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
- if (options.currentModel) {
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
+ }
@@ -64,9 +64,9 @@ class ReviewModal {
64
64
  </div>
65
65
  <div class="pending-draft-notice-content">
66
66
  <span class="pending-draft-notice-text">
67
- You have a pending draft review on GitHub with <strong id="pending-draft-count">0</strong> comments.
67
+ You have a pending draft review on <span class="rm-host-name">GitHub</span> with <strong id="pending-draft-count">0</strong> comments.
68
68
  Submitting here will add to or complete this review.
69
- <a href="#" id="pending-draft-link" target="_blank" rel="noopener noreferrer">Manage on GitHub</a>.
69
+ <a href="#" id="pending-draft-link" target="_blank" rel="noopener noreferrer">Manage on <span class="rm-host-name">GitHub</span></a>.
70
70
  </span>
71
71
  </div>
72
72
  </div>
@@ -120,7 +120,7 @@ class ReviewModal {
120
120
  <input type="radio" name="review-event" value="DRAFT">
121
121
  <div class="review-type-content">
122
122
  <span class="review-type-label">Save as Draft</span>
123
- <span class="review-type-desc">Save your review as a draft on GitHub to finish later.</span>
123
+ <span class="review-type-desc">Save your review as a draft on <span class="rm-host-name">GitHub</span> to finish later.</span>
124
124
  </div>
125
125
  </label>
126
126
  </div>
@@ -284,6 +284,11 @@ class ReviewModal {
284
284
  // Update AI summary link visibility
285
285
  this.updateAISummaryLink();
286
286
 
287
+ // Apply the configured remote-host display name + icon (resolves
288
+ // asynchronously after the modal HTML was built).
289
+ this.applyHostName();
290
+ this.applySubmitButtonIcon();
291
+
287
292
  // Update pending draft notice
288
293
  this.updatePendingDraftNotice();
289
294
  }
@@ -311,11 +316,17 @@ class ReviewModal {
311
316
  countElement.textContent = String(pendingDraft.comments_count || 0);
312
317
  }
313
318
 
314
- // Update the link - hide if no github_url
319
+ // Update the link. Prefer the URL built from the repo's configured
320
+ // url_template (host-correct) over the server-reported github_url,
321
+ // which some alt-hosts return as a wrong-host github.com/issues URL.
315
322
  const linkElement = notice.querySelector('#pending-draft-link');
316
323
  if (linkElement) {
317
- if (pendingDraft.github_url) {
318
- linkElement.href = pendingDraft.github_url;
324
+ const templatedUrl = (typeof window !== 'undefined' && window.RepoLinks
325
+ && typeof window.RepoLinks.externalUrl === 'function')
326
+ ? window.RepoLinks.externalUrl() : null;
327
+ const manageUrl = templatedUrl || pendingDraft.github_url;
328
+ if (manageUrl) {
329
+ linkElement.href = manageUrl;
319
330
  linkElement.style.display = 'inline';
320
331
  } else {
321
332
  linkElement.style.display = 'none';
@@ -452,6 +463,8 @@ class ReviewModal {
452
463
  submitBtn.disabled = false;
453
464
  cancelBtn.style.display = 'inline-block';
454
465
  closeBtn.style.display = 'inline-block';
466
+ // innerHTML reset drops any host icon — re-apply it.
467
+ this.applySubmitButtonIcon();
455
468
  }
456
469
  }
457
470
 
@@ -531,7 +544,7 @@ class ReviewModal {
531
544
  const reviewUrl = result.reviewUrl || result.github_url;
532
545
  if (isDraft) {
533
546
  window.toast.showSuccess(
534
- 'Draft review submitted to GitHub successfully!',
547
+ `Draft review submitted to ${ReviewModal.escapeHtml(ReviewModal.hostName())} successfully!`,
535
548
  {
536
549
  duration: 5000
537
550
  }
@@ -541,7 +554,7 @@ class ReviewModal {
541
554
  'Review submitted successfully!',
542
555
  {
543
556
  link: reviewUrl,
544
- linkText: 'View on GitHub',
557
+ linkText: `View on ${ReviewModal.escapeHtml(ReviewModal.hostName())}`,
545
558
  duration: 5000
546
559
  }
547
560
  );
@@ -586,11 +599,16 @@ class ReviewModal {
586
599
  }
587
600
 
588
601
  if (isDraft) {
589
- // After 2 seconds, open GitHub PR page for drafts
590
- setTimeout(() => {
591
- const githubUrl = result.github_url || `https://github.com/${pr.owner}/${pr.repo}/pull/${pr.number}`;
592
- window.open(githubUrl, '_blank');
593
- }, 2000);
602
+ // After 2 seconds, open the PR page for drafts. Use the PR's canonical
603
+ // html_url (correct host + `/pull/`) rather than the review's html_url,
604
+ // which some alt-hosts return as a github.com `/issues/<n>` URL. Never
605
+ // assume github.com — see resolveDraftPrUrl.
606
+ const prUrl = ReviewModal.resolveDraftPrUrl(pr, result);
607
+ if (prUrl) {
608
+ setTimeout(() => {
609
+ window.open(prUrl, '_blank');
610
+ }, 2000);
611
+ }
594
612
  }
595
613
 
596
614
  } catch (error) {
@@ -676,6 +694,110 @@ class ReviewModal {
676
694
  localStorage.setItem(ASSISTED_BY_STORAGE_KEY, String(checkbox.checked));
677
695
  }
678
696
 
697
+ /**
698
+ * Display name of the remote code host, for user-facing text in place of
699
+ * the literal "GitHub". Reads the configured `links.external.name` via
700
+ * `window.RepoLinks.hostName()`, falling back to "GitHub".
701
+ *
702
+ * @returns {string}
703
+ */
704
+ static hostName() {
705
+ if (typeof window !== 'undefined' && window.RepoLinks
706
+ && typeof window.RepoLinks.hostName === 'function') {
707
+ return window.RepoLinks.hostName();
708
+ }
709
+ return 'GitHub';
710
+ }
711
+
712
+ /**
713
+ * Escape a string for safe interpolation into HTML. Used for the host
714
+ * name (user-supplied config) before it goes into the success toast,
715
+ * which renders its message/linkText via innerHTML.
716
+ *
717
+ * @param {string} text
718
+ * @returns {string}
719
+ */
720
+ static escapeHtml(text) {
721
+ return String(text == null ? '' : text)
722
+ .replace(/&/g, '&amp;')
723
+ .replace(/</g, '&lt;')
724
+ .replace(/>/g, '&gt;')
725
+ .replace(/"/g, '&quot;')
726
+ .replace(/'/g, '&#39;');
727
+ }
728
+
729
+ /**
730
+ * Resolve the URL to open in a new tab after a draft submit.
731
+ *
732
+ * Precedence:
733
+ * 1. The URL built from the repo's configured `links.external.url_template`
734
+ * (`window.RepoLinks.externalUrl()`) — authoritative and host-correct.
735
+ * 2. The PR's canonical `html_url` (the host's own PR page).
736
+ * 3. The server-reported `github_url` as a last resort.
737
+ *
738
+ * Some alt-hosts return the pending-review `html_url` as a
739
+ * `github.com/.../issues/<n>` URL, which lands on the wrong host and page.
740
+ * We must never assume github.com, so there is no hardcoded fallback host:
741
+ * if none of the above yields a URL we open nothing.
742
+ *
743
+ * @param {{html_url?: string}|null|undefined} pr - current PR (from prManager)
744
+ * @param {{github_url?: string}|null|undefined} result - submit-review response
745
+ * @returns {string|null} URL to open, or null if none is available
746
+ */
747
+ static resolveDraftPrUrl(pr, result) {
748
+ if (typeof window !== 'undefined' && window.RepoLinks
749
+ && typeof window.RepoLinks.externalUrl === 'function') {
750
+ const templated = window.RepoLinks.externalUrl();
751
+ if (templated) return templated;
752
+ }
753
+ if (pr && pr.html_url) return pr.html_url;
754
+ if (result && result.github_url) return result.github_url;
755
+ return null;
756
+ }
757
+
758
+ /**
759
+ * Update host-name-dependent static text in the modal (the pending-draft
760
+ * notice and the "Save as Draft" description) to the configured host name.
761
+ * Called from `show()` because the name resolves asynchronously after the
762
+ * modal HTML is built. No-op when the modal isn't present.
763
+ */
764
+ applyHostName() {
765
+ if (!this.modal) return;
766
+ const name = ReviewModal.hostName();
767
+ const spans = this.modal.querySelectorAll('.rm-host-name');
768
+ spans.forEach((el) => { el.textContent = name; });
769
+ }
770
+
771
+ /**
772
+ * Prepend the configured external-host icon to the submit button, when an
773
+ * icon is configured for the repo. The icon is parsed via
774
+ * `window.RepoLinks.parseSvgIcon` (DOMParser + attribute stripping) and
775
+ * inserted as a DOM node — never via innerHTML. Idempotent: any previously
776
+ * inserted icon is removed first. No-op for plain github.com repos.
777
+ */
778
+ applySubmitButtonIcon() {
779
+ const submitBtn = this.modal?.querySelector('#submit-review-btn-modal');
780
+ if (!submitBtn) return;
781
+
782
+ const existing = submitBtn.querySelector?.('.submit-host-icon');
783
+ if (existing) existing.remove();
784
+
785
+ if (typeof window === 'undefined' || !window.RepoLinks
786
+ || typeof window.RepoLinks.externalIcon !== 'function'
787
+ || typeof window.RepoLinks.parseSvgIcon !== 'function') {
788
+ return;
789
+ }
790
+ const iconStr = window.RepoLinks.externalIcon();
791
+ if (!iconStr) return;
792
+ const svg = window.RepoLinks.parseSvgIcon(iconStr);
793
+ if (!svg) return;
794
+
795
+ svg.classList.add('submit-host-icon');
796
+ if (!svg.getAttribute('width')) svg.setAttribute('width', '16');
797
+ if (!svg.getAttribute('height')) svg.setAttribute('height', '16');
798
+ submitBtn.insertBefore(svg, submitBtn.firstChild);
799
+ }
800
+
679
801
  }
680
802
 
681
803
  // Initialize when DOM is ready if not already initialized
@@ -235,6 +235,37 @@ class VoiceCentricConfigTab {
235
235
  */
236
236
  setDefaultCouncilId(councilId) {
237
237
  this._pendingDefaultCouncilId = councilId;
238
+ // On a cached reopen the councils are already loaded, so loadCouncils() —
239
+ // and the _renderCouncilSelector() call that applies the pending default —
240
+ // will not run again (the modal instance is reused; see AnalysisConfigModal
241
+ // caching on window.analysisConfigModal). Apply it now so the saved/default
242
+ // council is restored instead of being silently dropped onto a blank
243
+ // "+ New Council" selection.
244
+ if (this._councilsLoaded && this._injected) {
245
+ this._renderCouncilSelector();
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Reset selection and editor state for a fresh modal open.
251
+ *
252
+ * The AnalysisConfigModal (and therefore this tab) is reused across runs — and
253
+ * in the index/bulk flow, across different repositories. Without this reset a
254
+ * council selected in a previous run (or its pending default / dirty edits)
255
+ * would carry over and could be displayed or submitted for the next batch.
256
+ */
257
+ reset() {
258
+ this.selectedCouncilId = null;
259
+ this._pendingDefaultCouncilId = null;
260
+ this._isDirty = false;
261
+ if (!this._injected) return;
262
+ const selector = this.modal.querySelector('#vc-council-selector');
263
+ if (selector) {
264
+ selector.value = '';
265
+ selector.classList.add('new-council-selected');
266
+ }
267
+ this._applyConfigToUI(this._defaultConfig());
268
+ this._markClean();
238
269
  }
239
270
 
240
271
  /**
@@ -1494,3 +1525,8 @@ class VoiceCentricConfigTab {
1494
1525
  if (typeof window !== 'undefined') {
1495
1526
  window.VoiceCentricConfigTab = VoiceCentricConfigTab;
1496
1527
  }
1528
+
1529
+ // Export for unit testing (Node/CommonJS environment)
1530
+ if (typeof module !== 'undefined' && module.exports) {
1531
+ module.exports = { VoiceCentricConfigTab };
1532
+ }