@in-the-loop-labs/pair-review 3.1.4 → 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.
package/public/js/pr.js CHANGED
@@ -178,6 +178,15 @@ class PRManager {
178
178
  set: (v) => { this.lineTracker.potentialDragStart = v; }
179
179
  });
180
180
 
181
+ // Stack analysis components
182
+ this.stackAnalysisDialog = window.StackAnalysisDialog ? new window.StackAnalysisDialog() : null;
183
+ this.stackProgressModal = window.StackProgressModal ? new window.StackProgressModal() : null;
184
+ // Track open state of split button and stack nav dropdowns
185
+ this._analyzeDropdownOpen = false;
186
+ this._stackNavOpen = false;
187
+ this._closeAnalyzeDropdown = null;
188
+ this._closeStackNav = null;
189
+
181
190
  // Initialize event handlers and UI
182
191
  this.setupEventHandlers();
183
192
  this.initTheme();
@@ -198,6 +207,13 @@ class PRManager {
198
207
  });
199
208
  }
200
209
 
210
+ // Initialize notification sounds dropdown (bell icon)
211
+ const notifBtn = document.getElementById('notification-toggle');
212
+ if (notifBtn && window.NotificationDropdown) {
213
+ const notifEvents = window.PAIR_REVIEW_LOCAL_MODE ? ['analysis'] : ['analysis', 'setup'];
214
+ this.notificationDropdown = new window.NotificationDropdown(notifBtn, { events: notifEvents });
215
+ }
216
+
201
217
  // In local mode, LocalManager handles init instead
