@in-the-loop-labs/pair-review 3.1.3 → 3.2.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 (39) hide show
  1. package/package.json +1 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  4. package/public/css/pr.css +980 -3
  5. package/public/js/components/AIPanel.js +7 -4
  6. package/public/js/components/ChatPanel.js +34 -4
  7. package/public/js/components/CouncilProgressModal.js +11 -0
  8. package/public/js/components/NotificationDropdown.js +257 -0
  9. package/public/js/components/StackAnalysisDialog.js +313 -0
  10. package/public/js/components/StackProgressModal.js +475 -0
  11. package/public/js/components/StatusIndicator.js +1 -0
  12. package/public/js/components/SuggestionNavigator.js +2 -0
  13. package/public/js/modules/comment-manager.js +7 -0
  14. package/public/js/modules/comment-minimizer.js +151 -4
  15. package/public/js/modules/file-comment-manager.js +66 -2
  16. package/public/js/modules/suggestion-manager.js +2 -1
  17. package/public/js/pr.js +433 -2
  18. package/public/js/utils/notification-sounds.js +62 -0
  19. package/public/local.html +10 -0
  20. package/public/pr.html +12 -0
  21. package/public/setup.html +4 -0
  22. package/src/ai/claude-provider.js +1 -11
  23. package/src/ai/codex-provider.js +18 -16
  24. package/src/ai/copilot-provider.js +21 -21
  25. package/src/ai/gemini-provider.js +10 -0
  26. package/src/ai/pi-provider.js +22 -25
  27. package/src/ai/provider.js +26 -3
  28. package/src/chat/pi-bridge.js +8 -0
  29. package/src/chat/session-manager.js +1 -0
  30. package/src/git/base-branch.js +1 -51
  31. package/src/git/worktree-lock.js +88 -0
  32. package/src/git/worktree.js +64 -0
  33. package/src/github/stack-walker.js +196 -0
  34. package/src/routes/local.js +12 -8
  35. package/src/routes/pr.js +139 -26
  36. package/src/routes/sound.js +49 -0
  37. package/src/routes/stack-analysis.js +886 -0
  38. package/src/server.js +4 -0
  39. package/src/setup/stack-setup.js +77 -0
@@ -7,7 +7,10 @@
7
7
  * are injected on the right edge of each diff line that has comments, showing
8
8
  * a person icon (user comments) or sparkles icon (AI suggestions).
9
9
  *
10
- * Clicking an indicator toggles visibility of that line's comments only.
10
+ * File-level comments (.file-comment-card inside .file-comments-zone) are also
11
+ * hidden, with an indicator button injected into the file header bar.
12
+ *
13
+ * Clicking an indicator toggles visibility of that line's or file's comments.
11
14
  */
12
15
 
