@in-the-loop-labs/pair-review 2.3.3 → 2.4.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 (45) hide show
  1. package/.pi/skills/review-model-guidance/SKILL.md +1 -1
  2. package/.pi/skills/review-roulette/SKILL.md +1 -1
  3. package/README.md +15 -1
  4. package/package.json +2 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/skills/review-requests/SKILL.md +1 -1
  7. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  8. package/public/css/pr.css +287 -14
  9. package/public/index.html +121 -57
  10. package/public/js/components/AIPanel.js +2 -1
  11. package/public/js/components/AdvancedConfigTab.js +2 -2
  12. package/public/js/components/AnalysisConfigModal.js +2 -2
  13. package/public/js/components/ChatPanel.js +187 -28
  14. package/public/js/components/CouncilProgressModal.js +4 -7
  15. package/public/js/components/SplitButton.js +66 -1
  16. package/public/js/components/VoiceCentricConfigTab.js +2 -2
  17. package/public/js/index.js +274 -21
  18. package/public/js/pr.js +194 -5
  19. package/public/local.html +8 -1
  20. package/public/pr.html +17 -2
  21. package/src/ai/codex-provider.js +14 -2
  22. package/src/ai/copilot-provider.js +1 -10
  23. package/src/ai/cursor-agent-provider.js +1 -10
  24. package/src/ai/gemini-provider.js +8 -17
  25. package/src/chat/acp-bridge.js +442 -0
  26. package/src/chat/api-reference.js +539 -0
  27. package/src/chat/chat-providers.js +290 -0
  28. package/src/chat/claude-code-bridge.js +499 -0
  29. package/src/chat/codex-bridge.js +601 -0
  30. package/src/chat/pi-bridge.js +56 -3
  31. package/src/chat/prompt-builder.js +12 -11
  32. package/src/chat/session-manager.js +110 -29
  33. package/src/config.js +4 -2
  34. package/src/database.js +50 -2
  35. package/src/github/client.js +43 -0
  36. package/src/routes/chat.js +60 -27
  37. package/src/routes/config.js +24 -1
  38. package/src/routes/github-collections.js +126 -0
  39. package/src/routes/mcp.js +2 -1
  40. package/src/routes/pr.js +166 -2
  41. package/src/routes/reviews.js +2 -1
  42. package/src/routes/shared.js +70 -49
  43. package/src/server.js +27 -1
  44. package/src/utils/safe-parse-json.js +19 -0
  45. package/.pi/skills/pair-review-api/SKILL.md +0 -448
@@ -279,6 +279,204 @@
279
279
  loaded: false
280
280
  };
281
281
 