202
218
  if (!window.PAIR_REVIEW_LOCAL_MODE) {
203
219
  this.init();
@@ -885,11 +901,12 @@ class PRManager {
885
901
  if (breadcrumbRepo) breadcrumbRepo.textContent = pr.repo;
886
902
  if (breadcrumbPr) breadcrumbPr.textContent = `#${pr.number}`;
887
903
 
888
- // Update title
904
+ // Update title — wrap in stack nav dropdown when stack data is available
889
905
  const titleElement = document.getElementById('pr-title-text');
890
906
  if (titleElement) {
891
907
  titleElement.textContent = pr.title;
892
908
  }
909
+ this._renderStackNavDropdown(pr);
893
910
 
894
911
  // Show/hide PR description info button
895
912
  const descToggle = document.getElementById('pr-description-toggle');
@@ -1012,6 +1029,395 @@ class PRManager {
1012
1029
 
1013
1030
  // Update pending draft indicator in toolbar
1014
1031
  this.updatePendingDraftIndicator(pr.pendingDraft);
1032
+
1033
+ // Render analyze split button when stack data is available
1034
+ this._renderAnalyzeSplitButton(pr);
1035
+ }
1036
+
1037
+ /**
1038
+ * Render the analyze split button when stack data is available.
1039
+ * Wraps the existing #analyze-btn with a dropdown toggle for "Analyze Stack".
1040
+ * @param {Object} pr - PR data with optional stack_data
1041
+ */
1042
+ _renderAnalyzeSplitButton(pr) {
1043
+ const analyzeBtn = document.getElementById('analyze-btn');
1044
+ if (!analyzeBtn) return;
1045
+
1046
+ // Remove existing split container if present (re-render safe)
1047
+ const existingContainer = document.getElementById('analyze-split-container');
1048
+ if (existingContainer) {
1049
+ // Move analyze button back out of the container before removing
1050
+ existingContainer.parentNode.insertBefore(analyzeBtn, existingContainer);
1051
+ existingContainer.remove();
1052
+ }
1053
+ // Clean up previous outside-click handler
1054
+ if (this._closeAnalyzeDropdown) {
1055
+ document.removeEventListener('click', this._closeAnalyzeDropdown);
1056
+ this._closeAnalyzeDropdown = null;
1057
+ }
1058
+
1059
+ // Determine stack PRs (non-trunk entries with PR numbers)
1060
+ const stackPRs = this._getStackPRs(pr);
1061
+ if (stackPRs.length < 2) return; // No meaningful stack
1062
+
1063
+ // Create split button container
1064
+ const container = document.createElement('div');
1065
+ container.className = 'analyze-split-container';
1066
+ container.id = 'analyze-split-container';
1067
+
1068
+ // Insert container where analyze button is, then move button inside
1069
+ analyzeBtn.parentNode.insertBefore(container, analyzeBtn);
1070
+ container.appendChild(analyzeBtn);
1071
+
1072
+ // Dropdown toggle (chevron)
1073
+ const toggle = document.createElement('button');
1074
+ toggle.className = 'analyze-dropdown-toggle';
1075
+ toggle.id = 'analyze-stack-toggle';
1076
+ toggle.type = 'button';
1077
+ toggle.setAttribute('aria-label', 'Stack analysis options');
1078
+ toggle.setAttribute('aria-haspopup', 'true');
1079
+ toggle.setAttribute('aria-expanded', 'false');
1080
+ toggle.innerHTML = `
1081
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
1082
+ <path d="M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z"/>
1083
+ </svg>
1084
+ `;
1085
+ container.appendChild(toggle);
1086
+
1087
+ // Dropdown menu
1088
+ const menu = document.createElement('div');
1089
+ menu.className = 'analyze-dropdown-menu';
1090
+ menu.id = 'analyze-dropdown-menu';
1091
+ const itemBtn = document.createElement('button');
1092
+ itemBtn.className = 'analyze-dropdown-item';
1093
+ itemBtn.id = 'analyze-stack-btn';
1094
+ itemBtn.type = 'button';
1095
+ itemBtn.textContent = `Analyze Stack (${stackPRs.length} PRs)`;
1096
+ menu.appendChild(itemBtn);
1097
+ container.appendChild(menu);
1098
+
1099
+ // Event: toggle dropdown
1100
+ toggle.addEventListener('click', (e) => {
1101
+ e.stopPropagation();
1102
+ const isOpen = container.classList.toggle('open');
1103
+ toggle.setAttribute('aria-expanded', String(isOpen));
1104
+ });
1105
+
1106
+ // Event: click stack analysis item
1107
+ itemBtn.addEventListener('click', (e) => {
1108
+ e.stopPropagation();
1109
+ container.classList.remove('open');
1110
+ toggle.setAttribute('aria-expanded', 'false');
1111
+ this.triggerStackAnalysis();
1112
+ });
1113
+
1114
+ // Close dropdown on outside click
1115
+ this._closeAnalyzeDropdown = (e) => {
1116
+ if (!container.contains(e.target)) {
1117
+ container.classList.remove('open');
1118
+ toggle.setAttribute('aria-expanded', 'false');
1119
+ }
1120
+ };
1121
+ document.addEventListener('click', this._closeAnalyzeDropdown);
1122
+ }
1123
+
1124
+ /**
1125
+ * Render the stack navigation dropdown around the PR title.
1126
+ * Replaces static title with a clickable dropdown when stack data has multiple PRs.
1127
+ * @param {Object} pr - PR data with optional stack_data
1128
+ */
1129
+ _renderStackNavDropdown(pr) {
1130
+ const titleWrapper = document.querySelector('.pr-title-wrapper');
1131
+ if (!titleWrapper) return;
1132
+
1133
+ // Remove existing stack nav if present (re-render safe)
1134
+ const existingNav = titleWrapper.querySelector('.stack-nav-dropdown');
1135
+ if (existingNav) {
1136
+ // Restore the title element outside the nav wrapper
1137
+ const titleEl = existingNav.querySelector('#pr-title-text');
1138
+ if (titleEl) {
1139
+ titleWrapper.insertBefore(titleEl, existingNav);
1140
+ }
1141
+ existingNav.remove();
1142
+ }
1143
+ // Clean up previous outside-click handler
1144
+ if (this._closeStackNav) {
1145
+ document.removeEventListener('click', this._closeStackNav);
1146
+ this._closeStackNav = null;
1147
+ }
1148
+
1149
+ const stackPRs = this._getStackPRs(pr);
1150
+ if (stackPRs.length < 2) return; // No meaningful stack
1151
+
1152
+ const titleElement = document.getElementById('pr-title-text');
1153
+ if (!titleElement) return;
1154
+
1155
+ // Create dropdown wrapper
1156
+ const dropdown = document.createElement('div');
1157
+ dropdown.className = 'stack-nav-dropdown';
1158
+
1159
+ // Create trigger button wrapping the title
1160
+ const trigger = document.createElement('button');
1161
+ trigger.className = 'stack-nav-trigger';
1162
+ trigger.type = 'button';
1163
+ trigger.setAttribute('aria-haspopup', 'true');
1164
+ trigger.setAttribute('aria-expanded', 'false');
1165
+
1166
+ // Move title into trigger
1167
+ titleWrapper.insertBefore(dropdown, titleElement);
1168
+ dropdown.appendChild(trigger);
1169
+ trigger.appendChild(titleElement);
1170
+
1171
+ // Add chevron
1172
+ const chevron = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
1173
+ chevron.classList.add('stack-nav-chevron');
1174
+ chevron.setAttribute('viewBox', '0 0 16 16');
1175
+ chevron.setAttribute('width', '14');
1176
+ chevron.setAttribute('height', '14');
1177
+ chevron.innerHTML = '<path d="M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z"/>';
1178
+ trigger.appendChild(chevron);
1179
+
1180
+ // Create menu
1181
+ const menu = document.createElement('div');
1182
+ menu.className = 'stack-nav-menu';
1183
+ dropdown.appendChild(menu);
1184
+
1185
+ // Populate menu items (reversed: stack base at bottom)
1186
+ const displayPRs = [...stackPRs].reverse();
1187
+ for (const stackPR of displayPRs) {
1188
+ const isCurrent = stackPR.prNumber === pr.number;
1189
+ const item = document.createElement('div');
1190
+ item.className = 'stack-nav-item';
1191
+ if (isCurrent) {
1192
+ item.classList.add('current');
1193
+ }
1194
+ item.dataset.pr = stackPR.prNumber;
1195
+
1196
+ // Text content column
1197
+ const textCol = document.createElement('div');
1198
+ textCol.className = 'stack-nav-text';
1199
+
1200
+ // Primary row: PR number + title inline
1201
+ const primaryRow = document.createElement('div');
1202
+ primaryRow.className = 'stack-nav-primary';
1203
+
1204
+ const numberSpan = document.createElement('span');
1205
+ numberSpan.className = 'stack-nav-number';
1206
+ numberSpan.textContent = `#${stackPR.prNumber}`;
1207
+ primaryRow.appendChild(numberSpan);
1208
+
1209
+ if (stackPR.title) {
1210
+ const titleSpan = document.createElement('span');
1211
+ titleSpan.className = 'stack-nav-title';
1212
+ titleSpan.textContent = stackPR.title;
1213
+ primaryRow.appendChild(titleSpan);
1214
+ }
1215
+
1216
+ textCol.appendChild(primaryRow);
1217
+
1218
+ // Secondary row: branch name
1219
+ const branchRow = document.createElement('div');
1220
+ branchRow.className = 'stack-nav-branch';
1221
+ // SVG branch icon
1222
+ branchRow.innerHTML = '<svg class="stack-nav-branch-icon" width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" d="M11.75 2.5a.75.75 0 100 1.5.75.75 0 000-1.5zm-2.25.75a2.25 2.25 0 113 2.122V6A2.5 2.5 0 0110 8.5H6a1 1 0 00-1 1v1.128a2.251 2.251 0 11-1.5 0V5.372a2.25 2.25 0 111.5 0v1.836A2.492 2.492 0 016 7h4a1 1 0 001-1v-.628A2.25 2.25 0 019.5 3.25zM4.25 12a.75.75 0 100 1.5.75.75 0 000-1.5zM3.5 3.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0z"></path></svg>';
1223
+ const branchName = document.createElement('span');
1224
+ branchName.textContent = stackPR.branch || '';
1225
+ branchRow.appendChild(branchName);
1226
+ textCol.appendChild(branchRow);
1227
+
1228
+ item.appendChild(textCol);
1229
+
1230
+ // Navigate on click
1231
+ item.addEventListener('click', () => {
1232
+ if (stackPR.prNumber !== pr.number) {
1233
+ window.location.href = `/pr/${encodeURIComponent(pr.owner)}/${encodeURIComponent(pr.repo)}/${stackPR.prNumber}`;
1234
+ }
1235
+ dropdown.classList.remove('open');
1236
+ trigger.setAttribute('aria-expanded', 'false');
1237
+ });
1238
+
1239
+ menu.appendChild(item);
1240
+ }
1241
+
1242
+ // Toggle dropdown
1243
+ trigger.addEventListener('click', (e) => {
1244
+ e.stopPropagation();
1245
+ const isOpen = dropdown.classList.toggle('open');
1246
+ trigger.setAttribute('aria-expanded', String(isOpen));
1247
+ });
1248
+
1249
+ // Close on outside click
1250
+ this._closeStackNav = (e) => {
1251
+ if (!dropdown.contains(e.target)) {
1252
+ dropdown.classList.remove('open');
1253
+ trigger.setAttribute('aria-expanded', 'false');
1254
+ }
1255
+ };
1256
+ document.addEventListener('click', this._closeStackNav);
1257
+ }
1258
+
1259
+ /**
1260
+ * Extract non-trunk stack PRs from PR data.
1261
+ * @param {Object} pr - PR data with optional stack_data
1262
+ * @returns {Array<Object>} Stack PR entries with prNumber, title, branch, hasAnalysis
1263
+ */
1264
+ _getStackPRs(pr) {
1265
+ if (!pr.stack_data || !Array.isArray(pr.stack_data)) return [];
1266
+ return pr.stack_data.filter(entry => !entry.isTrunk && entry.prNumber);
1267
+ }
1268
+
1269
+ /**
1270
+ * Trigger stack analysis flow:
1271
+ * 1. Open StackAnalysisDialog to select PRs
1272
+ * 2. Open AnalysisConfigModal for analysis config
1273
+ * 3. Call startStackAnalysis()
1274
+ */
1275
+ async triggerStackAnalysis() {
1276
+ // If a stack analysis is active (running but hidden), reopen its progress modal
1277
+ if (this.stackProgressModal?.isActive) {
1278
+ this.stackProgressModal.reopenFromBackground();
1279
+ return;
1280
+ }
1281
+
1282
+ if (!this.currentPR) {
1283
+ this.showError('No PR loaded');
1284
+ return;
1285
+ }
1286
+
1287
+ const { owner, repo, number } = this.currentPR;
1288
+
1289
+ try {
1290
+ // Open stack selection dialog
1291
+ if (!this.stackAnalysisDialog) {
1292
+ console.warn('StackAnalysisDialog not initialized');
1293
+ return;
1294
+ }
1295
+
1296
+ const dialogResult = await this.stackAnalysisDialog.open(owner, repo, number);
1297
+ if (!dialogResult) return; // User cancelled
1298
+ const { selectedPRNumbers, prList } = dialogResult;
1299
+ if (!selectedPRNumbers || selectedPRNumbers.length === 0) return;
1300
+
1301
+ // Open analysis config modal
1302
+ if (!this.analysisConfigModal) {
1303
+ console.warn('AnalysisConfigModal not initialized, proceeding with defaults');
1304
+ await this.startStackAnalysis(owner, repo, number, selectedPRNumbers, {}, prList);
1305
+ return;
1306
+ }
1307
+
1308
+ // Fetch settings in parallel
1309
+ const [repoSettings, reviewSettings] = await Promise.all([
1310
+ this.fetchRepoSettings().catch(() => null),
1311
+ this.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null }))
1312
+ ]);
1313
+
1314
+ const currentModel = repoSettings?.default_model || 'opus';
1315
+ const currentProvider = repoSettings?.default_provider || 'claude';
1316
+ const tabStorageKey = PRManager.getRepoStorageKey('pair-review-tab', owner, repo);
1317
+ const rememberedTab = localStorage.getItem(tabStorageKey);
1318
+ const defaultTab = rememberedTab || repoSettings?.default_tab || 'single';
1319
+ const instructionsStorageKey = PRManager.getRepoStorageKey('pair-review-instructions', owner, repo);
1320
+ const lastInstructions = reviewSettings.custom_instructions
1321
+ ?? localStorage.getItem(instructionsStorageKey)
1322
+ ?? '';
1323
+ const lastCouncilId = reviewSettings.last_council_id;
1324
+
1325
+ this.analysisConfigModal.onTabChange = (tabId) => {
1326
+ localStorage.setItem(tabStorageKey, tabId);
1327
+ };
1328
+
1329
+ const config = await this.analysisConfigModal.show({
1330
+ currentModel,
1331
+ currentProvider,
1332
+ defaultTab,
1333
+ repoInstructions: repoSettings?.default_instructions || '',
1334
+ lastInstructions,
1335
+ lastCouncilId,
1336
+ defaultCouncilId: repoSettings?.default_council_id || null
1337
+ });
1338
+
1339
+ if (!config) return; // User cancelled
1340
+
1341
+ // Persist custom instructions
1342
+ const submittedInstructions = config.customInstructions || '';
1343
+ if (submittedInstructions) {
1344
+ localStorage.setItem(instructionsStorageKey, submittedInstructions);
1345
+ } else {
1346
+ localStorage.removeItem(instructionsStorageKey);
1347
+ }
1348
+
1349
+ await this.startStackAnalysis(owner, repo, number, selectedPRNumbers, config, prList);
1350
+
1351
+ } catch (error) {
1352
+ console.error('Error triggering stack analysis:', error);
1353
+ this.showError(`Failed to start stack analysis: ${error.message}`);
1354
+ }
1355
+ }
1356
+
1357
+ /**
1358
+ * Start stack analysis by posting to the backend and opening the progress modal.
1359
+ * @param {string} owner - Repository owner
1360
+ * @param {string} repo - Repository name
1361
+ * @param {number} number - Current PR number
1362
+ * @param {Array<number>} selectedPRNumbers - PRs to analyze
1363
+ * @param {Object} analysisConfig - Analysis configuration from the config modal
1364
+ * @param {Array<Object>} [prList] - PR metadata with titles from the selection dialog
1365
+ */
1366
+ async startStackAnalysis(owner, repo, number, selectedPRNumbers, analysisConfig, prList) {
1367
+ try {
1368
+ const response = await fetch(`/api/pr/${owner}/${repo}/${number}/analyses/stack`, {
1369
+ method: 'POST',
1370
+ headers: { 'Content-Type': 'application/json' },
1371
+ body: JSON.stringify({
1372
+ prNumbers: selectedPRNumbers,
1373
+ analysisConfig
1374
+ })
1375
+ });
1376
+
1377
+ if (!response.ok) {
1378
+ const error = await response.json().catch(() => ({}));
1379
+ throw new Error(error.error || 'Failed to start stack analysis');
1380
+ }
1381
+
1382
+ const result = await response.json();
1383
+
1384
+ // Merge titles from dialog into backend response
1385
+ const prAnalysesWithTitles = (result.prAnalyses || []).map(pr => {
1386
+ const info = (prList || []).find(p => p.prNumber === pr.prNumber);
1387
+ return { ...pr, title: info?.title || pr.title };
1388
+ });
1389
+
1390
+ // Set button to analyzing state so clicking it reopens the modal
1391
+ this.setButtonAnalyzing(result.stackAnalysisId);
1392
+
1393
+ // Update dropdown item to show "Analyzing Stack..."
1394
+ const stackBtn = document.getElementById('analyze-stack-btn');
1395
+ if (stackBtn) {
1396
+ stackBtn.textContent = 'Analyzing Stack...';
1397
+ }
1398
+
1399
+ // Open stack progress modal
1400
+ if (this.stackProgressModal) {
1401
+ this.stackProgressModal.open(result.stackAnalysisId, prAnalysesWithTitles, {
1402
+ owner, repo,
1403
+ onComplete: () => {
1404
+ this.resetButton();
1405
+ // Reset dropdown item text
1406
+ const btn = document.getElementById('analyze-stack-btn');
1407
+ if (btn) {
1408
+ const stackPRs = this._getStackPRs(this.currentPR);
1409
+ btn.textContent = `Analyze Stack (${stackPRs.length} PRs)`;
1410
+ }
1411
+ }
1412
+ });
1413
+ }
1414
+
1415
+ } catch (error) {
1416
+ console.error('Error starting stack analysis:', error);
1417
+ if (window.toast) {
1418
+ window.toast.showError(`Stack analysis failed: ${error.message}`);
1419
+ }
1420
+ }
1015
1421
  }