13
16
  class CommentMinimizer {
@@ -24,6 +27,8 @@ class CommentMinimizer {
24
27
  this._active = false;
25
28
  // Track which diff lines have been expanded by the user (Set of diff row elements)
26
29
  this._expandedLines = new Set();
30
+ // Track which file-comments-zones have been expanded (Set of zone elements)
31
+ this._expandedFiles = new Set();
27
32
  }
28
33
 
29
34
  /** @returns {boolean} Whether minimize mode is active */
@@ -38,6 +43,7 @@ class CommentMinimizer {
38
43
  setMinimized(minimized) {
39
44
  this._active = minimized;
40
45
  this._expandedLines.clear();
46
+ this._expandedFiles.clear();
41
47
 
42
48
  const diffContainer = document.getElementById('diff-container');
43
49
  if (!diffContainer) return;
@@ -50,6 +56,8 @@ class CommentMinimizer {
50
56
  this._removeAllIndicators();
51
57
  // Remove any per-line expansion overrides
52
58
  document.querySelectorAll('.comment-expanded').forEach(el => el.classList.remove('comment-expanded'));
59
+ // Remove any per-file expansion overrides
60
+ document.querySelectorAll('.file-comments-expanded').forEach(el => el.classList.remove('file-comments-expanded'));
53
61
  }
54
62
  }
55
63
 
@@ -103,10 +111,13 @@ class CommentMinimizer {
103
111
  lineMap.set(diffRow, entry);
104
112
  }
105
113
 
106
- // Inject indicators
114
+ // Inject line-level indicators
107
115
  for (const [diffRow, info] of lineMap) {
108
116
  this._injectIndicator(diffRow, info);
109
117
  }
118
+
119
+ // Scan file-comments-zones and inject file-header indicators
120
+ this._refreshFileIndicators();
110
121
  }
111
122
 
112
123
  /**
@@ -266,7 +277,22 @@ class CommentMinimizer {
266
277
  expandForElement(element) {
267
278
  if (!this._active) return;
268
279
 
269
- // Find the containing comment/suggestion row
280
+ // Check if this element is inside a file-comments-zone (file-level comment)
281
+ const zone = element.closest('.file-comments-zone');
282
+ if (zone) {
283
+ if (this._expandedFiles.has(zone)) return; // already expanded
284
+ this._expandedFiles.add(zone);
285
+ zone.classList.add('file-comments-expanded');
286
+ // Update the file-header indicator button
287
+ const wrapper = zone.closest('.d2h-file-wrapper');
288
+ const btn = wrapper?.querySelector('.d2h-file-header .file-comment-indicator');
289
+ if (btn) {
290
+ btn.classList.add('expanded');
291
+ }
292
+ return;
293
+ }
294
+
295
+ // Line-level: find the containing comment/suggestion row
270
296
  const commentRow = element.closest('.user-comment-row, .ai-suggestion-row') || element;
271
297
  if (!commentRow.classList.contains('user-comment-row') && !commentRow.classList.contains('ai-suggestion-row')) {
272
298
  return;
@@ -290,9 +316,130 @@ class CommentMinimizer {
290
316
  }
291
317
  }
292
318
 
293
- /** Remove all indicator buttons from the DOM. */
319
+ // ---------------------------------------------------------------------------
320
+ // File-level comment indicators
321
+ // ---------------------------------------------------------------------------
322
+
323
+ /**
324
+ * Scan all file-comments-zones and inject indicator buttons into file headers.
325
+ */
326
+ _refreshFileIndicators() {
327
+ const zones = document.querySelectorAll('.file-comments-zone');
328
+ for (const zone of zones) {
329
+ const cards = zone.querySelectorAll('.file-comment-card');
330
+ if (cards.length === 0) continue;
331
+
332
+ // Count comment types
333
+ const info = { hasUser: false, hasAI: false, hasAdopted: false, userCount: 0, aiCount: 0, adoptedCount: 0 };
334
+ for (const card of cards) {
335
+ // Skip collapsed cards (adopted/dismissed originals remain in DOM)
336
+ if (card.classList.contains('collapsed')) continue;
337
+
338
+ if (card.classList.contains('ai-suggestion')) {
339
+ info.hasAI = true;
340
+ info.aiCount++;
341
+ } else if (card.classList.contains('user-comment')) {
342
+ if (card.classList.contains('adopted-comment')) {
343
+ info.hasAdopted = true;
344
+ info.adoptedCount++;
345
+ } else {
346
+ info.hasUser = true;
347
+ info.userCount++;
348
+ }
349
+ }
350
+ }
351
+
352
+ if (info.userCount + info.aiCount + info.adoptedCount === 0) continue;
353
+
354
+ // Find the file header — zone and header are siblings inside .d2h-file-wrapper
355
+ const wrapper = zone.closest('.d2h-file-wrapper');
356
+ const header = wrapper?.querySelector('.d2h-file-header');
357
+ if (!header) continue;
358
+
359
+ this._injectFileIndicator(header, zone, info);
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Inject an indicator button into a file header, positioned before the comment button.
365
+ * @param {HTMLElement} header - The .d2h-file-header element
366
+ * @param {HTMLElement} zone - The .file-comments-zone element
367
+ * @param {Object} info - { hasUser, hasAI, hasAdopted, userCount, aiCount, adoptedCount }
368
+ */
369
+ _injectFileIndicator(header, zone, info) {
370
+ // Don't double-inject
371
+ if (header.querySelector('.file-comment-indicator')) return;
372
+
373
+ const btn = document.createElement('button');
374
+ btn.className = 'file-comment-indicator';
375
+ btn.type = 'button';
376
+
377
+ // Build icon — pick the dominant type icon
378
+ const icons = [];
379
+ if (info.hasUser) {
380
+ icons.push(`<span class="indicator-icon indicator-user">${CommentMinimizer.PERSON_ICON}</span>`);
381
+ }
382
+ if (info.hasAdopted) {
383
+ icons.push(`<span class="indicator-icon indicator-adopted">${CommentMinimizer.AI_COMMENT_ICON}</span>`);
384
+ }
385
+ if (info.hasAI) {
386
+ icons.push(`<span class="indicator-icon indicator-ai">${CommentMinimizer.SPARKLES_ICON}</span>`);
387
+ }
388
+
389
+ const total = info.userCount + info.adoptedCount + info.aiCount;
390
+ const countBadge = total > 1 ? `<span class="indicator-count">${total}</span>` : '';
391
+
392
+ btn.innerHTML = icons.join('') + countBadge;
393
+
394
+ const totalLabel = [];
395
+ if (info.userCount) totalLabel.push(`${info.userCount} file comment${info.userCount !== 1 ? 's' : ''}`);
396
+ if (info.adoptedCount) totalLabel.push(`${info.adoptedCount} adopted`);
397
+ if (info.aiCount) totalLabel.push(`${info.aiCount} suggestion${info.aiCount !== 1 ? 's' : ''}`);
398
+ btn.title = totalLabel.join(', ');
399
+
400
+ // Restore expanded state
401
+ if (this._expandedFiles.has(zone)) {
402
+ btn.classList.add('expanded');
403
+ }
404
+
405
+ btn.addEventListener('click', (e) => {
406
+ e.stopPropagation();
407
+ e.preventDefault();
408
+ this._toggleFileComments(zone, btn);
409
+ });
410
+
411
+ // Insert before the file-header-comment-btn if present, otherwise append
412
+ const commentBtn = header.querySelector('.file-header-comment-btn');
413
+ if (commentBtn) {
414
+ header.insertBefore(btn, commentBtn);
415
+ } else {
416
+ header.appendChild(btn);
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Toggle visibility of file-level comments for a specific file.
422
+ * @param {HTMLElement} zone - The .file-comments-zone element
423
+ * @param {HTMLElement} btn - The indicator button
424
+ */
425
+ _toggleFileComments(zone, btn) {
426
+ const isExpanded = this._expandedFiles.has(zone);
427
+
428
+ if (isExpanded) {
429
+ this._expandedFiles.delete(zone);
430
+ btn.classList.remove('expanded');
431
+ zone.classList.remove('file-comments-expanded');
432
+ } else {
433
+ this._expandedFiles.add(zone);
434
+ btn.classList.add('expanded');
435
+ zone.classList.add('file-comments-expanded');
436
+ }
437
+ }
438
+
439
+ /** Remove all indicator buttons (both line-level and file-level) from the DOM. */
294
440
  _removeAllIndicators() {
295
441
  document.querySelectorAll('.comment-indicator').forEach(btn => btn.remove());
442
+ document.querySelectorAll('.file-comment-indicator').forEach(btn => btn.remove());
296
443
  }
297
444
  }
298
445
 
@@ -17,12 +17,15 @@ class FileCommentManager {
17
17
  if (chatBtn && window.chatPanel) {
18
18
  e.stopPropagation();
19
19
  const suggestionCard = chatBtn.closest('.ai-suggestion');
20
- const bodyText = suggestionCard?.dataset?.originalBody
21
- ? JSON.parse(suggestionCard.dataset.originalBody) : '';
20
+ const bodyText = suggestionCard?.dataset?.formattedBody
21
+ ? JSON.parse(suggestionCard.dataset.formattedBody)
22
+ : suggestionCard?.dataset?.originalBody
23
+ ? JSON.parse(suggestionCard.dataset.originalBody) : '';
22
24
  window.chatPanel.open({
23
25
  reviewId: this.prManager?.currentPR?.id,
24
26
  suggestionId: chatBtn.dataset.suggestionId,
25
27
  suggestionContext: {
28
+ suggestionId: chatBtn.dataset.suggestionId || null,
26
29
  title: chatBtn.dataset.title || '',
27
30
  body: bodyText,
28
31
  type: suggestionCard?.querySelector('.ai-suggestion-badge')?.dataset?.type || '',
@@ -312,6 +315,16 @@ class FileCommentManager {
312
315
  // Update count badge
313
316
  this.updateCommentCount(zone);
314
317
 
318
+ // Refresh minimize-mode indicators so file-header counts stay current
319
+ if (this.prManager?.commentMinimizer) {
320
+ this.prManager.commentMinimizer.refreshIndicators();
321
+ // Auto-expand so the new comment stays visible in minimize mode
322
+ const newCard = zone.querySelector(`.file-comment-card[data-comment-id="${commentData.id}"]`);
323
+ if (newCard) {
324
+ this.prManager.commentMinimizer.expandForElement(newCard);
325
+ }
326
+ }
327
+
315
328
  // Notify AI Panel if available
316
329
  if (window.aiPanel?.addComment) {
317
330
  window.aiPanel.addComment(commentData);
@@ -322,6 +335,8 @@ class FileCommentManager {
322
335
  this.prManager.updateCommentCount();
323
336
  }
324
337
 
338
+ window.chatPanel?.queueUserActionHint(`[User Action: created comment ${result.commentId}]`);
339
+
325
340
  } catch (error) {
326
341
  console.error('Error saving file-level comment:', error);
327
342
  if (window.toast) {
@@ -432,6 +447,7 @@ class FileCommentManager {
432
447
  // Store original markdown body for adopt functionality via extractSuggestionData
433
448
  // Use JSON.stringify to preserve newlines and special characters (matches line-level suggestions)
434
449
  card.dataset.originalBody = JSON.stringify(suggestion.body || '');
450
+ card.dataset.formattedBody = JSON.stringify(suggestion.formattedBody || '');
435
451
 
436
452
  // Store target info on the card for reliable retrieval in getFileAndLineInfo
437
453
  // File-level suggestions don't have line numbers, just the file name
@@ -604,6 +620,16 @@ class FileCommentManager {
604
620
  this.displayUserComment(zone, commentData);
605
621
  this.updateCommentCount(zone);
606
622
 
623
+ // Refresh minimize-mode indicators so file-header counts stay current
624
+ if (this.prManager?.commentMinimizer) {
625
+ this.prManager.commentMinimizer.refreshIndicators();
626
+ // Auto-expand so the new comment stays visible in minimize mode
627
+ const newCard = zone.querySelector(`.file-comment-card[data-comment-id="${commentData.id}"]`);
628
+ if (newCard) {
629
+ this.prManager.commentMinimizer.expandForElement(newCard);
630
+ }
631
+ }
632
+
607
633
  // Update parent comment count for Preview button
608
634
  if (this.prManager?.updateCommentCount) {
609
635
  this.prManager.updateCommentCount();
@@ -619,6 +645,8 @@ class FileCommentManager {
619
645
  window.aiPanel.updateFindingStatus(suggestion.id, 'adopted');
620
646
  }
621
647
 
648
+ window.chatPanel?.queueUserActionHint(`[User Action: adopted suggestion ${suggestion.id}]`);
649
+
622
650
  } catch (error) {
623
651
  console.error('Error adopting suggestion:', error);
624
652
  if (window.toast) {
@@ -657,11 +685,18 @@ class FileCommentManager {
657
685
 
658
686
  this.updateCommentCount(zone);
659
687
 
688
+ // Refresh minimize-mode indicators so file-header counts stay current
689
+ if (this.prManager?.commentMinimizer) {
690
+ this.prManager.commentMinimizer.refreshIndicators();
691
+ }
692
+
660
693
  // Update finding status in AI Panel (mark suggestion as dismissed)
661
694
  if (window.aiPanel?.updateFindingStatus) {
662
695
  window.aiPanel.updateFindingStatus(suggestionId, 'dismissed');
663
696
  }
664
697
 
698
+ window.chatPanel?.queueUserActionHint(`[User Action: dismissed suggestion ${suggestionId}]`);
699
+
665
700
  } catch (error) {
666
701
  console.error('Error dismissing suggestion:', error);
667
702
  if (window.toast) {
@@ -703,6 +738,13 @@ class FileCommentManager {
703
738
  // Update comment count (for consistency with dismissAISuggestion)
704
739
  this.updateCommentCount(zone);
705
740
 
741
+ // Refresh minimize-mode indicators so file-header counts stay current
742
+ if (this.prManager?.commentMinimizer) {
743
+ this.prManager.commentMinimizer.refreshIndicators();
744
+ }
745
+
746
+ window.chatPanel?.queueUserActionHint(`[User Action: restored suggestion ${suggestionId}]`);
747
+
706
748
  } catch (error) {
707
749
  console.error('Error restoring suggestion:', error);
708
750
  if (window.toast) {
@@ -848,6 +890,16 @@ class FileCommentManager {
848
890
  this.displayUserComment(zone, commentData);
849
891
  this.updateCommentCount(zone);
850
892
 
893
+ // Refresh minimize-mode indicators so file-header counts stay current
894
+ if (this.prManager?.commentMinimizer) {
895
+ this.prManager.commentMinimizer.refreshIndicators();
896
+ // Auto-expand so the new comment stays visible in minimize mode
897
+ const newCard = zone.querySelector(`.file-comment-card[data-comment-id="${commentData.id}"]`);
898
+ if (newCard) {
899
+ this.prManager.commentMinimizer.expandForElement(newCard);
900
+ }
901
+ }
902
+
851
903
  // Update parent comment count for Preview button
852
904
  if (this.prManager?.updateCommentCount) {
853
905
  this.prManager.updateCommentCount();
@@ -863,6 +915,8 @@ class FileCommentManager {
863
915
  window.aiPanel.updateFindingStatus(suggestion.id, 'adopted');
864
916
  }
865
917
 
918
+ window.chatPanel?.queueUserActionHint(`[User Action: adopted suggestion ${suggestion.id}]`);
919
+
866
920
  } catch (error) {
867
921
  console.error('Error adopting suggestion with edit:', error);
868
922
  if (window.toast) {
@@ -1012,6 +1066,11 @@ class FileCommentManager {
1012
1066
 
1013
1067
  this.updateCommentCount(zone);
1014
1068
 
1069
+ // Refresh minimize-mode indicators so file-header counts stay current
1070
+ if (this.prManager?.commentMinimizer) {
1071
+ this.prManager.commentMinimizer.refreshIndicators();
1072
+ }
1073
+
1015
1074
  // Update parent comment count
1016
1075
  if (this.prManager?.updateCommentCount) {
1017
1076
  this.prManager.updateCommentCount();
@@ -1029,6 +1088,11 @@ class FileCommentManager {
1029
1088
  window.aiPanel.updateFindingStatus(apiResult.dismissedSuggestionId, 'dismissed');
1030
1089
  }
1031
1090
 
1091
+ if (apiResult.dismissedSuggestionId) {
1092
+ window.chatPanel?.queueUserActionHint(`[User Action: dismissed suggestion ${apiResult.dismissedSuggestionId}]`);
1093
+ }
1094
+ window.chatPanel?.queueUserActionHint(`[User Action: dismissed comment ${commentId}]`);
1095
+
1032
1096
  } catch (error) {
1033
1097
  console.error('Error deleting comment:', error);
1034
1098
  if (window.toast) {
@@ -24,8 +24,9 @@ class SuggestionManager {
24
24
  reviewId: this.prManager?.currentPR?.id,
25
25
  suggestionId: chatBtn.dataset.suggestionId,
26
26
  suggestionContext: {
27
+ suggestionId: chatBtn.dataset.suggestionId || null,
27
28
  title: chatBtn.dataset.title || suggestionData.suggestionTitle || '',
28
- body: suggestionData.suggestionText || '',
29
+ body: suggestionData.formattedBody || suggestionData.suggestionText || '',
29
30
  type: suggestionData.suggestionType || '',
30
31
  file: chatBtn.dataset.file || '',
31
32
  line_start: suggestionDiv?.dataset?.lineNumber ? parseInt(suggestionDiv.dataset.lineNumber) : null,