282
+ // ─── GitHub PR Collections (My Review Requests / My PRs) ───────────────────
283
+
284
+ var reviewRequestsState = {
285
+ loaded: false,
286
+ prs: [],
287
+ fetchedAt: null
288
+ };
289
+
290
+ var myPrsState = {
291
+ loaded: false,
292
+ prs: [],
293
+ fetchedAt: null
294
+ };
295
+
296
+ /**
297
+ * Render a single row for a collection PR table.
298
+ * @param {Object} pr - PR object from the API
299
+ * @param {string} collection - The collection name ('review-requests' or 'my-prs')
300
+ * @returns {string} HTML string for the table row
301
+ */
302
+ function renderCollectionPrRow(pr, collection) {
303
+ var repoFull = pr.owner + '/' + pr.repo;
304
+ var prUrl = pr.html_url || ('https://github.com/' + repoFull + '/pull/' + pr.number);
305
+ var relativeTime = formatRelativeTime(pr.updated_at);
306
+
307
+ var authorDisplay = pr.author
308
+ ? '<a href="https://github.com/' + encodeURIComponent(pr.author) + '" target="_blank" rel="noopener">' + escapeHtml(pr.author) + '</a>'
309
+ : '';
310
+
311
+ var githubLinkHtml =
312
+ '<a href="' + escapeHtml(pr.html_url || prUrl) + '" target="_blank" rel="noopener" class="btn-github-link" title="Open on GitHub">' +
313
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"/></svg>' +
314
+ '</a>';
315
+
316
+ var authorTd = collection === 'my-prs'
317
+ ? ''
318
+ : '<td class="col-author">' + authorDisplay + '</td>';
319
+
320
+ return '' +
321
+ '<tr class="collection-pr-row" data-pr-url="' + escapeHtml(prUrl) + '">' +
322
+ '<td class="col-repo">' + escapeHtml(repoFull) + '</td>' +
323
+ '<td class="col-pr"><span class="collection-pr-number">#' + pr.number + '</span></td>' +
324
+ '<td class="col-title" title="' + escapeHtml(pr.title || '') + '">' + escapeHtml(pr.title || '') + '</td>' +
325
+ authorTd +
326
+ '<td class="col-time">' + relativeTime + '</td>' +
327
+ '<td class="col-actions">' + githubLinkHtml + '</td>' +
328
+ '</tr>';
329
+ }
330
+
331
+ /**
332
+ * Render the collection table into a container element.
333
+ * @param {HTMLElement} container - The container element
334
+ * @param {Object} state - The collection state object
335
+ * @param {string} collection - The collection name ('review-requests' or 'my-prs')
336
+ */
337
+ function renderCollectionTable(container, state, collection) {
338
+ var fetchedAtId = collection === 'review-requests' ? 'review-requests-fetched-at' : 'my-prs-fetched-at';
339
+ var fetchedAtEl = document.getElementById(fetchedAtId);
340
+ if (fetchedAtEl) {
341
+ var lsKey = 'github-collection-fetched-at:' + collection;
342
+ var displayTs = localStorage.getItem(lsKey) || state.fetchedAt;
343
+ fetchedAtEl.textContent = displayTs
344
+ ? 'Updated ' + formatRelativeTime(displayTs)
345
+ : '';
346
+ }
347
+
348
+ if (state.prs.length === 0) {
349
+ var emptyMsg = collection === 'review-requests'
350
+ ? 'No pull requests awaiting your review.'
351
+ : 'You have no open pull requests.';
352
+
353
+ if (!state.fetchedAt) {
354
+ emptyMsg = 'Click refresh to fetch from GitHub.';
355
+ }
356
+
357
+ container.innerHTML =
358
+ '<div class="recent-reviews-empty">' +
359
+ '<p>' + emptyMsg + '</p>' +
360
+ '</div>';
361
+ container.classList.remove('recent-reviews-loading');
362
+ return;
363
+ }
364
+
365
+ var authorTh = collection === 'my-prs' ? '' : '<th>Author</th>';
366
+
367
+ container.innerHTML =
368
+ '<table class="recent-reviews-table">' +
369
+ '<thead>' +
370
+ '<tr>' +
371
+ '<th>Repository</th>' +
372
+ '<th>PR</th>' +
373
+ '<th>Title</th>' +
374
+ authorTh +
375
+ '<th>Updated</th>' +
376
+ '<th>Actions</th>' +
377
+ '</tr>' +
378
+ '</thead>' +
379
+ '<tbody>' +
380
+ state.prs.map(function (pr) { return renderCollectionPrRow(pr, collection); }).join('') +
381
+ '</tbody>' +
382
+ '</table>';
383
+ container.classList.remove('recent-reviews-loading');
384
+ }
385
+
386
+ /**
387
+ * Load collection PRs from cached backend data.
388
+ * @param {string} collection - 'review-requests' or 'my-prs'
389
+ * @param {string} containerId - DOM id of the container element
390
+ * @param {Object} state - The collection state object
391
+ */
392
+ async function loadCollectionPrs(collection, containerId, state) {
393
+ var container = document.getElementById(containerId);
394
+
395
+ try {
396
+ var response = await fetch('/api/github/' + collection);
397
+ if (!response.ok) throw new Error('Failed to fetch');
398
+ var data = await response.json();
399
+
400
+ state.loaded = true;
401
+ state.prs = data.prs || [];
402
+ state.fetchedAt = data.fetched_at;
403
+
404
+ renderCollectionTable(container, state, collection);
405
+
406
+ // Auto-refresh on first load if cache is empty
407
+ if (state.prs.length === 0 && !state.fetchedAt) {
408
+ refreshCollectionPrs(collection, containerId, state);
409
+ }
410
+ } catch (error) {
411
+ console.error('Error loading ' + collection + ':', error);
412
+ container.innerHTML =
413
+ '<div class="recent-reviews-empty">' +
414
+ '<p>Failed to load. Click refresh to try again.</p>' +
415
+ '</div>';
416
+ container.classList.remove('recent-reviews-loading');
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Refresh collection PRs by fetching fresh data from GitHub.
422
+ * @param {string} collection - 'review-requests' or 'my-prs'
423
+ * @param {string} containerId - DOM id of the container element
424
+ * @param {Object} state - The collection state object
425
+ */
426
+ async function refreshCollectionPrs(collection, containerId, state) {
427
+ var container = document.getElementById(containerId);
428
+ var btnId = collection === 'review-requests' ? 'refresh-review-requests' : 'refresh-my-prs';
429
+ var btn = document.getElementById(btnId);
430
+
431
+ if (btn) btn.classList.add('refreshing');
432
+
433
+ // Show loading state only if this is the first load (no existing data)
434
+ if (state.prs.length === 0) {
435
+ container.innerHTML = '<div class="recent-reviews-loading">Fetching from GitHub...</div>';
436
+ }
437
+
438
+ try {
439
+ var response = await fetch('/api/github/' + collection + '/refresh', { method: 'POST' });
440
+
441
+ if (!response.ok) {
442
+ var errData = await response.json().catch(function() { return {}; });
443
+ if (response.status === 401) {
444
+ container.innerHTML =
445
+ '<div class="recent-reviews-empty">' +
446
+ '<p>Configure a GitHub token to see ' +
447
+ (collection === 'review-requests' ? 'review requests' : 'your pull requests') +
448
+ '.</p>' +
449
+ '</div>';
450
+ container.classList.remove('recent-reviews-loading');
451
+ return;
452
+ }
453
+ throw new Error(errData.error || 'Refresh failed');
454
+ }
455
+
456
+ var data = await response.json();
457
+ state.prs = data.prs || [];
458
+ state.fetchedAt = data.fetched_at;
459
+ state.loaded = true;
460
+ localStorage.setItem('github-collection-fetched-at:' + collection, new Date().toISOString());
461
+
462
+ renderCollectionTable(container, state, collection);
463
+ } catch (error) {
464
+ console.error('Error refreshing ' + collection + ':', error);
465
+ // If we had existing data, keep showing it
466
+ if (state.prs.length > 0) {
467
+ renderCollectionTable(container, state, collection);
468
+ } else {
469
+ container.innerHTML =
470
+ '<div class="recent-reviews-empty">' +
471
+ '<p>Failed to fetch from GitHub. Check your token and try again.</p>' +
472
+ '</div>';
473
+ container.classList.remove('recent-reviews-loading');
474
+ }
475
+ } finally {
476
+ if (btn) btn.classList.remove('refreshing');
477
+ }
478
+ }
479
+
282
480
  /**
283
481
  * Fetch and display local review sessions (initial load).
284
482
  */
@@ -554,6 +752,9 @@
554
752
  '<td class="col-author">' + authorDisplay + '</td>' +
555
753
  '<td class="col-time">' + relativeTime + '</td>' +
556
754
  '<td class="col-actions">' +
755
+ '<a href="https://github.com/' + escapeHtml(worktree.repository) + '/pull/' + worktree.pr_number + '" target="_blank" rel="noopener" class="btn-github-link" title="Open on GitHub">' +
756
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"/></svg>' +
757
+ '</a>' +
557
758
  '<a href="' + settingsLink + '" class="btn-repo-settings" title="Repository settings">' +
558
759
  '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">' +
559
760
  '<path d="M8 0a8.2 8.2 0 0 1 .701.031C9.444.095 9.99.645 10.16 1.29l.288 1.107c.018.066.079.158.212.224.231.114.454.243.668.386.123.082.233.09.299.071l1.103-.303c.644-.176 1.392.021 1.82.63.27.385.506.792.704 1.218.315.675.111 1.422-.364 1.891l-.814.806c-.049.048-.098.147-.088.294.016.257.016.515 0 .772-.01.147.038.246.088.294l.814.806c.475.469.679 1.216.364 1.891a7.977 7.977 0 0 1-.704 1.217c-.428.61-1.176.807-1.82.63l-1.102-.302c-.067-.019-.177-.011-.3.071a5.909 5.909 0 0 1-.668.386c-.133.066-.194.158-.211.224l-.29 1.106c-.168.646-.715 1.196-1.458 1.26a8.006 8.006 0 0 1-1.402 0c-.743-.064-1.289-.614-1.458-1.26l-.289-1.106c-.018-.066-.079-.158-.212-.224a5.738 5.738 0 0 1-.668-.386c-.123-.082-.233-.09-.299-.071l-1.103.303c-.644.176-1.392-.021-1.82-.63a8.12 8.12 0 0 1-.704-1.218c-.315-.675-.111-1.422.363-1.891l.815-.806c.05-.048.098-.147.088-.294a6.214 6.214 0 0 1 0-.772c.01-.147-.038-.246-.088-.294l-.815-.806C.635 6.045.431 5.298.746 4.623a7.92 7.92 0 0 1 .704-1.217c.428-.61 1.176-.807 1.82-.63l1.102.302c.067.019.177.011.3-.071.214-.143.437-.272.668-.386.133-.066.194-.158.211-.224l.29-1.106C6.009.645 6.556.095 7.299.03 7.53.01 7.764 0 8 0Zm-.571 1.525c-.036.003-.108.036-.137.146l-.289 1.105c-.147.561-.549.967-.998 1.189-.173.086-.34.183-.5.29-.417.278-.97.423-1.529.27l-1.103-.303c-.109-.03-.175.016-.195.045-.22.312-.412.644-.573.99-.014.031-.021.11.059.19l.815.806c.411.406.562.957.53 1.456a4.709 4.709 0 0 0 0 .582c.032.499-.119 1.05-.53 1.456l-.815.806c-.081.08-.073.159-.059.19.162.346.353.677.573.989.02.03.085.076.195.046l1.102-.303c.56-.153 1.113-.008 1.53.27.161.107.328.204.501.29.447.222.85.629.997 1.189l.289 1.105c.029.109.101.143.137.146a6.6 6.6 0 0 0 1.142 0c.036-.003.108-.036.137-.146l.289-1.105c.147-.561.549-.967.998-1.189.173-.086.34-.183.5-.29.417-.278.97-.423 1.529-.27l1.103.303c.109.029.175-.016.195-.045.22-.313.411-.644.573-.99.014-.031.021-.11-.059-.19l-.815-.806c-.411-.406-.562-.957-.53-1.456a4.709 4.709 0 0 0 0-.582c-.032-.499.119-1.05.53-1.456l.815-.806c.081-.08.073-.159.059-.19a6.464 6.464 0 0 0-.573-.989c-.02-.03-.085-.076-.195-.046l-1.102.303c-.56.153-1.113.008-1.53-.27a4.44 4.44 0 0 0-.501-.29c-.447-.222-.85-.629-.997-1.189l-.289-1.105c-.029-.11-.101-.143-.137-.146a6.6 6.6 0 0 0-1.142 0ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM9.5 8a1.5 1.5 0 1 0-3.001.001A1.5 1.5 0 0 0 9.5 8Z"/>' +
@@ -649,8 +850,6 @@
649
850
  async function loadRecentReviews() {
650
851
  const container = document.getElementById('recent-reviews-container');
651
852
  const section = document.getElementById('recent-reviews-section');
652
- const usageInfo = document.getElementById('usage-info');
653
-
654
853
  // Reset pagination state
655
854
  recentReviewsPagination.lastTimestamp = null;
656
855
  recentReviewsPagination.hasMore = false;
@@ -665,14 +864,14 @@
665
864
  const data = await response.json();
666
865
 
667
866
  if (!data.success || !data.worktrees || data.worktrees.length === 0) {
668
- // Show friendly empty state with usage info
867
+ // Show friendly empty state
669
868
  container.innerHTML =
670
869
  '<div class="recent-reviews-empty">' +
671
870
  '<p>No PR reviews yet. Paste a PR URL above to get started.</p>' +
672
871
  '</div>';
673
872
  container.classList.remove('recent-reviews-loading');
674
- // Show usage info when no reviews exist
675
- if (usageInfo) usageInfo.classList.remove('loading-hidden');
873
+ // Show help modal when no reviews exist
874
+ openHelpModal();
676
875
  return;
677
876
  }
678
877
 
@@ -703,9 +902,11 @@
703
902
 
704
903
  } catch (error) {
705
904
  console.error('Error loading recent reviews:', error);
706
- // Hide the section on error, show usage info as fallback
707
- section.style.display = 'none';
708
- if (usageInfo) usageInfo.classList.remove('loading-hidden');
905
+ container.innerHTML =
906
+ '<div class="recent-reviews-empty">' +
907
+ '<p>Failed to load recent reviews. Please try refreshing the page.</p>' +
908
+ '</div>';
909
+ container.classList.remove('recent-reviews-loading');
709
910
  }
710
911
  }
711
912
 
@@ -896,10 +1097,17 @@
896
1097
  const config = await response.json();
897
1098
  updateCommandExamples(config.is_running_via_npx);
898
1099
 
899
- // Set chat feature state based on config and Pi availability
1100
+ // Expose chat provider config to components (ChatPanel reads these)
1101
+ window.__pairReview = window.__pairReview || {};
1102
+ window.__pairReview.chatProvider = config.chat_provider || 'pi';
1103
+ const chatProviders = config.chat_providers || [];
1104
+ window.__pairReview.chatProviders = chatProviders;
1105
+
1106
+ // Set chat feature state based on config and provider availability
900
1107
  let chatState = 'disabled';
901
1108
  if (config.enable_chat) {
902
- chatState = config.pi_available ? 'available' : 'unavailable';
1109
+ const anyAvailable = chatProviders.some(p => p.available);
1110
+ chatState = anyAvailable ? 'available' : 'unavailable';
903
1111
  }
904
1112
  document.documentElement.setAttribute('data-chat', chatState);
905
1113
  window.dispatchEvent(new CustomEvent('chat-state-changed', { detail: { state: chatState } }));
@@ -936,6 +1144,46 @@
936
1144
  return;
937
1145
  }
