@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
@@ -807,8 +807,9 @@ class AIPanel {
807
807
  const finding = this.findings.find(f => f.id === findingId);
808
808
  if (finding) {
809
809
  suggestionContext = {
810
+ suggestionId: findingId ? String(findingId) : null,
810
811
  title: finding.title || title,
811
- body: finding.body || '',
812
+ body: finding.formattedBody || finding.body || '',
812
813
  type: finding.type || '',
813
814
  file: finding.file || file,
814
815
  line_start: finding.line_start || null,
@@ -1002,6 +1003,8 @@ class AIPanel {
1002
1003
  if (targetSuggestion) {
1003
1004
  const minimizer = window.prManager?.commentMinimizer;
1004
1005
  if (minimizer?.active) {
1006
+ // Expand file-level comments so the target becomes visible
1007
+ minimizer.expandForElement(targetSuggestion);
1005
1008
  // Comments are minimized — scroll to the parent diff line instead
1006
1009
  const diffRow = minimizer.findDiffRowFor(targetSuggestion);
1007
1010
  (diffRow || targetSuggestion).scrollIntoView({ behavior: 'smooth', block: 'center' });
@@ -1069,9 +1072,9 @@ class AIPanel {
1069
1072
 
1070
1073
  if (targetElement) {
1071
1074
  const minimizer = window.prManager?.commentMinimizer;
1072
- if (minimizer?.active && !isFileLevel) {
1073
- // Comments are minimized — scroll to the parent diff line instead
1074
- const diffRow = minimizer.findDiffRowFor(targetElement);
1075
+ if (minimizer?.active) {
1076
+ minimizer.expandForElement(targetElement);
1077
+ const diffRow = isFileLevel ? null : minimizer.findDiffRowFor(targetElement);
1075
1078
  (diffRow || targetElement).scrollIntoView({ behavior: 'smooth', block: 'center' });
1076
1079
  } else {
1077
1080
  targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
@@ -32,6 +32,7 @@ class ChatPanel {
32
32
  this._pendingContext = [];
33
33
  this._pendingContextData = [];
34
34
  this._pendingDiffStateNotifications = [];
35
+ this._pendingUserActionHints = [];
35
36
  this._contextSource = null; // 'suggestion' or 'user' — set when opened with context
36
37
  this._contextItemId = null; // suggestion ID or comment ID from context
37
38
  this._contextLineMeta = null; // { file, line_start, line_end } — set when opened with line context
@@ -667,6 +668,7 @@ class ChatPanel {
667
668
  this._pendingContext = [];
668
669
  this._pendingContextData = [];
669
670
  this._pendingDiffStateNotifications = [];
671
+ this._pendingUserActionHints = [];
670
672
  this._contextSource = null;
671
673
  this._contextItemId = null;
672
674
  this._contextLineMeta = null;
@@ -1030,6 +1032,7 @@ class ChatPanel {
1030
1032
  this._pendingContext = [];
1031
1033
  this._pendingContextData = [];
1032
1034
  this._pendingDiffStateNotifications = [];
1035
+ this._pendingUserActionHints = [];
1033
1036
  this._contextSource = null;
1034
1037
  this._contextItemId = null;
1035
1038
  this._contextLineMeta = null;
@@ -1269,12 +1272,28 @@ class ChatPanel {
1269
1272
  this._pendingDiffStateNotifications = [];
1270
1273
  }
1271
1274
 
1275
+ // Snapshot user-action-hints queue for error recovery (invisible to user, no UI cards)
1276
+ const savedUserActionHints = this._pendingUserActionHints.slice();
1277
+ let userActionPrefix = '';
1278
+ if (this._pendingUserActionHints.length > 0) {
1279
+ userActionPrefix = '[User Action Hints]\n' + this._pendingUserActionHints.join('\n');
1280
+ this._pendingUserActionHints = [];
1281
+ }
1282
+
1283
+ // Combine invisible prefixes (diff state + user action hints)
1284
+ let invisiblePrefix = '';
1285
+ if (diffStatePrefix && userActionPrefix) {
1286
+ invisiblePrefix = diffStatePrefix + '\n\n' + userActionPrefix;
1287
+ } else {
1288
+ invisiblePrefix = diffStatePrefix || userActionPrefix;
1289
+ }
1290
+
1272
1291
  const savedContext = this._pendingContext;
1273
1292
  const savedContextData = this._pendingContextData;
1274
1293
  if (this._pendingContext.length > 0) {
1275
1294
  const userContext = this._pendingContext.join('\n\n');
1276
- payload.context = diffStatePrefix
1277
- ? diffStatePrefix + '\n\n' + userContext
1295
+ payload.context = invisiblePrefix
1296
+ ? invisiblePrefix + '\n\n' + userContext
1278
1297
  : userContext;
1279
1298
  payload.contextData = this._pendingContextData;
1280
1299
  this._pendingContext = [];
@@ -1287,8 +1306,8 @@ class ChatPanel {
1287
1306
  if (btn) btn.remove();
1288
1307
  delete card.dataset.contextIndex;
1289
1308
  });
1290
- } else if (diffStatePrefix) {
1291
- payload.context = diffStatePrefix;
1309
+ } else if (invisiblePrefix) {
1310
+ payload.context = invisiblePrefix;
1292
1311
  }
1293
1312
 
1294
1313
  // Lock analysis context card (not indexed, handled separately from pending context)
@@ -1353,6 +1372,7 @@ class ChatPanel {
1353
1372
  this._pendingContext = savedContext;
1354
1373
  this._pendingContextData = savedContextData;
1355
1374
  this._pendingDiffStateNotifications = [...savedDiffState, ...this._pendingDiffStateNotifications];
1375
+ this._pendingUserActionHints = [...savedUserActionHints, ...this._pendingUserActionHints];
1356
1376
  // Restore removability on context cards that were locked before the failed send
1357
1377
  this._restoreRemovableCards();
1358
1378
  console.error('[ChatPanel] Error sending message:', error);
@@ -1371,6 +1391,16 @@ class ChatPanel {
1371
1391
  this._pendingDiffStateNotifications.push(message);
1372
1392
  }
1373
1393
 
1394
+ /**
1395
+ * Queue an invisible user-action hint for the chat agent.
1396
+ * Like diff-state notifications, these do NOT render UI cards and survive panel close.
1397
+ * Drained into the context parameter on the next sendMessage() call.
1398
+ * @param {string} message - Description of the user action (e.g., "[User Action: adopted suggestion 42]")
1399
+ */
1400
+ queueUserActionHint(message) {
1401
+ this._pendingUserActionHints.push(message);
1402
+ }
1403
+
1374
1404
  /**
1375
1405
  * Store pending context and render a compact context card in the UI.
1376
1406
  * Called when the user clicks "Ask about this" on a suggestion.
@@ -55,6 +55,7 @@ class CouncilProgressModal {
55
55
  this.councilConfig = councilConfig;
56
56
  this.isVisible = true;
57
57
  this._voiceStates = {};
58
+ this._completionHandled = false;
58
59
 
59
60
  // Detect rendering mode
60
61
  const configType = options.configType || (councilConfig ? 'advanced' : 'single');
@@ -903,6 +904,13 @@ class CouncilProgressModal {
903
904
  // ---------------------------------------------------------------------------
904
905
 
905
906
  _handleCompletion(status) {
907
+ // Guard against duplicate invocations (e.g. WebSocket retry + reconnect fetch race)
908
+ if (this._completionHandled) return;
909
+ this._completionHandled = true;
910
+
911
+ // Play notification sound before any async work so it fires promptly
912
+ if (window.notificationSounds) window.notificationSounds.playIfEnabled('analysis');
913
+
906
914
  if (this._renderMode === 'council') {
907
915
  // Voice-centric: mark all voice-level children as complete
908
916
  const vcEls = this.modal.querySelectorAll('[data-vc-voice][data-vc-level]');
@@ -1312,6 +1320,9 @@ class CouncilProgressModal {
1312
1320
  <span class="council-voice-icon pending">\u25CB</span>
1313
1321
  <span class="council-voice-label">Consolidation</span>
1314
1322
  <span class="council-voice-status pending">Pending</span>
1323
+ <div class="council-voice-detail">
1324
+ <div class="council-voice-snippet" style="display: none;"></div>
1325
+ </div>
1315
1326
  </div>
1316
1327
  `;
1317
1328
  }
@@ -0,0 +1,257 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * NotificationDropdown - Bell-icon popover for notification sound preferences.
4
+ *
5
+ * Anchors a small dropdown below the bell button with checkbox toggles that
6
+ * control which events trigger a notification chime. Supports configurable
7
+ * event types (e.g. 'analysis', 'setup') and a "Test sound" link.
8
+ *
9
+ * Follows the same popover pattern as DiffOptionsDropdown (fixed positioning
10
+ * via getBoundingClientRect, click-outside and Escape to dismiss,
11
+ * opacity+transform animation).
12
+ *
13
+ * Usage:
14
+ * const dropdown = new NotificationDropdown(
15
+ * document.getElementById('notification-btn'),
16
+ * { events: ['analysis', 'setup'] }
17
+ * );
18
+ */
19
+
20
+ const EVENT_LABELS = {
21
+ 'analysis': 'Analysis complete',
22
+ 'setup': 'Setup complete'
23
+ };
24
+
25
+ class NotificationDropdown {
26
+ /**
27
+ * @param {HTMLElement} buttonElement - The bell icon button already in the DOM
28
+ * @param {Object} options
29
+ * @param {string[]} options.events - Event type strings to show toggles for
30
+ */
31
+ constructor(buttonElement, { events }) {
32
+ this._btn = buttonElement;
33
+ this._events = events || [];
34
+
35
+ this._popoverEl = null;
36
+ this._checkboxes = {};
37
+ this._visible = false;
38
+ this._outsideClickHandler = null;
39
+ this._escapeHandler = null;
40
+
41
+ this._renderPopover();
42
+ this._syncButtonActive();
43
+
44
+ // Toggle popover on button click
45
+ this._btnClickHandler = (e) => {
46
+ e.stopPropagation();
47
+ if (this._visible) {
48
+ this._hide();
49
+ } else {
50
+ this._show();
51
+ }
52
+ };
53
+ this._btn.addEventListener('click', this._btnClickHandler);
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Public API
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /** Remove all DOM elements and event listeners. Safe to call multiple times. */
61
+ destroy() {
62
+ this._hide();
63
+ if (this._popoverEl) {
64
+ this._popoverEl.remove();
65
+ this._popoverEl = null;
66
+ }
67
+ if (this._btn && this._btnClickHandler) {
68
+ this._btn.removeEventListener('click', this._btnClickHandler);
69
+ this._btnClickHandler = null;
70
+ }
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // DOM construction
75
+ // ---------------------------------------------------------------------------
76
+
77
+ _renderPopover() {
78
+ const popover = document.createElement('div');
79
+ popover.className = 'notification-popover';
80
+ // Start hidden (opacity 0, shifted up)
81
+ popover.style.opacity = '0';
82
+ popover.style.transform = 'translateY(-4px)';
83
+ popover.style.pointerEvents = 'none';
84
+ popover.style.position = 'fixed';
85
+ popover.style.zIndex = '1100';
86
+ popover.style.transition = 'opacity 0.15s ease, transform 0.15s ease';
87
+
88
+ // --- Event checkboxes ---
89
+ this._events.forEach((eventType) => {
90
+ const labelText = EVENT_LABELS[eventType] || eventType;
91
+ const enabled = window.notificationSounds
92
+ ? window.notificationSounds.isEnabled(eventType)
93
+ : false;
94
+
95
+ const label = this._createCheckboxLabel(labelText, enabled);
96
+ const checkbox = label.querySelector('input');
97
+ popover.appendChild(label);
98
+
99
+ this._checkboxes[eventType] = checkbox;
100
+
101
+ checkbox.addEventListener('change', () => {
102
+ if (window.notificationSounds) {
103
+ window.notificationSounds.setEnabled(eventType, checkbox.checked);
104
+ }
105
+ this._syncButtonActive();
106
+ });
107
+ });
108
+
109
+ // --- Divider ---
110
+ const divider = document.createElement('div');
111
+ divider.style.height = '1px';
112
+ divider.style.background = 'var(--color-border-primary, #d0d7de)';
113
+ divider.style.margin = '0';
114
+ popover.appendChild(divider);
115
+
116
+ // --- Test sound link ---
117
+ const testLink = document.createElement('div');
118
+ testLink.textContent = 'Test sound';
119
+ testLink.style.padding = '8px 12px';
120
+ testLink.style.fontSize = '0.8125rem';
121
+ testLink.style.color = 'var(--color-accent-primary)';
122
+ testLink.style.cursor = 'pointer';
123
+ testLink.style.textDecoration = 'none';
124
+ testLink.style.userSelect = 'none';
125
+
126
+ testLink.addEventListener('mouseenter', () => {
127
+ testLink.style.textDecoration = 'underline';
128
+ });
129
+ testLink.addEventListener('mouseleave', () => {
130
+ testLink.style.textDecoration = 'none';
131
+ });
132
+ testLink.addEventListener('click', (e) => {
133
+ e.stopPropagation();
134
+ if (window.notificationSounds) {
135
+ window.notificationSounds.playChime();
136
+ }
137
+ });
138
+
139
+ popover.appendChild(testLink);
140
+
141
+ document.body.appendChild(popover);
142
+ this._popoverEl = popover;
143
+ }
144
+
145
+ /**
146
+ * Create a label element wrapping a checkbox.
147
+ * @param {string} text - Label text
148
+ * @param {boolean} checked - Initial checked state
149
+ * @returns {HTMLLabelElement}
150
+ */
151
+ _createCheckboxLabel(text, checked) {
152
+ const label = document.createElement('label');
153
+ label.style.display = 'flex';
154
+ label.style.alignItems = 'center';
155
+ label.style.gap = '8px';
156
+ label.style.cursor = 'pointer';
157
+ label.style.fontSize = '0.8125rem';
158
+ label.style.whiteSpace = 'nowrap';
159
+ label.style.padding = '8px 12px';
160
+ label.style.color = 'var(--color-text-primary, #24292f)';
161
+ label.style.userSelect = 'none';
162
+
163
+ const checkbox = document.createElement('input');
164
+ checkbox.type = 'checkbox';
165
+ checkbox.checked = checked;
166
+ checkbox.style.margin = '0';
167
+ checkbox.style.cursor = 'pointer';
168
+
169
+ label.appendChild(checkbox);
170
+ label.appendChild(document.createTextNode(text));
171
+ return label;
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Show / Hide (mirrors DiffOptionsDropdown pattern)
176
+ // ---------------------------------------------------------------------------
177
+
178
+ _show() {
179
+ if (!this._popoverEl || !this._btn) return;
180
+
181
+ // Position below the button
182
+ const rect = this._btn.getBoundingClientRect();
183
+ this._popoverEl.style.top = `${rect.bottom + 4}px`;
184
+ this._popoverEl.style.left = `${rect.left + rect.width / 2}px`;
185
+ this._popoverEl.style.transform = 'translateX(-50%) translateY(-4px)';
186
+
187
+ // Make visible
188
+ this._popoverEl.style.opacity = '1';
189
+ this._popoverEl.style.pointerEvents = 'auto';
190
+ this._visible = true;
191
+
192
+ // Animate into final position
193
+ requestAnimationFrame(() => {
194
+ if (this._popoverEl) {
195
+ this._popoverEl.style.transform = 'translateX(-50%) translateY(0)';
196
+ }
197
+ });
198
+
199
+ // Click-outside-to-close
200
+ this._outsideClickHandler = (e) => {
201
+ if (!this._popoverEl.contains(e.target) && !this._btn.contains(e.target)) {
202
+ this._hide();
203
+ }
204
+ };
205
+ document.addEventListener('click', this._outsideClickHandler, true);
206
+
207
+ // Escape to dismiss
208
+ this._escapeHandler = (e) => {
209
+ if (e.key === 'Escape') {
210
+ this._hide();
211
+ }
212
+ };
213
+ document.addEventListener('keydown', this._escapeHandler, true);
214
+ }
215
+
216
+ _hide() {
217
+ if (!this._popoverEl) return;
218
+
219
+ this._popoverEl.style.opacity = '0';
220
+ this._popoverEl.style.transform = 'translateX(-50%) translateY(-4px)';
221
+ this._popoverEl.style.pointerEvents = 'none';
222
+ this._visible = false;
223
+
224
+ if (this._outsideClickHandler) {
225
+ document.removeEventListener('click', this._outsideClickHandler, true);
226
+ this._outsideClickHandler = null;
227
+ }
228
+ if (this._escapeHandler) {
229
+ document.removeEventListener('keydown', this._escapeHandler, true);
230
+ this._escapeHandler = null;
231
+ }
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Helpers
236
+ // ---------------------------------------------------------------------------
237
+
238
+ /** Swap bell icon visibility when any notification is enabled. */
239
+ _syncButtonActive() {
240
+ if (!this._btn) return;
241
+ const anyEnabled = this._events.some((eventType) => {
242
+ return window.notificationSounds
243
+ ? window.notificationSounds.isEnabled(eventType)
244
+ : false;
245
+ });
246
+ const onIcon = this._btn.querySelector('.bell-icon-on');
247
+ const offIcon = this._btn.querySelector('.bell-icon-off');
248
+ if (onIcon) onIcon.style.display = anyEnabled ? '' : 'none';
249
+ if (offIcon) offIcon.style.display = anyEnabled ? 'none' : '';
250
+ }
251
+ }
252
+
253
+ window.NotificationDropdown = NotificationDropdown;
254
+
255
+ if (typeof module !== 'undefined' && module.exports) {
256
+ module.exports = { NotificationDropdown };
257
+ }