@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
@@ -0,0 +1,475 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Stack Progress Modal Component
4
+ * Displays per-PR progress during stack analysis.
5
+ * Subscribes to WebSocket for live updates and shows level detail for
6
+ * the currently-running PR.
7
+ */
8
+ class StackProgressModal {
9
+ constructor() {
10
+ this.modal = null;
11
+ this.isVisible = false;
12
+ this.isRunningInBackground = false;
13
+ this.stackAnalysisId = null;
14
+ this.prList = [];
15
+ this.owner = null;
16
+ this.repo = null;
17
+ this._wsStackUnsub = null;
18
+ this._wsAnalysisUnsubs = new Map();
19
+ this._onReconnect = null;
20
+ this._prStatuses = [];
21
+ this._onComplete = null;
22
+
23
+ this._createModal();
24
+ this._setupEventListeners();
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Lifecycle
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Show the modal and begin tracking stack analysis progress.
33
+ * @param {string} stackAnalysisId - The stack analysis tracking ID
34
+ * @param {Array<{prNumber: number, title?: string}>} prList - Ordered list of PRs being analyzed
35
+ * @param {Object} context - Additional context
36
+ * @param {string} context.owner - Repository owner
37
+ * @param {string} context.repo - Repository name
38
+ */
39
+ open(stackAnalysisId, prList, context = {}) {
40
+ this.stackAnalysisId = stackAnalysisId;
41
+ this.prList = prList;
42
+ this.owner = context.owner || null;
43
+ this.repo = context.repo || null;
44
+ this._onComplete = context.onComplete || null;
45
+ this._prStatuses = prList.map(pr => ({
46
+ prNumber: pr.prNumber,
47
+ title: pr.title || `PR #${pr.prNumber}`,
48
+ status: 'pending',
49
+ analysisId: null,
50
+ suggestionsCount: null,
51
+ error: null
52
+ }));
53
+
54
+ this.isVisible = true;
55
+ this.isRunningInBackground = false;
56
+
57
+ this._rebuildBody();
58
+ this._updateFooter('running');
59
+ this.modal.style.display = 'flex';
60
+
61
+ this._startMonitoring();
62
+ }
63
+
64
+ /**
65
+ * Hide the modal. Keeps subscriptions alive if analysis is still running
66
+ * so it can be reopened later.
67
+ */
68
+ hide() {
69
+ this.isVisible = false;
70
+ this.isRunningInBackground = !!this._wsStackUnsub;
71
+ this.modal.style.display = 'none';
72
+ }
73
+
74
+ /**
75
+ * Run the analysis in the background (same as hide — kept for API clarity).
76
+ */
77
+ runInBackground() {
78
+ this.hide();
79
+ }
80
+
81
+ /**
82
+ * Reopen the progress modal after it was hidden/backgrounded.
83
+ */
84
+ reopenFromBackground() {
85
+ if (this.stackAnalysisId) {
86
+ this.isRunningInBackground = false;
87
+ this.isVisible = true;
88
+ this.modal.style.display = 'flex';
89
+ // Re-fetch status in case we missed updates
90
+ this._fetchStatus();
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Whether a stack analysis is actively running (for external callers).
96
+ */
97
+ get isActive() {
98
+ return !!this.stackAnalysisId && (this.isVisible || this.isRunningInBackground);
99
+ }
100
+
101
+ /**
102
+ * Cancel the stack analysis.
103
+ */
104
+ async cancel() {
105
+ if (!this.stackAnalysisId) {
106
+ this.hide();
107
+ return;
108
+ }
109
+
110
+ try {
111
+ await fetch(`/api/analyses/stack/${this.stackAnalysisId}/cancel`, { method: 'POST' });
112
+ } catch (error) {
113
+ console.warn('Stack cancel request failed:', error.message);
114
+ }
115
+
116
+ this._stopMonitoring();
117
+ this.stackAnalysisId = null;
118
+ this.isRunningInBackground = false;
119
+ if (this._onComplete) {
120
+ this._onComplete('cancelled');
121
+ }
122
+ this.isVisible = false;
123
+ this.modal.style.display = 'none';
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // DOM creation
128
+ // ---------------------------------------------------------------------------
129
+
130
+ _createModal() {
131
+ const existing = document.getElementById('stack-progress-modal');
132
+ if (existing) existing.remove();
133
+
134
+ const overlay = document.createElement('div');
135
+ overlay.id = 'stack-progress-modal';
136
+ overlay.className = 'stack-progress-overlay';
137
+ overlay.style.display = 'none';
138
+
139
+ overlay.innerHTML = `
140
+ <div class="stack-progress-backdrop" data-action="close"></div>
141
+ <div class="stack-progress-modal">
142
+ <div class="stack-progress-header">
143
+ <h3>Stack Analysis Progress</h3>
144
+ <button class="modal-close-btn" data-action="close" title="Close">
145
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
146
+ <path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"/>
147
+ </svg>
148
+ </button>
149
+ </div>
150
+ <div class="stack-progress-body">
151
+ <!-- Rebuilt dynamically -->
152
+ </div>
153
+ <div class="stack-progress-footer">
154
+ <button class="btn btn-danger stack-progress-cancel-btn" data-action="cancel">Cancel</button>
155
+ <button class="btn btn-secondary stack-progress-bg-btn" data-action="background">Run in Background</button>
156
+ </div>
157
+ </div>
158
+ `;
159
+
160
+ document.body.appendChild(overlay);
161
+ this.modal = overlay;
162
+ }
163
+
164
+ _setupEventListeners() {
165
+ document.addEventListener('keydown', (e) => {
166
+ if (e.key === 'Escape' && this.isVisible) {
167
+ this.hide();
168
+ }
169
+ });
170
+
171
+ document.addEventListener('click', (e) => {
172
+ if (!this.modal) return;
173
+
174
+ const actionEl = e.target.closest('#stack-progress-modal [data-action]');
175
+ if (!actionEl) return;
176
+
177
+ const action = actionEl.dataset.action;
178
+ if (action === 'close') {
179
+ this.hide();
180
+ } else if (action === 'cancel') {
181
+ this.cancel();
182
+ } else if (action === 'background') {
183
+ this.runInBackground();
184
+ }
185
+ });
186
+
187
+ // Handle clicks on completed PR links
188
+ document.addEventListener('click', (e) => {
189
+ const link = e.target.closest('#stack-progress-modal .stack-progress-pr-link');
190
+ if (link) {
191
+ const href = link.dataset.href;
192
+ if (href) {
193
+ window.location.href = href;
194
+ }
195
+ }
196
+ });
197
+ }
198
+
199
+ _rebuildBody() {
200
+ const body = this.modal?.querySelector('.stack-progress-body');
201
+ if (!body) return;
202
+
203
+ let html = '<div class="stack-progress-pr-list">';
204
+
205
+ for (const pr of this._prStatuses) {
206
+ html += this._renderPRRow(pr);
207
+ }
208
+
209
+ html += '</div>';
210
+ body.innerHTML = html;
211
+ }
212
+
213
+ _renderPRRow(pr) {
214
+ const statusIcon = this._getStatusIcon(pr.status);
215
+ const statusClass = `status-${pr.status}`;
216
+ const detailText = this._getDetailText(pr);
217
+
218
+ return `
219
+ <div class="stack-progress-pr-row ${statusClass}" data-pr-number="${pr.prNumber}">
220
+ <span class="stack-progress-status-icon ${statusClass}">${statusIcon}</span>
221
+ <span class="stack-progress-pr-label">
222
+ PR #${pr.prNumber}: ${this._escapeHtml(pr.title)}
223
+ </span>
224
+ <span class="stack-progress-pr-detail">${detailText}</span>
225
+ </div>
226
+ `;
227
+ }
228
+
229
+ _getStatusIcon(status) {
230
+ switch (status) {
231
+ case 'completed': return '\u2713';
232
+ case 'running': return '<span class="council-spinner"></span>';
233
+ case 'setting_up': return '<span class="council-spinner"></span>';
234
+ case 'pending': return '\u25CB';
235
+ case 'failed': return '\u2717';
236
+ case 'cancelled': return '\u2298';
237
+ default: return '\u25CB';
238
+ }
239
+ }
240
+
241
+ _getDetailText(pr) {
242
+ switch (pr.status) {
243
+ case 'completed':
244
+ return pr.suggestionsCount != null ? `${pr.suggestionsCount} suggestions` : 'Complete';
245
+ case 'running':
246
+ return 'Analyzing...';
247
+ case 'setting_up':
248
+ return 'Setting up...';
249
+ case 'pending':
250
+ return 'Pending';
251
+ case 'failed':
252
+ return pr.error ? this._escapeHtml(pr.error) : 'Failed';
253
+ case 'cancelled':
254
+ return 'Cancelled';
255
+ default:
256
+ return '';
257
+ }
258
+ }
259
+
260
+ _updatePRRow(prNumber) {
261
+ const pr = this._prStatuses.find(p => p.prNumber === prNumber);
262
+ if (!pr) return;
263
+
264
+ const row = this.modal?.querySelector(`.stack-progress-pr-row[data-pr-number="${prNumber}"]`);
265
+ if (!row) return;
266
+
267
+ const iconEl = row.querySelector('.stack-progress-status-icon');
268
+ const detailEl = row.querySelector('.stack-progress-pr-detail');
269
+
270
+ // Update status class
271
+ row.className = `stack-progress-pr-row status-${pr.status}`;
272
+
273
+ // Update icon
274
+ if (iconEl) {
275
+ iconEl.className = `stack-progress-status-icon status-${pr.status}`;
276
+ iconEl.innerHTML = this._getStatusIcon(pr.status);
277
+ }
278
+
279
+ // Update detail text
280
+ if (detailEl) {
281
+ detailEl.innerHTML = this._getDetailText(pr);
282
+ }
283
+
284
+ // Make completed PRs clickable links
285
+ if (pr.status === 'completed' && this.owner && this.repo) {
286
+ const labelEl = row.querySelector('.stack-progress-pr-label');
287
+ if (labelEl && !labelEl.classList.contains('stack-progress-pr-link')) {
288
+ const href = `/pr/${this.owner}/${this.repo}/${prNumber}`;
289
+ labelEl.classList.add('stack-progress-pr-link');
290
+ labelEl.dataset.href = href;
291
+ labelEl.title = 'Click to view this PR\'s review';
292
+ }
293
+ }
294
+ }
295
+
296
+ _updateFooter(overallStatus) {
297
+ const cancelBtn = this.modal?.querySelector('.stack-progress-cancel-btn');
298
+ const bgBtn = this.modal?.querySelector('.stack-progress-bg-btn');
299
+
300
+ if (!cancelBtn || !bgBtn) return;
301
+
302
+ const isTerminal = ['completed', 'failed', 'cancelled'].includes(overallStatus);
303
+
304
+ if (isTerminal) {
305
+ cancelBtn.style.display = 'none';
306
+ bgBtn.textContent = 'Close';
307
+ bgBtn.dataset.action = 'close';
308
+ bgBtn.className = 'btn btn-secondary stack-progress-bg-btn';
309
+ } else {
310
+ cancelBtn.style.display = '';
311
+ bgBtn.textContent = 'Run in Background';
312
+ bgBtn.dataset.action = 'background';
313
+ }
314
+ }
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // WebSocket monitoring
318
+ // ---------------------------------------------------------------------------
319
+
320
+ _startMonitoring() {
321
+ this._stopMonitoring();
322
+ if (!this.stackAnalysisId) return;
323
+
324
+ window.wsClient.connect();
325
+
326
+ // Subscribe to stack-level progress
327
+ this._wsStackUnsub = window.wsClient.subscribe(
328
+ `stack-analysis:${this.stackAnalysisId}`,
329
+ (msg) => this._handleStackProgress(msg)
330
+ );
331
+
332
+ // Fetch initial status via HTTP (covers startup race)
333
+ this._fetchStatus();
334
+
335
+ // Listen for reconnects
336
+ this._onReconnect = () => { this._fetchStatus(); };
337
+ window.addEventListener('wsReconnected', this._onReconnect);
338
+ }
339
+
340
+ _stopMonitoring() {
341
+ if (this._wsStackUnsub) {
342
+ this._wsStackUnsub();
343
+ this._wsStackUnsub = null;
344
+ }
345
+ if (this._wsAnalysisUnsubs) {
346
+ for (const unsub of this._wsAnalysisUnsubs.values()) {
347
+ unsub();
348
+ }
349
+ this._wsAnalysisUnsubs.clear();
350
+ }
351
+ if (this._onReconnect) {
352
+ window.removeEventListener('wsReconnected', this._onReconnect);
353
+ this._onReconnect = null;
354
+ }
355
+ }
356
+
357
+ _handleStackProgress(msg) {
358
+ if (msg.type !== 'stack-progress') return;
359
+
360
+ // Update internal state from the server message
361
+ if (msg.prStatuses && Array.isArray(msg.prStatuses)) {
362
+ for (const serverPR of msg.prStatuses) {
363
+ const local = this._prStatuses.find(p => p.prNumber === serverPR.prNumber);
364
+ if (local) {
365
+ local.status = serverPR.status || local.status;
366
+ local.analysisId = serverPR.analysisId || local.analysisId;
367
+ if (serverPR.suggestionsCount != null) {
368
+ local.suggestionsCount = serverPR.suggestionsCount;
369
+ }
370
+ if (serverPR.error) {
371
+ local.error = serverPR.error;
372
+ }
373
+ this._updatePRRow(local.prNumber);
374
+ }
375
+ }
376
+ }
377
+
378
+ // Track per-PR analysis subscriptions for all running PRs
379
+ this._subscribeToRunningPRs(msg.prStatuses);
380
+
381
+ // Update footer based on overall status
382
+ this._updateFooter(msg.status || 'running');
383
+
384
+ // Handle terminal states
385
+ if (['completed', 'failed', 'cancelled'].includes(msg.status)) {
386
+ this.isRunningInBackground = false;
387
+ this._stopMonitoring();
388
+ if (this._onComplete) {
389
+ this._onComplete(msg.status);
390
+ }
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Subscribe to analysis WebSocket topics for all currently running PRs,
396
+ * so we can show inline level progress for each.
397
+ */
398
+ _subscribeToRunningPRs(prStatuses) {
399
+ if (!prStatuses || !window.wsClient) return;
400
+
401
+ const runningPRs = prStatuses.filter(p => p.status === 'running' && p.analysisId);
402
+ const runningAnalysisIds = new Set(runningPRs.map(p => p.analysisId));
403
+
404
+ // Unsubscribe from analyses no longer running
405
+ for (const [analysisId, unsub] of this._wsAnalysisUnsubs) {
406
+ if (!runningAnalysisIds.has(analysisId)) {
407
+ unsub();
408
+ this._wsAnalysisUnsubs.delete(analysisId);
409
+ }
410
+ }
411
+
412
+ // Subscribe to new running analyses
413
+ for (const pr of runningPRs) {
414
+ if (!this._wsAnalysisUnsubs.has(pr.analysisId)) {
415
+ const unsub = window.wsClient.subscribe(
416
+ `analysis:${pr.analysisId}`,
417
+ (msg) => this._handleAnalysisProgress(pr.prNumber, msg)
418
+ );
419
+ this._wsAnalysisUnsubs.set(pr.analysisId, unsub);
420
+ }
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Handle individual PR analysis progress (placeholder for future detail).
426
+ * Level-by-level detail (L1/L2/L3) was removed as it cluttered the UI
427
+ * without adding value in the stack context.
428
+ */
429
+ _handleAnalysisProgress(_prNumber, _msg) {
430
+ // No-op: the stack-level progress handler already shows status per PR.
431
+ // Per-analysis subscriptions are kept for potential future use (e.g. %).
432
+ }
433
+
434
+ /**
435
+ * Fetch stack analysis status via HTTP for initial state / reconnect.
436
+ */
437
+ _fetchStatus() {
438
+ if (!this.stackAnalysisId) return;
439
+
440
+ fetch(`/api/analyses/stack/${this.stackAnalysisId}`)
441
+ .then(r => r.ok ? r.json() : null)
442
+ .then(data => {
443
+ if (data) {
444
+ this._handleStackProgress({
445
+ type: 'stack-progress',
446
+ ...data
447
+ });
448
+ }
449
+ })
450
+ .catch(err => {
451
+ console.warn('Failed to fetch stack analysis status:', err);
452
+ });
453
+ }
454
+
455
+ // ---------------------------------------------------------------------------
456
+ // Utilities
457
+ // ---------------------------------------------------------------------------
458
+
459
+ _escapeHtml(str) {
460
+ if (!str) return '';
461
+ const div = document.createElement('div');
462
+ div.textContent = str;
463
+ return div.innerHTML;
464
+ }
465
+ }
466
+
467
+ // Export for use in other modules
468
+ if (typeof window !== 'undefined') {
469
+ window.StackProgressModal = StackProgressModal;
470
+ }
471
+
472
+ // Export for Node.js/test environments
473
+ if (typeof module !== 'undefined' && module.exports) {
474
+ module.exports = { StackProgressModal };
475
+ }
@@ -110,6 +110,7 @@ class StatusIndicator {
110
110
  * @param {string} message - Completion message
111
111
  */
112
112
  showComplete(message = 'Analysis complete') {
113
+ if (window.notificationSounds) window.notificationSounds.playIfEnabled('analysis');
113
114
  this.updateText(message);
114
115
  this.showCheckmark();
115
116
  this.stopDotsAnimation();
@@ -371,6 +371,8 @@ class SuggestionNavigator {
371
371
  if (suggestionEl) {
372
372
  const minimizer = window.prManager?.commentMinimizer;
373
373
  if (minimizer?.active) {
374
+ // Expand file-level comments so the target becomes visible
375
+ minimizer.expandForElement(suggestionEl);
374
376
  // Comments are minimized — scroll to the parent diff line instead
375
377
  const diffRow = minimizer.findDiffRowFor(suggestionEl);
376
378
  if (diffRow) {
@@ -503,8 +503,15 @@ class CommentManager {
503
503
  // Refresh minimize-mode indicators so the new comment is reflected
504
504
  if (window.prManager?.commentMinimizer) {
505
505
  window.prManager.commentMinimizer.refreshIndicators();
506
+ // Auto-expand so the new comment stays visible in minimize mode
507
+ const newRow = document.querySelector(`.user-comment-row[data-comment-id="${commentData.id}"]`);
508
+ if (newRow) {
509
+ window.prManager.commentMinimizer.expandForElement(newRow);
510
+ }
506
511
  }
507
512
 
513
+ window.chatPanel?.queueUserActionHint(`[User Action: created comment ${result.commentId}]`);
514
+
508
515
  } catch (error) {
509
516
  console.error('Error saving comment:', error);
510
517
  alert('Failed to save comment');