938
1146
 
1147
+ // Refresh buttons for GitHub collections
1148
+ var refreshReviewRequestsBtn = event.target.closest('#refresh-review-requests');
1149
+ if (refreshReviewRequestsBtn) {
1150
+ event.preventDefault();
1151
+ refreshCollectionPrs('review-requests', 'review-requests-container', reviewRequestsState);
1152
+ return;
1153
+ }
1154
+
1155
+ var refreshMyPrsBtn = event.target.closest('#refresh-my-prs');
1156
+ if (refreshMyPrsBtn) {
1157
+ event.preventDefault();
1158
+ refreshCollectionPrs('my-prs', 'my-prs-container', myPrsState);
1159
+ return;
1160
+ }
1161
+
1162
+ // Click on a collection PR row to start review
1163
+ var collectionRow = event.target.closest('.collection-pr-row');
1164
+ if (collectionRow && !event.target.closest('a')) {
1165
+ var prUrl = collectionRow.dataset.prUrl;
1166
+ if (prUrl) {
1167
+ // Switch to PR tab to show loading state
1168
+ var tabBar = document.getElementById('unified-tab-bar');
1169
+ var prTabBtn = tabBar.querySelector('[data-tab="pr-tab"]');
1170
+ switchTab(tabBar, prTabBtn);
1171
+ localStorage.setItem(TAB_STORAGE_KEY, 'pr-tab');
1172
+
1173
+ // Populate input and submit the form programmatically
1174
+ var input = document.getElementById('pr-url-input');
1175
+ if (input) {
1176
+ input.value = prUrl;
1177
+ // Trigger the form submit
1178
+ var form = document.getElementById('start-review-form');
1179
+ if (form) {
1180
+ form.dispatchEvent(new Event('submit', { cancelable: true }));
1181
+ }
1182
+ }
1183
+ }
1184
+ return;
1185
+ }
1186
+
939
1187
  // Show more (PR reviews)