1016
1422
 
1017
1423
  /**
@@ -3973,6 +4379,10 @@ class PRManager {
3973
4379
  btn.classList.add('btn-analyzing');
3974
4380
  btn.disabled = false; // Keep clickable to reopen modal
3975
4381
 
4382
+ // Also highlight the split dropdown toggle if present
4383
+ const toggle = document.getElementById('analyze-stack-toggle');
4384
+ if (toggle) toggle.classList.add('btn-analyzing');
4385
+
3976
4386
  const btnText = btn.querySelector('.btn-text');
3977
4387
  if (btnText) {
3978
4388
  btnText.textContent = 'Analyzing...';
@@ -3994,6 +4404,10 @@ class PRManager {
3994
4404
  btn.classList.remove('btn-analyzing');
3995
4405
  btn.classList.add('btn-complete');
3996
4406
 
4407
+ // Also clear the split dropdown toggle
4408
+ const toggleComplete = document.getElementById('analyze-stack-toggle');
4409
+ if (toggleComplete) toggleComplete.classList.remove('btn-analyzing');
4410
+
3997
4411
  const btnText = btn.querySelector('.btn-text');
3998
4412
  if (btnText) {
3999
4413
  btnText.textContent = 'Complete';
@@ -4022,6 +4436,10 @@ class PRManager {
4022
4436
  btn.classList.remove('btn-analyzing', 'btn-complete');
4023
4437
  btn.disabled = false;
4024
4438
 
4439
+ // Also clear the split dropdown toggle
4440
+ const toggleReset = document.getElementById('analyze-stack-toggle');
4441
+ if (toggleReset) toggleReset.classList.remove('btn-analyzing');
4442
+
4025
4443
  const btnText = btn.querySelector('.btn-text');
4026
4444
  if (btnText) {
4027
4445
  btnText.textContent = 'Analyze';
@@ -4194,7 +4612,7 @@ class PRManager {
4194
4612
  reopenModal() {
4195
4613
  if (!this.currentAnalysisId) return;
4196
4614
 
4197
- // Reopen the progress modal if it was tracking this analysis
4615
+ // Reopen the per-PR progress modal (council/single analysis)
4198
4616
  if (window.councilProgressModal && window.councilProgressModal.currentAnalysisId === this.currentAnalysisId) {
4199
4617
  window.councilProgressModal.reopenFromBackground();
4200
4618
  }
@@ -0,0 +1,62 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+
3
+ /**
4
+ * Notification sound preferences backed by localStorage.
5
+ * Sound playback is delegated to the server (POST /api/play-sound) so that it
6
+ * works reliably even when the browser was opened programmatically without a
7
+ * user gesture (which would block Web Audio API).
8
+ */
9
+ class NotificationSounds {
10
+ /**
11
+ * Returns the localStorage key for a given event type.
12
+ * @param {string} eventType - 'analysis' or 'setup'
13
+ * @returns {string}
14
+ */
15
+ _storageKey(eventType) {
16
+ return 'pair-review-notify-' + eventType;
17
+ }
18
+
19
+ /**
20
+ * Check whether notifications are enabled for the given event type.
21
+ * Returns false if the key is missing (default off).
22
+ * @param {string} eventType - 'analysis' or 'setup'
23
+ * @returns {boolean}
24
+ */
25
+ isEnabled(eventType) {
26
+ const val = localStorage.getItem(this._storageKey(eventType));
27
+ return val === 'true';
28
+ }
29
+
30
+ /**
31
+ * Set whether notifications are enabled for the given event type.
32
+ * @param {string} eventType - 'analysis' or 'setup'
33
+ * @param {boolean} enabled
34
+ */
35
+ setEnabled(eventType, enabled) {
36
+ localStorage.setItem(this._storageKey(eventType), enabled ? 'true' : 'false');
37
+ }
38
+
39
+ /**
40
+ * Play a chime if notifications are enabled for the given event type.
41
+ * @param {string} eventType - 'analysis' or 'setup'
42
+ */
43
+ playIfEnabled(eventType) {
44
+ if (this.isEnabled(eventType)) {
45
+ this.playChime();
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Ask the server to play a system notification sound.
51
+ * Fire-and-forget — errors are silently ignored.
52
+ */
53
+ playChime() {
54
+ fetch('/api/play-sound', { method: 'POST' }).catch(() => {});
55
+ }
56
+ }
57
+
58
+ window.notificationSounds = new NotificationSounds();
59
+
60
+ if (typeof module !== 'undefined' && module.exports) {
61
+ module.exports = { NotificationSounds };
62
+ }
package/public/local.html CHANGED
@@ -341,6 +341,14 @@
341
341
  <path d="M9.598 1.591a.749.749 0 0 1 .785-.175 7.001 7.001 0 1 1-8.967 8.967.75.75 0 0 1 .961-.96 5.5 5.5 0 0 0 7.046-7.046.75.75 0 0 1 .175-.786Z"/>
342
342
  </svg>
343
343
  </button>
344
+ <button class="btn btn-icon" id="notification-toggle" title="Notification sounds">
345
+ <svg class="bell-icon-on" viewBox="0 0 16 16" fill="currentColor" width="16" height="16">
346
+ <path d="M8 16a2 2 0 0 0 1.985-1.75c.017-.137-.097-.25-.235-.25h-3.5c-.138 0-.252.113-.235.25A2 2 0 0 0 8 16ZM3 5a5 5 0 0 1 10 0v2.947c0 .05.015.098.042.139l1.703 2.555A1.519 1.519 0 0 1 13.482 13H2.518a1.516 1.516 0 0 1-1.263-2.36l1.703-2.554A.255.255 0 0 0 3 7.947Zm5-3.5A3.5 3.5 0 0 0 4.5 5v2.947c0 .346-.102.683-.294.97l-1.703 2.556a.017.017 0 0 0-.003.01l.001.006c0 .002.002.004.004.006l.006.004.007.001h10.964l.007-.001.006-.004.004-.006.001-.007a.017.017 0 0 0-.003-.01l-1.703-2.554a1.745 1.745 0 0 1-.294-.97V5A3.5 3.5 0 0 0 8 1.5Z"/>
347
+ </svg>
348
+ <svg class="bell-icon-off" viewBox="0 0 16 16" fill="currentColor" width="16" height="16" style="display: none;">
349
+ <path d="m4.182 4.31.016.011 10.104 7.316.013.01 1.375.996a.75.75 0 1 1-.88 1.214L13.626 13H2.518a1.516 1.516 0 0 1-1.263-2.36l1.703-2.554A.255.255 0 0 0 3 7.947V5.305L.31 3.357a.75.75 0 1 1 .88-1.214Zm7.373 7.19L4.5 6.391v1.556c0 .346-.102.683-.294.97l-1.703 2.556a.017.017 0 0 0-.003.01c0 .005.002.009.005.012l.006.004.007.001ZM8 1.5c-.997 0-1.895.416-2.534 1.086A.75.75 0 1 1 4.38 1.55 5 5 0 0 1 13 5v2.373a.75.75 0 0 1-1.5 0V5A3.5 3.5 0 0 0 8 1.5ZM8 16a2 2 0 0 1-1.985-1.75c-.017-.137.097-.25.235-.25h3.5c.138 0 .252.113.235.25A2 2 0 0 1 8 16Z"/>
350
+ </svg>
351
+ </button>
344
352
  <a class="btn btn-icon settings-link" id="settings-link" href="#" title="Repository settings" style="display: none;">
345
353
  <svg viewBox="0 0 16 16" fill="currentColor" width="16" height="16">
346
354
  <path d="M8 0a8.2 8.2 0 0 1 .701.031C9.444.095 9.99.645 10.16 1.29l.288 1.107c.018.066.079.158.212.224.231.114.454.243.668.386.123.082.233.09.299.071l1.103-.303c.644-.176 1.392.021 1.82.63.27.385.506.792.704 1.218.315.675.111 1.422-.364 1.891l-.814.806c-.049.048-.098.147-.088.294.016.257.016.515 0 .772-.01.147.038.246.088.294l.814.806c.475.469.679 1.216.364 1.891a7.977 7.977 0 0 1-.704 1.217c-.428.61-1.176.807-1.82.63l-1.102-.302c-.067-.019-.177-.011-.3.071a5.909 5.909 0 0 1-.668.386c-.133.066-.194.158-.211.224l-.29 1.106c-.168.646-.715 1.196-1.458 1.26a8.006 8.006 0 0 1-1.402 0c-.743-.064-1.289-.614-1.458-1.26l-.289-1.106c-.018-.066-.079-.158-.212-.224a5.738 5.738 0 0 1-.668-.386c-.123-.082-.233-.09-.299-.071l-1.103.303c-.644.176-1.392-.021-1.82-.63a8.12 8.12 0 0 1-.704-1.218c-.315-.675-.111-1.422.363-1.891l.815-.806c.05-.048.098-.147.088-.294a6.214 6.214 0 0 1 0-.772c.01-.147-.038-.246-.088-.294l-.815-.806C.635 6.045.431 5.298.746 4.623a7.92 7.92 0 0 1 .704-1.217c.428-.61 1.176-.807 1.82-.63l1.102.302c.067.019.177.011.3-.071.214-.143.437-.272.668-.386.133-.066.194-.158.211-.224l.29-1.106C6.009.645 6.556.095 7.299.03 7.53.01 7.764 0 8 0Zm-.571 1.525c-.036.003-.108.036-.137.146l-.289 1.105c-.147.561-.549.967-.998 1.189-.173.086-.34.183-.5.29-.417.278-.97.423-1.529.27l-1.103-.303c-.109-.03-.175.016-.195.045-.22.312-.412.644-.573.99-.014.031-.021.11.059.19l.815.806c.411.406.562.957.53 1.456a4.709 4.709 0 0 0 0 .582c.032.499-.119 1.05-.53 1.456l-.815.806c-.081.08-.073.159-.059.19.162.346.353.677.573.989.02.03.085.076.195.046l1.102-.303c.56-.153 1.113-.008 1.53.27.161.107.328.204.501.29.447.222.85.629.997 1.189l.289 1.105c.029.109.101.143.137.146a6.6 6.6 0 0 0 1.142 0c.036-.003.108-.036.137-.146l.289-1.105c.147-.561.549-.967.998-1.189.173-.086.34-.183.5-.29.417-.278.97-.423 1.529-.27l1.103.303c.109.029.175-.016.195-.045.22-.313.411-.644.573-.99.014-.031.021-.11-.059-.19l-.815-.806c-.411-.406-.562-.957-.53-1.456a4.709 4.709 0 0 0 0-.582c-.032-.499.119-1.05.53-1.456l.815-.806c.081-.08.073-.159.059-.19a6.464 6.464 0 0 0-.573-.989c-.02-.03-.085-.076-.195-.046l-1.102.303c-.56.153-1.113.008-1.53-.27a4.44 4.44 0 0 0-.501-.29c-.447-.222-.85-.629-.997-1.189l-.289-1.105c-.029-.11-.101-.143-.137-.146a6.6 6.6 0 0 0-1.142 0ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM9.5 8a1.5 1.5 0 1 0-3.001.001A1.5 1.5 0 0 0 9.5 8Z"/>
@@ -590,6 +598,8 @@
590
598
  <script src="/js/components/VoiceCentricConfigTab.js"></script>
591
599
  <script src="/js/components/AdvancedConfigTab.js"></script>
592
600
  <script src="/js/components/CouncilProgressModal.js"></script>
601
+ <script src="/js/utils/notification-sounds.js"></script>
602
+ <script src="/js/components/NotificationDropdown.js"></script>
593
603
  <script src="/js/components/StatusIndicator.js"></script>
594
604
  <script src="/js/components/SuggestionNavigator.js"></script>
595
605
  <script src="/js/components/ReviewModal.js"></script>
package/public/pr.html CHANGED
@@ -134,6 +134,14 @@
134
134
  <path d="M9.598 1.591a.749.749 0 0 1 .785-.175 7.001 7.001 0 1 1-8.967 8.967.75.75 0 0 1 .961-.96 5.5 5.5 0 0 0 7.046-7.046.75.75 0 0 1 .175-.786Z"/>
135
135
  </svg>
136
136
  </button>
137
+ <button class="btn btn-icon" id="notification-toggle" title="Notification sounds">
138
+ <svg class="bell-icon-on" viewBox="0 0 16 16" fill="currentColor" width="16" height="16">
139
+ <path d="M8 16a2 2 0 0 0 1.985-1.75c.017-.137-.097-.25-.235-.25h-3.5c-.138 0-.252.113-.235.25A2 2 0 0 0 8 16ZM3 5a5 5 0 0 1 10 0v2.947c0 .05.015.098.042.139l1.703 2.555A1.519 1.519 0 0 1 13.482 13H2.518a1.516 1.516 0 0 1-1.263-2.36l1.703-2.554A.255.255 0 0 0 3 7.947Zm5-3.5A3.5 3.5 0 0 0 4.5 5v2.947c0 .346-.102.683-.294.97l-1.703 2.556a.017.017 0 0 0-.003.01l.001.006c0 .002.002.004.004.006l.006.004.007.001h10.964l.007-.001.006-.004.004-.006.001-.007a.017.017 0 0 0-.003-.01l-1.703-2.554a1.745 1.745 0 0 1-.294-.97V5A3.5 3.5 0 0 0 8 1.5Z"/>
140
+ </svg>
141
+ <svg class="bell-icon-off" viewBox="0 0 16 16" fill="currentColor" width="16" height="16" style="display: none;">
142
+ <path d="m4.182 4.31.016.011 10.104 7.316.013.01 1.375.996a.75.75 0 1 1-.88 1.214L13.626 13H2.518a1.516 1.516 0 0 1-1.263-2.36l1.703-2.554A.255.255 0 0 0 3 7.947V5.305L.31 3.357a.75.75 0 1 1 .88-1.214Zm7.373 7.19L4.5 6.391v1.556c0 .346-.102.683-.294.97l-1.703 2.556a.017.017 0 0 0-.003.01c0 .005.002.009.005.012l.006.004.007.001ZM8 1.5c-.997 0-1.895.416-2.534 1.086A.75.75 0 1 1 4.38 1.55 5 5 0 0 1 13 5v2.373a.75.75 0 0 1-1.5 0V5A3.5 3.5 0 0 0 8 1.5ZM8 16a2 2 0 0 1-1.985-1.75c-.017-.137.097-.25.235-.25h3.5c.138 0 .252.113.235.25A2 2 0 0 1 8 16Z"/>
143
+ </svg>
144
+ </button>
137
145
  <a class="btn btn-icon settings-link" id="settings-link" href="#" title="Repository settings">
138
146
  <svg viewBox="0 0 16 16" fill="currentColor" width="16" height="16">
139
147
  <path d="M8 0a8.2 8.2 0 0 1 .701.031C9.444.095 9.99.645 10.16 1.29l.288 1.107c.018.066.079.158.212.224.231.114.454.243.668.386.123.082.233.09.299.071l1.103-.303c.644-.176 1.392.021 1.82.63.27.385.506.792.704 1.218.315.675.111 1.422-.364 1.891l-.814.806c-.049.048-.098.147-.088.294.016.257.016.515 0 .772-.01.147.038.246.088.294l.814.806c.475.469.679 1.216.364 1.891a7.977 7.977 0 0 1-.704 1.217c-.428.61-1.176.807-1.82.63l-1.102-.302c-.067-.019-.177-.011-.3.071a5.909 5.909 0 0 1-.668.386c-.133.066-.194.158-.211.224l-.29 1.106c-.168.646-.715 1.196-1.458 1.26a8.006 8.006 0 0 1-1.402 0c-.743-.064-1.289-.614-1.458-1.26l-.289-1.106c-.018-.066-.079-.158-.212-.224a5.738 5.738 0 0 1-.668-.386c-.123-.082-.233-.09-.299-.071l-1.103.303c-.644.176-1.392-.021-1.82-.63a8.12 8.12 0 0 1-.704-1.218c-.315-.675-.111-1.422.363-1.891l.815-.806c.05-.048.098-.147.088-.294a6.214 6.214 0 0 1 0-.772c.01-.147-.038-.246-.088-.294l-.815-.806C.635 6.045.431 5.298.746 4.623a7.92 7.92 0 0 1 .704-1.217c.428-.61 1.176-.807 1.82-.63l1.102.302c.067.019.177.011.3-.071.214-.143.437-.272.668-.386.133-.066.194-.158.211-.224l.29-1.106C6.009.645 6.556.095 7.299.03 7.53.01 7.764 0 8 0Zm-.571 1.525c-.036.003-.108.036-.137.146l-.289 1.105c-.147.561-.549.967-.998 1.189-.173.086-.34.183-.5.29-.417.278-.97.423-1.529.27l-1.103-.303c-.109-.03-.175.016-.195.045-.22.312-.412.644-.573.99-.014.031-.021.11.059.19l.815.806c.411.406.562.957.53 1.456a4.709 4.709 0 0 0 0 .582c.032.499-.119 1.05-.53 1.456l-.815.806c-.081.08-.073.159-.059.19.162.346.353.677.573.989.02.03.085.076.195.046l1.102-.303c.56-.153 1.113-.008 1.53.27.161.107.328.204.501.29.447.222.85.629.997 1.189l.289 1.105c.029.109.101.143.137.146a6.6 6.6 0 0 0 1.142 0c.036-.003.108-.036.137-.146l.289-1.105c.147-.561.549-.967.998-1.189.173-.086.34-.183.5-.29.417-.278.97-.423 1.529-.27l1.103.303c.109.029.175-.016.195-.045.22-.313.411-.644.573-.99.014-.031.021-.11-.059-.19l-.815-.806c-.411-.406-.562-.957-.53-1.456a4.709 4.709 0 0 0 0-.582c-.032-.499.119-1.05.53-1.456l.815-.806c.081-.08.073-.159.059-.19a6.464 6.464 0 0 0-.573-.989c-.02-.03-.085-.076-.195-.046l-1.102.303c-.56.153-1.113.008-1.53-.27a4.44 4.44 0 0 0-.501-.29c-.447-.222-.85-.629-.997-1.189l-.289-1.105c-.029-.11-.101-.143-.137-.146a6.6 6.6 0 0 0-1.142 0ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM9.5 8a1.5 1.5 0 1 0-3.001.001A1.5 1.5 0 0 0 9.5 8Z"/>
@@ -386,6 +394,8 @@
386
394
  <script src="/js/components/VoiceCentricConfigTab.js"></script>
387
395
  <script src="/js/components/AdvancedConfigTab.js"></script>
388
396
  <script src="/js/components/CouncilProgressModal.js"></script>
397
+ <script src="/js/utils/notification-sounds.js"></script>
398
+ <script src="/js/components/NotificationDropdown.js"></script>
389
399
  <script src="/js/components/StatusIndicator.js"></script>
390
400
  <script src="/js/components/SuggestionNavigator.js"></script>
391
401
  <script src="/js/components/ReviewModal.js"></script>
@@ -396,6 +406,8 @@
396
406
  <script src="/js/components/EmojiPicker.js"></script>
397
407
  <script src="/js/components/KeyboardShortcuts.js"></script>
398
408
  <script src="/js/components/DiffOptionsDropdown.js"></script>
409
+ <script src="/js/components/StackAnalysisDialog.js"></script>
410
+ <script src="/js/components/StackProgressModal.js"></script>
399
411
 
400
412
  <!-- PR Modules (must load before pr.js) -->
401
413
  <script src="/js/modules/storage-cleanup.js"></script>
package/public/setup.html CHANGED
@@ -528,6 +528,7 @@
528
528
 
529
529
  <!-- WebSocket client -->
530
530
  <script src="/js/ws-client.js"></script>
531
+ <script src="/js/utils/notification-sounds.js"></script>
531
532
 
532
533
  <script>
533
534
  // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
@@ -818,6 +819,9 @@
818
819
  updateProgressBar();
819
820
  showRedirect();
820
821
 
822
+ // Play notification sound for PR setup completion
823
+ if (window.notificationSounds && mode !== 'local') window.notificationSounds.playIfEnabled('setup');
824
+
821
825
  // Small delay so the user sees the completed state
822
826
  setTimeout(function() {
823
827
  if (msg.reviewUrl) {