@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.
@@ -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]');
@@ -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
+ }
@@ -0,0 +1,313 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Stack Analysis Dialog Component
4
+ * Modal showing stack PRs with checkboxes for selection before analysis.
5
+ * Returns a Promise resolving to { selectedPRNumbers } or null if cancelled.
6
+ */
7
+ class StackAnalysisDialog {
8
+ constructor(container) {
9
+ this.container = container || document.body;
10
+ this.overlay = null;
11
+ this._resolve = null;
12
+ this._stackData = null;
13
+ this._currentPRNumber = null;
14
+
15
+ // Bind methods
16
+ this.handleKeydown = this.handleKeydown.bind(this);
17
+ }
18
+
19
+ /**
20
+ * Open the dialog, fetch stack info, and let the user select PRs.
21
+ * @param {string} owner - Repository owner
22
+ * @param {string} repo - Repository name
23
+ * @param {number} number - Current PR number
24
+ * @returns {Promise<{selectedPRNumbers: number[]}|null>} Selected PR numbers or null if cancelled
25
+ */
26
+ open(owner, repo, number) {
27
+ this._currentPRNumber = number;
28
+
29
+ return new Promise((resolve) => {
30
+ this._resolve = resolve;
31
+ this._createOverlay();
32
+ this._showLoading();
33
+ this._attachListeners();
34
+ this._fetchStackInfo(owner, repo, number);
35
+ });
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // DOM creation
40
+ // ---------------------------------------------------------------------------
41
+
42
+ _createOverlay() {
43
+ // Remove any existing overlay
44
+ this._removeOverlay();
45
+
46
+ const overlay = document.createElement('div');
47
+ overlay.className = 'stack-dialog-overlay';
48
+
49
+ overlay.innerHTML = `
50
+ <div class="stack-dialog-backdrop" data-action="cancel"></div>
51
+ <div class="stack-dialog">
52
+ <div class="stack-dialog-header">
53
+ <h3>Analyze Stack</h3>
54
+ <button class="modal-close-btn" data-action="cancel" title="Close">
55
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
56
+ <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"/>
57
+ </svg>
58
+ </button>
59
+ </div>
60
+ <div class="stack-dialog-body">
61
+ <!-- Populated dynamically -->
62
+ </div>
63
+ <div class="stack-dialog-footer">
64
+ <button class="btn btn-secondary" data-action="cancel">Cancel</button>
65
+ <button class="btn btn-primary stack-dialog-submit" data-action="submit" disabled>Configure &amp; Analyze</button>
66
+ </div>
67
+ </div>
68
+ `;
69
+
70
+ this.container.appendChild(overlay);
71
+ this.overlay = overlay;
72
+
73
+ // Event delegation for clicks
74
+ overlay.addEventListener('click', (e) => {
75
+ const action = e.target.closest('[data-action]')?.dataset.action;
76
+ if (action === 'cancel') {
77
+ this._cancel();
78
+ return;
79
+ }
80
+ if (action === 'submit') {
81
+ this._submit();
82
+ return;
83
+ }
84
+ if (action === 'select-all') {
85
+ this._setAllChecked(true);
86
+ return;
87
+ }
88
+ if (action === 'select-none') {
89
+ this._setAllChecked(false);
90
+ return;
91
+ }
92
+
93
+ // Checkbox change via clicking the row
94
+ const checkbox = e.target.closest('.stack-dialog-pr-checkbox');
95
+ if (checkbox) {
96
+ // Let the native checkbox toggle, then update button state
97
+ setTimeout(() => this._updateSubmitButton(), 0);
98
+ }
99
+ });
100
+
101
+ // Handle checkbox changes (covers keyboard toggle too)
102
+ overlay.addEventListener('change', () => {
103
+ this._updateSubmitButton();
104
+ });
105
+ }
106
+
107
+ _showLoading() {
108
+ const body = this.overlay?.querySelector('.stack-dialog-body');
109
+ if (!body) return;
110
+
111
+ body.innerHTML = `
112
+ <div class="stack-dialog-loading">
113
+ <span class="council-spinner"></span>
114
+ <span>Loading stack info...</span>
115
+ </div>
116
+ `;
117
+ }
118
+
119
+ _showError(message) {
120
+ const body = this.overlay?.querySelector('.stack-dialog-body');
121
+ if (!body) return;
122
+
123
+ body.innerHTML = `
124
+ <div class="stack-dialog-error">
125
+ <p>${this._escapeHtml(message)}</p>
126
+ </div>
127
+ `;
128
+ }
129
+
130
+ _renderPRList(stack) {
131
+ const body = this.overlay?.querySelector('.stack-dialog-body');
132
+ if (!body) return;
133
+
134
+ // Filter to non-trunk entries with PR numbers
135
+ const prs = stack.filter(entry => !entry.isTrunk && entry.prNumber);
136
+ if (prs.length === 0) {
137
+ this._showError('No PRs found in this stack.');
138
+ return;
139
+ }
140
+
141
+ this._stackData = prs;
142
+
143
+ // Display PRs in reverse order: tip of stack (top) first, base (bottom) last
144
+ const displayPRs = [...prs].reverse();
145
+
146
+ let html = `
147
+ <div class="stack-dialog-controls">
148
+ <button class="btn btn-sm btn-secondary" data-action="select-all">Select All</button>
149
+ <button class="btn btn-sm btn-secondary" data-action="select-none">Select None</button>
150
+ </div>
151
+ <div class="stack-dialog-pr-list">
152
+ `;
153
+
154
+ for (const pr of displayPRs) {
155
+ const isCurrent = pr.prNumber === this._currentPRNumber;
156
+ const currentClass = isCurrent ? ' stack-dialog-pr-current' : '';
157
+ const currentBadge = isCurrent ? ' <span class="stack-dialog-current-badge">\u2605</span>' : '';
158
+ const analysisBadge = pr.hasAnalysis
159
+ ? ' <span class="stack-dialog-analyzed-badge" title="Has existing analysis">\u2022 analyzed</span>'
160
+ : '';
161
+
162
+ html += `
163
+ <label class="stack-dialog-pr-item${currentClass}">
164
+ <input type="checkbox" class="stack-dialog-pr-checkbox" data-pr-number="${pr.prNumber}" checked />
165
+ <span class="stack-dialog-pr-number">#${pr.prNumber}</span>
166
+ <div class="stack-dialog-pr-info">
167
+ <span class="stack-dialog-pr-title-row">
168
+ ${currentBadge}
169
+ <span class="stack-dialog-pr-title">${this._escapeHtml(pr.title || pr.branch || '')}</span>
170
+ ${analysisBadge}
171
+ </span>
172
+ <span class="stack-dialog-pr-branch"><svg class="stack-dialog-branch-icon" width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Z"/></svg><span class="stack-dialog-branch-name">${this._escapeHtml(pr.branch || '')}</span></span>
173
+ </div>
174
+ </label>
175
+ `;
176
+ }
177
+
178
+ html += `
179
+ </div>
180
+ <div class="stack-dialog-note">
181
+ <span class="stack-dialog-note-info" title="Each PR gets its own worktree (created automatically if needed). All selected PRs are analyzed in parallel.">
182
+ <svg class="stack-dialog-info-icon" width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
183
+ <path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"/>
184
+ </svg>
185
+ Each PR gets its own worktree (created automatically if needed). All selected PRs are analyzed in parallel.
186
+ </span>
187
+ </div>
188
+ `;
189
+
190
+ body.innerHTML = html;
191
+
192
+ // Enable submit button
193
+ this._updateSubmitButton();
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Fetch
198
+ // ---------------------------------------------------------------------------
199
+
200
+ async _fetchStackInfo(owner, repo, number) {
201
+ try {
202
+ const response = await fetch(`/api/pr/${owner}/${repo}/${number}/stack-info`);
203
+ if (!response.ok) {
204
+ const data = await response.json().catch(() => ({}));
205
+ throw new Error(data.error || `Failed to fetch stack info (${response.status})`);
206
+ }
207
+
208
+ const data = await response.json();
209
+ this._renderPRList(data.stack || []);
210
+ } catch (error) {
211
+ console.error('Error fetching stack info:', error);
212
+ this._showError(error.message || 'Failed to load stack information.');
213
+ }
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // Actions
218
+ // ---------------------------------------------------------------------------
219
+
220
+ _getSelectedPRNumbers() {
221
+ if (!this.overlay) return [];
222
+ const checkboxes = this.overlay.querySelectorAll('.stack-dialog-pr-checkbox:checked');
223
+ return Array.from(checkboxes).map(cb => Number(cb.dataset.prNumber));
224
+ }
225
+
226
+ _setAllChecked(checked) {
227
+ if (!this.overlay) return;
228
+ const checkboxes = this.overlay.querySelectorAll('.stack-dialog-pr-checkbox');
229
+ checkboxes.forEach(cb => { cb.checked = checked; });
230
+ this._updateSubmitButton();
231
+ }
232
+
233
+ _updateSubmitButton() {
234
+ const submitBtn = this.overlay?.querySelector('.stack-dialog-submit');
235
+ if (!submitBtn) return;
236
+ const selected = this._getSelectedPRNumbers();
237
+ submitBtn.disabled = selected.length === 0;
238
+ }
239
+
240
+ _submit() {
241
+ const selectedPRNumbers = this._getSelectedPRNumbers();
242
+ if (selectedPRNumbers.length === 0) return;
243
+
244
+ // Build prList with titles for the progress modal
245
+ const selectedSet = new Set(selectedPRNumbers);
246
+ const prList = (this._stackData || [])
247
+ .filter(pr => selectedSet.has(pr.prNumber))
248
+ .map(pr => ({ prNumber: pr.prNumber, title: pr.title || pr.branch || '' }));
249
+
250
+ this._cleanup();
251
+ if (this._resolve) {
252
+ this._resolve({ selectedPRNumbers, prList });
253
+ this._resolve = null;
254
+ }
255
+ }
256
+
257
+ _cancel() {
258
+ this._cleanup();
259
+ if (this._resolve) {
260
+ this._resolve(null);
261
+ this._resolve = null;
262
+ }
263
+ }
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // Listeners & cleanup
267
+ // ---------------------------------------------------------------------------
268
+
269
+ _attachListeners() {
270
+ document.addEventListener('keydown', this.handleKeydown);
271
+ }
272
+
273
+ handleKeydown(e) {
274
+ if (e.key === 'Escape') {
275
+ this._cancel();
276
+ }
277
+ }
278
+
279
+ _cleanup() {
280
+ document.removeEventListener('keydown', this.handleKeydown);
281
+ this._removeOverlay();
282
+ this._stackData = null;
283
+ this._currentPRNumber = null;
284
+ }
285
+
286
+ _removeOverlay() {
287
+ if (this.overlay) {
288
+ this.overlay.remove();
289
+ this.overlay = null;
290
+ }
291
+ }
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // Utilities
295
+ // ---------------------------------------------------------------------------
296
+
297
+ _escapeHtml(str) {
298
+ if (!str) return '';
299
+ const div = document.createElement('div');
300
+ div.textContent = str;
301
+ return div.innerHTML;
302
+ }
303
+ }
304
+
305
+ // Export for use in other modules
306
+ if (typeof window !== 'undefined') {
307
+ window.StackAnalysisDialog = StackAnalysisDialog;
308
+ }
309
+
310
+ // Export for Node.js/test environments
311
+ if (typeof module !== 'undefined' && module.exports) {
312
+ module.exports = { StackAnalysisDialog };
313
+ }