940
1188
  const showMoreBtn = event.target.closest('#btn-show-more');
941
1189
  if (showMoreBtn) {
@@ -963,6 +1211,13 @@
963
1211
  if (tabId === 'local-tab' && !localReviewsPagination.loaded) {
964
1212
  loadLocalReviews();
965
1213
  }
1214
+ // Lazy-load GitHub collection tabs on first switch
1215
+ if (tabId === 'review-requests-tab' && !reviewRequestsState.loaded) {
1216
+ loadCollectionPrs('review-requests', 'review-requests-container', reviewRequestsState);
1217
+ }
1218
+ if (tabId === 'my-prs-tab' && !myPrsState.loaded) {
1219
+ loadCollectionPrs('my-prs', 'my-prs-container', myPrsState);
1220
+ }
966
1221
  });
967
1222
  return;
968
1223
  }
@@ -972,17 +1227,7 @@
972
1227
 
973
1228
  document.addEventListener('DOMContentLoaded', function () {
974
1229
  // Load config and update command examples based on npx detection
975
- loadConfigAndUpdateUI().then(function () {
976
- // Sync help content to usage-info section AFTER command examples are updated
977
- const helpContent = document.querySelector('.help-modal-content');
978
- const usageInfo = document.getElementById('usage-info');
979
- if (helpContent && usageInfo) {
980
- usageInfo.innerHTML = '';
981
- Array.from(helpContent.childNodes).forEach(function (node) {
982
- usageInfo.appendChild(node.cloneNode(true));
983
- });
984
- }
985
- });
1230
+ loadConfigAndUpdateUI();
986
1231
 
987
1232
  // Restore saved tab from localStorage (default: 'pr-tab')
988
1233
  const savedTab = localStorage.getItem(TAB_STORAGE_KEY) || 'pr-tab';
@@ -1003,6 +1248,14 @@
1003
1248
  loadLocalReviews();
1004
1249
  }
1005
1250
 
1251
+ // If a GitHub collection tab is active, load it immediately
1252
+ if (savedTab === 'review-requests-tab') {
1253
+ loadCollectionPrs('review-requests', 'review-requests-container', reviewRequestsState);
1254
+ }
1255
+ if (savedTab === 'my-prs-tab') {
1256
+ loadCollectionPrs('my-prs', 'my-prs-container', myPrsState);
1257
+ }
1258
+
1006
1259
  // Set up start review form handler
1007
1260
  const form = document.getElementById('start-review-form');
1008
1261
  if (form) {
package/public/js/pr.js CHANGED
@@ -260,6 +260,9 @@ class PRManager {
260
260
  refreshBtn.addEventListener('click', () => this.refreshPR());
261
261
  }
262
262
 
263
+ // PR description popover
264
+ this.setupPRDescriptionPopover();
265
+
263
266
  // Setup comment form keyboard shortcut delegation
264
267
  this.setupCommentFormDelegation();
265
268
 
@@ -494,6 +497,19 @@ class PRManager {
494
497
  }
495
498
  }
496
499
 
500
+ /**
501
+ * Reload AI suggestions and user comments after an analysis completes.
502
+ * Shared by the foreground `analysis_completed` handler and the deferred
503
+ * `_dirtyAnalysis` branch in the visibilitychange listener.
504
+ */
505
+ _reloadAfterAnalysis() {
506
+ const includeDismissed = window.aiPanel?.showDismissedComments ?? false;
507
+ return Promise.all([
508
+ this.loadAISuggestions(),
509
+ this.loadUserComments(includeDismissed)
510
+ ]);
511
+ }
512
+
497
513
  /**
498
514
  * Listen for review-scoped CustomEvents dispatched by ChatPanel's
499
515
  * WebSocket pub/sub connection.
@@ -555,9 +571,9 @@ class PRManager {
555
571
  debounced('analysis', () => {
556
572
  if (this.analysisHistoryManager) {
557
573
  this.analysisHistoryManager.refresh({ switchToNew: true })
558
- .then(() => this.loadAISuggestions());
574
+ .then(() => this._reloadAfterAnalysis());
559
575
  } else {
560
- this.loadAISuggestions();
576
+ this._reloadAfterAnalysis();
561
577
  }
562
578
  });
563
579
  });
@@ -590,9 +606,9 @@ class PRManager {
590
606
  this._dirtySuggestions = false; // analysis refresh includes suggestion reload
591
607
  if (this.analysisHistoryManager) {
592
608
  this.analysisHistoryManager.refresh({ switchToNew: true })
593
- .then(() => this.loadAISuggestions());
609
+ .then(() => this._reloadAfterAnalysis());
594
610
  } else {
595
- this.loadAISuggestions();
611
+ this._reloadAfterAnalysis();
596
612
  }
597
613
  } else if (this._dirtySuggestions) {
598
614
  this._dirtySuggestions = false;
@@ -774,6 +790,18 @@ class PRManager {
774
790
  titleElement.textContent = pr.title;
775
791
  }
776
792
 
793
+ // Show/hide PR description info button
794
+ const descToggle = document.getElementById('pr-description-toggle');
795
+ if (descToggle) {
796
+ if (pr.body) {
797
+ descToggle.style.display = '';
798
+ this._prBody = pr.body;
799
+ } else {
800
+ descToggle.style.display = 'none';
801
+ this._prBody = null;
802
+ }
803
+ }
804
+
777
805
  // Update meta info - show only head branch, full info in tooltip
778
806
  const branchName = document.getElementById('pr-branch-name');
779
807
  const branchContainer = document.getElementById('pr-branch');
@@ -875,6 +903,81 @@ class PRManager {
875
903
  this.updatePendingDraftIndicator(pr.pendingDraft);
876
904
  }
877
905
 
906
+ /**
907
+ * Set up the PR description popover toggle (called once during init).
908
+ */
909
+ setupPRDescriptionPopover() {
910
+ const toggle = document.getElementById('pr-description-toggle');
911
+ if (!toggle) return;
912
+
913
+ const wrapper = toggle.closest('.pr-title-wrapper');
914
+ if (!wrapper) return;
915
+
916
+ const closePopover = () => {
917
+ const existing = wrapper.querySelector('.pr-description-popover');
918
+ if (existing) existing.remove();
919
+ toggle.classList.remove('active');
920
+ toggle.setAttribute('aria-expanded', 'false');
921
+ };
922
+
923
+ toggle.addEventListener('click', (e) => {
924
+ e.stopPropagation();
925
+ const existing = wrapper.querySelector('.pr-description-popover');
926
+ if (existing) {
927
+ closePopover();
928
+ return;
929
+ }
930
+
931
+ const body = this._prBody || '';
932
+ const rendered = window.renderMarkdown ? window.renderMarkdown(body) : this.escapeHtml(body);
933
+
934
+ const popover = document.createElement('div');
935
+ popover.className = 'pr-description-popover';
936
+
937
+ const arrow = document.createElement('div');
938
+ arrow.className = 'pr-description-popover-arrow';
939
+
940
+ const header = document.createElement('div');
941
+ header.className = 'pr-description-popover-header';
942
+
943
+ const title = document.createElement('span');
944
+ title.className = 'pr-description-popover-title';
945
+ title.textContent = 'PR Description';
946
+
947
+ const closeBtn = document.createElement('button');
948
+ closeBtn.className = 'pr-description-popover-close';
949
+ closeBtn.title = 'Close';
950
+ closeBtn.innerHTML = '&times;';
951
+
952
+ header.append(title, closeBtn);
953
+
954
+ const content = document.createElement('div');
955
+ content.className = 'pr-description-popover-content';
956
+ content.innerHTML = rendered;
957
+
958
+ popover.append(arrow, header, content);
959
+
960
+ wrapper.appendChild(popover);
961
+ toggle.classList.add('active');
962
+ toggle.setAttribute('aria-expanded', 'true');
963
+
964
+ closeBtn.addEventListener('click', (ev) => {
965
+ ev.stopPropagation();
966
+ closePopover();
967
+ });
968
+
969
+ popover.addEventListener('click', (ev) => ev.stopPropagation());
970
+ });
971
+
972
+ document.addEventListener('click', () => {
973
+ if (toggle.classList.contains('active')) closePopover();
974
+ });
975
+
976
+ document.addEventListener('keydown', (e) => {
977
+ if (e.key === 'Escape' && toggle.classList.contains('active')) closePopover();
978
+ });
979
+ }
980
+
878
981
  /**
879
982
  * Update the pending draft indicator in the toolbar
880
983
  * @param {Object|null} pendingDraft - Pending draft data or null if no draft
@@ -2940,14 +3043,37 @@ class PRManager {
2940
3043
  // Clear placeholder in case of any orphaned elements
2941
3044
  placeholder.innerHTML = '';
2942
3045
 
3046
+ const shareConfig = window.__pairReview?.share;
3047
+ let validatedShareUrl = null;
3048
+ if (shareConfig?.url) {
3049
+ try {
3050
+ new URL(shareConfig.url);
3051
+ validatedShareUrl = shareConfig.url;
3052
+ } catch {
3053
+ // Invalid share URL in config — don't render share button
3054
+ }
3055
+ }
2943
3056
  this.splitButton = new window.SplitButton({
2944
3057
  onSubmit: () => this.openReviewModal(),
2945
3058
  onPreview: () => this.openPreviewModal(),
2946
- onClear: () => this.clearAllUserComments()
3059
+ onClear: () => this.clearAllUserComments(),
3060
+ onShare: () => this.openSharePage(),
3061
+ shareUrl: validatedShareUrl,
3062
+ shareIcon: shareConfig?.icon || null,
3063
+ shareLabel: shareConfig?.label || 'Share',
3064
+ shareDescription: shareConfig?.description || null
2947
3065
  });
2948
3066
  const buttonElement = this.splitButton.render();
2949
3067
  placeholder.appendChild(buttonElement);
2950
3068
  this.updateCommentCount();
3069
+
3070
+ // Handle late config arrival — update share config when config fetch resolves
3071
+ window.addEventListener('chat-state-changed', () => {
3072
+ const lateCfg = window.__pairReview?.share;
3073
+ if (this.splitButton) {
3074
+ this.splitButton.setShareConfig(lateCfg || null);
3075
+ }
3076
+ }, { once: true });
2951
3077
  }
2952
3078
  }
2953
3079
  }
@@ -2972,6 +3098,69 @@ class PRManager {
2972
3098
  this.previewModal.show();
2973
3099
  }
2974
3100
 
3101
+ /**
3102
+ * Open share page in a new tab
3103
+ * Builds the share URL with a callback_url pointing to this PR's share endpoint
3104
+ * Validates that there is analysis data to share before opening.
3105
+ */
3106
+ async openSharePage() {
3107
+ const shareConfig = window.__pairReview?.share;
3108
+ if (!shareConfig?.url) return;
3109
+
3110
+ const pr = this.currentPR;
3111
+ if (!pr) return;
3112
+
3113
+ // Validate the share URL before attempting to use it
3114
+ let shareUrl;
3115
+ try {
3116
+ shareUrl = new URL(shareConfig.url);
3117
+ } catch {
3118
+ console.error('Invalid share URL in configuration:', shareConfig.url);
3119
+ return;
3120
+ }
3121
+
3122
+ // Build the callback URL for the share endpoint
3123
+ let callbackUrl = `${window.location.origin}/api/pr/${encodeURIComponent(pr.owner)}/${encodeURIComponent(pr.repo)}/${pr.number}/share`;
3124
+
3125
+ // Include selected run ID if one is explicitly selected
3126
+ if (this.selectedRunId) {
3127
+ callbackUrl += `?runId=${encodeURIComponent(this.selectedRunId)}`;
3128
+ }
3129
+
3130
+ // Check that there is analysis data to share before opening the page
3131
+ try {
3132
+ const response = await fetch(callbackUrl);
3133
+ if (!response.ok) {
3134
+ if (window.toast) {
3135
+ window.toast.showError('Unable to share: could not load review data');
3136
+ }
3137
+ return;
3138
+ }
3139
+ const data = await response.json().catch(() => null);
3140
+ if (!data) {
3141
+ if (window.toast) {
3142
+ window.toast.showError('Unable to share: unexpected response from server');
3143
+ }
3144
+ return;
3145
+ }
3146
+ if (!data.run) {
3147
+ if (window.toast) {
3148
+ window.toast.showError('Nothing to share: no completed analysis found. Run an AI analysis first.');
3149
+ }
3150
+ return;
3151
+ }
3152
+ } catch (error) {
3153
+ console.error('Error checking share data:', error);
3154
+ if (window.toast) {
3155
+ window.toast.showError('Unable to share: ' + error.message);
3156
+ }
3157
+ return;
3158
+ }
3159
+
3160
+ shareUrl.searchParams.set('callback_url', callbackUrl);
3161
+ window.open(shareUrl.toString(), '_blank');
3162
+ }
3163
+
2975
3164
  /**
2976
3165
  * Update comment count display
2977
3166
  * Note: Dismissed comments are never in the diff DOM (design decision), so we simply count all visible elements.
package/public/local.html CHANGED
@@ -10,8 +10,15 @@
10
10
  // Fetch chat availability early so PanelGroup sees the correct state
11
11
  fetch('/api/config').then(r => r.ok ? r.json() : null).then(config => {
12
12
  if (!config) return;
13
+ const chatProviders = config.chat_providers || [];
13
14
  let state = 'disabled';
14
- if (config.enable_chat) state = config.pi_available ? 'available' : 'unavailable';
15
+ if (config.enable_chat) {
16
+ const anyAvailable = chatProviders.some(p => p.available);
17
+ state = anyAvailable ? 'available' : 'unavailable';
18
+ }
19
+ window.__pairReview = window.__pairReview || {};
20
+ window.__pairReview.chatProvider = config.chat_provider || 'pi';
21
+ window.__pairReview.chatProviders = chatProviders;
15
22
  document.documentElement.setAttribute('data-chat', state);
16
23
  const shortcutsState = config.chat_enable_shortcuts === false ? 'disabled' : 'enabled';
17
24
  document.documentElement.setAttribute('data-chat-shortcuts', shortcutsState);