@in-the-loop-labs/pair-review 3.1.4 → 3.2.1

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.
@@ -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();