@in-the-loop-labs/pair-review 1.4.3 → 1.5.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 (48) hide show
  1. package/package.json +1 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/plugin/skills/review-requests/SKILL.md +54 -0
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/public/css/pr.css +1081 -54
  6. package/public/css/repo-settings.css +452 -140
  7. package/public/js/components/AdvancedConfigTab.js +1364 -0
  8. package/public/js/components/AnalysisConfigModal.js +488 -112
  9. package/public/js/components/CouncilProgressModal.js +1416 -0
  10. package/public/js/components/TextInputDialog.js +231 -0
  11. package/public/js/components/TimeoutSelect.js +367 -0
  12. package/public/js/components/VoiceCentricConfigTab.js +1334 -0
  13. package/public/js/local.js +162 -83
  14. package/public/js/modules/analysis-history.js +185 -11
  15. package/public/js/modules/comment-manager.js +13 -0
  16. package/public/js/modules/file-comment-manager.js +28 -0
  17. package/public/js/pr.js +233 -115
  18. package/public/js/repo-settings.js +575 -106
  19. package/public/local.html +11 -1
  20. package/public/pr.html +6 -1
  21. package/public/repo-settings.html +28 -21
  22. package/public/setup.html +8 -2
  23. package/src/ai/analyzer.js +1262 -111
  24. package/src/ai/claude-cli.js +2 -2
  25. package/src/ai/claude-provider.js +6 -6
  26. package/src/ai/codex-provider.js +6 -6
  27. package/src/ai/copilot-provider.js +3 -3
  28. package/src/ai/cursor-agent-provider.js +6 -6
  29. package/src/ai/gemini-provider.js +6 -6
  30. package/src/ai/opencode-provider.js +6 -6
  31. package/src/ai/pi-provider.js +6 -6
  32. package/src/ai/prompts/baseline/consolidation/balanced.js +208 -0
  33. package/src/ai/prompts/baseline/consolidation/fast.js +175 -0
  34. package/src/ai/prompts/baseline/consolidation/thorough.js +283 -0
  35. package/src/ai/prompts/config.js +1 -1
  36. package/src/ai/prompts/index.js +26 -2
  37. package/src/ai/provider.js +4 -2
  38. package/src/database.js +417 -14
  39. package/src/main.js +1 -1
  40. package/src/routes/analysis.js +495 -10
  41. package/src/routes/config.js +36 -15
  42. package/src/routes/councils.js +351 -0
  43. package/src/routes/local.js +33 -11
  44. package/src/routes/mcp.js +9 -2
  45. package/src/routes/setup.js +12 -2
  46. package/src/routes/shared.js +126 -13
  47. package/src/server.js +34 -4
  48. package/src/utils/stats-calculator.js +2 -0
@@ -0,0 +1,1416 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * Council AI Analysis Progress Modal Component
4
+ *
5
+ * Displays a hierarchical tree view of council analysis progress:
6
+ * - Levels as parent rows (1, 2, 3)
7
+ * - Voice participants as child rows under each level
8
+ * - Consolidation section at the bottom
9
+ *
10
+ * Replaces ProgressModal when a council analysis is running.
11
+ * The existing ProgressModal remains for single-model analysis.
12
+ */
13
+ class CouncilProgressModal {
14
+ constructor() {
15
+ this.modal = null;
16
+ this.isVisible = false;
17
+ this.currentAnalysisId = null;
18
+ this.eventSource = null;
19
+ this.statusCheckInterval = null;
20
+ this.isRunningInBackground = false;
21
+ this.councilConfig = null;
22
+
23
+ // Track per-voice completion state
24
+ this._voiceStates = {};
25
+ // Track SSE endpoint mode
26
+ this._useLocalEndpoint = false;
27
+ this._localReviewId = null;
28
+
29
+ this._createModal();
30
+ this._setupEventListeners();
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Lifecycle
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /**
38
+ * Show the modal for an analysis.
39
+ * Supports three rendering modes:
40
+ * - Single model: pass null/undefined for councilConfig
41
+ * - Voice-centric council: pass councilConfig with configType 'council'
42
+ * - Level-centric (advanced) council: pass councilConfig with configType 'advanced' (or no configType)
43
+ *
44
+ * @param {string} analysisId - Analysis ID to track
45
+ * @param {Object} [councilConfig] - Council configuration (levels + orchestration), or null for single model
46
+ * @param {string} [councilName] - Display name for the council
47
+ * @param {Object} [options] - Additional options
48
+ * @param {string} [options.configType] - 'single', 'council', or 'advanced'
49
+ * @param {Array} [options.enabledLevels] - For single model: which levels are enabled [1,2,3]
50
+ */
51
+ show(analysisId, councilConfig, councilName, options = {}) {
52
+ this.currentAnalysisId = analysisId;
53
+ this.councilConfig = councilConfig;
54
+ this.isVisible = true;
55
+ this._voiceStates = {};
56
+
57
+ // Detect rendering mode
58
+ const configType = options.configType || (councilConfig ? 'advanced' : 'single');
59
+ this._renderMode = configType;
60
+
61
+ // Rebuild DOM based on mode
62
+ if (configType === 'single') {
63
+ const enabledLevels = options.enabledLevels || [1, 2, 3];
64
+ this._rebuildBodySingleModel(enabledLevels);
65
+ } else if (configType === 'council') {
66
+ this._rebuildBodyVoiceCentric(councilConfig);
67
+ } else {
68
+ this._rebuildBody(councilConfig);
69
+ }
70
+
71
+ // Update the header
72
+ const titleEl = this.modal.querySelector('#council-progress-title');
73
+ if (titleEl) {
74
+ if (configType === 'single') {
75
+ titleEl.textContent = 'Review progress';
76
+ } else if (councilName) {
77
+ titleEl.textContent = `Review council progress \u00b7 ${councilName}`;
78
+ } else {
79
+ titleEl.textContent = 'Review council progress';
80
+ }
81
+ }
82
+
83
+ this.modal.style.display = 'flex';
84
+ this._resetFooter();
85
+
86
+ this.startProgressMonitoring();
87
+ }
88
+
89
+ /**
90
+ * Hide the modal.
91
+ */
92
+ hide() {
93
+ this.isVisible = false;
94
+ this.modal.style.display = 'none';
95
+
96
+ if (!this.isRunningInBackground) {
97
+ this.stopProgressMonitoring();
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Run analysis in background.
103
+ */
104
+ runInBackground() {
105
+ this.isRunningInBackground = true;
106
+ this.hide();
107
+ }
108
+
109
+ /**
110
+ * Reopen the modal from background execution.
111
+ */
112
+ reopenFromBackground() {
113
+ this.isRunningInBackground = false;
114
+ if (this.currentAnalysisId && this.councilConfig) {
115
+ // Don't rebuild — just re-show the existing DOM
116
+ this.isVisible = true;
117
+ this.modal.style.display = 'flex';
118
+ }
119
+
120
+ if (window.statusIndicator) {
121
+ window.statusIndicator.hide();
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Cancel the analysis.
127
+ */
128
+ async cancel() {
129
+ if (!this.currentAnalysisId) {
130
+ this.hide();
131
+ return;
132
+ }
133
+
134
+ try {
135
+ await fetch(`/api/analyze/cancel/${this.currentAnalysisId}`, { method: 'POST' });
136
+ } catch (error) {
137
+ console.warn('Cancel not available on server:', error.message);
138
+ }
139
+
140
+ this.stopProgressMonitoring();
141
+ this.hide();
142
+
143
+ if (window.prManager) {
144
+ window.prManager.resetButton();
145
+ }
146
+ if (window.aiPanel?.setAnalysisState) {
147
+ window.aiPanel.setAnalysisState('unknown');
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Configure for local mode SSE endpoint.
153
+ * @param {string|number} reviewId
154
+ */
155
+ setLocalMode(reviewId) {
156
+ this._useLocalEndpoint = true;
157
+ this._localReviewId = reviewId;
158
+ }
159
+
160
+ /**
161
+ * Configure for PR mode SSE endpoint (default).
162
+ */
163
+ setPRMode() {
164
+ this._useLocalEndpoint = false;
165
+ this._localReviewId = null;
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // SSE / Polling
170
+ // ---------------------------------------------------------------------------
171
+
172
+ startProgressMonitoring() {
173
+ if (this.eventSource) {
174
+ this.eventSource.close();
175
+ }
176
+ if (!this.currentAnalysisId) return;
177
+
178
+ const sseUrl = this._useLocalEndpoint
179
+ ? `/api/local/${this._localReviewId}/ai-suggestions/status`
180
+ : `/api/pr/${this.currentAnalysisId}/ai-suggestions/status`;
181
+
182
+ this.eventSource = new EventSource(sseUrl);
183
+
184
+ this.eventSource.onopen = () => {
185
+ console.log('Council progress: connected to SSE stream');
186
+ };
187
+
188
+ this.eventSource.onmessage = (event) => {
189
+ try {
190
+ const data = JSON.parse(event.data);
191
+ if (data.type === 'connected') return;
192
+ if (data.type === 'progress') {
193
+ this.updateProgress(data);
194
+ if (['completed', 'failed', 'cancelled'].includes(data.status)) {
195
+ this.stopProgressMonitoring();
196
+ }
197
+ }
198
+ } catch (error) {
199
+ console.error('Error parsing council SSE data:', error);
200
+ }
201
+ };
202
+
203
+ this.eventSource.onerror = () => {
204
+ console.error('Council SSE connection error, falling back to polling');
205
+ this._fallbackToPolling();
206
+ };
207
+ }
208
+
209
+ stopProgressMonitoring() {
210
+ if (this.statusCheckInterval) {
211
+ clearInterval(this.statusCheckInterval);
212
+ this.statusCheckInterval = null;
213
+ }
214
+ if (this.eventSource) {
215
+ this.eventSource.close();
216
+ this.eventSource = null;
217
+ }
218
+ }
219
+
220
+ _fallbackToPolling() {
221
+ if (this.eventSource) {
222
+ this.eventSource.close();
223
+ this.eventSource = null;
224
+ }
225
+ if (this.statusCheckInterval) {
226
+ clearInterval(this.statusCheckInterval);
227
+ }
228
+
229
+ this.statusCheckInterval = setInterval(async () => {
230
+ if (!this.currentAnalysisId) return;
231
+ try {
232
+ const response = await fetch(`/api/analyze/status/${this.currentAnalysisId}`);
233
+ if (!response.ok) throw new Error('Failed to fetch status');
234
+ const status = await response.json();
235
+ this.updateProgress(status);
236
+ if (['completed', 'failed', 'cancelled'].includes(status.status)) {
237
+ this.stopProgressMonitoring();
238
+ }
239
+ } catch (error) {
240
+ console.error('Error polling council analysis status:', error);
241
+ }
242
+ }, 1000);
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Progress update
247
+ // ---------------------------------------------------------------------------
248
+
249
+ /**
250
+ * Main handler for incoming progress events.
251
+ * Routes updates to per-level, per-voice, and consolidation sections.
252
+ * @param {Object} status
253
+ */
254
+ updateProgress(status) {
255
+ if (!status.levels || typeof status.levels !== 'object') {
256
+ console.warn('Council progress: invalid status structure', status);
257
+ return;
258
+ }
259
+
260
+ if (this._renderMode === 'single') {
261
+ // Single-model: update level headers directly
262
+ for (let level = 1; level <= 3; level++) {
263
+ const levelStatus = status.levels[level];
264
+ if (!levelStatus) continue;
265
+ this._updateSingleModelLevel(level, levelStatus);
266
+ }
267
+ const level4 = status.levels[4];
268
+ if (level4) {
269
+ this._updateConsolidation(level4);
270
+ }
271
+ } else if (this._renderMode === 'council') {
272
+ // Voice-centric council: transpose level-first SSE data to voice-first DOM
273
+ this._updateVoiceCentric(status);
274
+ } else {
275
+ // Advanced (level-centric): update voices within levels
276
+ for (let level = 1; level <= 3; level++) {
277
+ const levelStatus = status.levels[level];
278
+ if (!levelStatus) continue;
279
+ this._updateVoiceFromLevelStatus(level, levelStatus);
280
+ this._refreshLevelHeader(level);
281
+ }
282
+ const level4 = status.levels[4];
283
+ if (level4) {
284
+ this._updateConsolidation(level4);
285
+ }
286
+ }
287
+
288
+ // Update toolbar progress dots
289
+ const manager = window.prManager || window.localManager;
290
+ if (manager?.updateProgressDot) {
291
+ for (let level = 1; level <= 4; level++) {
292
+ if (status.levels[level]) {
293
+ manager.updateProgressDot(level, status.levels[level].status);
294
+ }
295
+ }
296
+ }
297
+
298
+ // Terminal states
299
+ if (status.status === 'completed') {
300
+ this._handleCompletion(status);
301
+ } else if (status.status === 'failed') {
302
+ this._handleFailure(status);
303
+ } else if (status.status === 'cancelled') {
304
+ this._handleCancellation(status);
305
+ }
306
+ }
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // Per-voice progress
310
+ // ---------------------------------------------------------------------------
311
+
312
+ /**
313
+ * Update a level header directly for single-model mode (no voice children).
314
+ */
315
+ _updateSingleModelLevel(level, levelStatus) {
316
+ const header = this.modal.querySelector(`.council-level-header[data-level="${level}"]`);
317
+ if (!header) return;
318
+ if (header.dataset.skipped === 'true') return;
319
+
320
+ const iconEl = header.querySelector('.council-level-icon');
321
+ const statusEl = header.querySelector('.council-level-status');
322
+ if (!iconEl || !statusEl) return;
323
+
324
+ const state = levelStatus.status || 'pending';
325
+ this._renderState(iconEl, statusEl, state, 'council-level');
326
+
327
+ // Show stream event text in the snippet element (mirrors _setVoiceState logic)
328
+ const levelEl = header.closest('.council-level');
329
+ const snippetEl = levelEl?.querySelector('.council-level-snippet');
330
+ if (snippetEl) {
331
+ if (state === 'running' && levelStatus.streamEvent?.text) {
332
+ snippetEl.textContent = levelStatus.streamEvent.text;
333
+ snippetEl.style.display = 'block';
334
+ } else if (state !== 'running') {
335
+ snippetEl.style.display = 'none';
336
+ }
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Given a level's status (which may include a voiceId), update the right voice row.
342
+ */
343
+ _updateVoiceFromLevelStatus(level, levelStatus) {
344
+ // Per-voice statuses map: tracks all voices, prevents clobbering when
345
+ // multiple voices on the same level complete concurrently
346
+ if (levelStatus.voices) {
347
+ for (const [vid, vStatus] of Object.entries(levelStatus.voices)) {
348
+ this._setVoiceState(vid, vStatus.status || 'running', vStatus);
349
+ }
350
+ // Don't fall through to bulk completion — individual voice states are
351
+ // authoritative. Overall completion is handled by _handleCompletion().
352
+ return;
353
+ }
354
+
355
+ const voiceId = levelStatus.voiceId;
356
+
357
+ if (voiceId) {
358
+ // Single voiceId without voices map (stream events or legacy path)
359
+ this._setVoiceState(voiceId, levelStatus.status || 'running', levelStatus);
360
+ } else if (levelStatus.status === 'completed') {
361
+ // Level completed without a voiceId — mark all pending/running voices as complete
362
+ this._completeAllVoicesForLevel(level);
363
+ } else if (levelStatus.status === 'failed') {
364
+ this._failAllVoicesForLevel(level);
365
+ } else if (levelStatus.status === 'cancelled') {
366
+ this._cancelAllVoicesForLevel(level);
367
+ } else if (levelStatus.status === 'skipped') {
368
+ // Already handled by DOM construction
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Shared state rendering helper.
374
+ * Maps a state string to the appropriate icon, CSS class, and label,
375
+ * then applies them to the given icon and status elements.
376
+ *
377
+ * @param {HTMLElement} iconEl - Element that shows the state icon
378
+ * @param {HTMLElement} statusEl - Element that shows the state label
379
+ * @param {string} state - One of 'pending', 'running', 'completed', 'failed', 'cancelled', 'skipped'
380
+ * @param {string} cssPrefix - CSS class prefix (e.g. 'council-voice' or 'council-level')
381
+ */
382
+ _renderState(iconEl, statusEl, state, cssPrefix) {
383
+ const stateMap = {
384
+ pending: { icon: '\u25CB', label: 'Pending' },
385
+ running: { icon: null, label: 'Running...' }, // null = spinner HTML
386
+ completed: { icon: '\u2713', label: 'Complete' },
387
+ failed: { icon: '\u2717', label: 'Failed' },
388
+ cancelled: { icon: '\u2298', label: 'Cancelled' },
389
+ skipped: { icon: '\u2014', label: 'Skipped' }
390
+ };
391
+
392
+ const entry = stateMap[state] || stateMap.pending;
393
+ const resolvedState = stateMap[state] ? state : 'pending';
394
+
395
+ iconEl.className = `${cssPrefix}-icon ${resolvedState}`;
396
+ if (entry.icon === null) {
397
+ iconEl.innerHTML = '<span class="council-spinner"></span>';
398
+ } else {
399
+ iconEl.textContent = entry.icon;
400
+ }
401
+
402
+ statusEl.textContent = entry.label;
403
+ statusEl.className = `${cssPrefix}-status ${resolvedState}`;
404
+ }
405
+
406
+ /**
407
+ * Set the display state for a voice element.
408
+ */
409
+ _setVoiceState(voiceId, state, levelStatus) {
410
+ const el = this.modal.querySelector(`[data-voice-id="${voiceId}"]`);
411
+ if (!el) return;
412
+
413
+ this._voiceStates[voiceId] = state;
414
+
415
+ const iconEl = el.querySelector('.council-voice-icon');
416
+ const statusEl = el.querySelector('.council-voice-status');
417
+ const snippetEl = el.querySelector('.council-voice-snippet');
418
+
419
+ this._renderState(iconEl, statusEl, state, 'council-voice');
420
+
421
+ // State-specific detail visibility
422
+ if (state === 'running') {
423
+ if (snippetEl && levelStatus?.streamEvent?.text) {
424
+ snippetEl.textContent = levelStatus.streamEvent.text;
425
+ snippetEl.style.display = 'block';
426
+ }
427
+ } else {
428
+ if (snippetEl) snippetEl.style.display = 'none';
429
+ }
430
+ }
431
+
432
+ _completeAllVoicesForLevel(level) {
433
+ const voiceEls = this.modal.querySelectorAll(`.council-voice[data-level="${level}"]`);
434
+ voiceEls.forEach(el => {
435
+ const vid = el.dataset.voiceId;
436
+ if (this._voiceStates[vid] !== 'completed' && this._voiceStates[vid] !== 'failed') {
437
+ this._setVoiceState(vid, 'completed', {});
438
+ }
439
+ });
440
+ }
441
+
442
+ _failAllVoicesForLevel(level) {
443
+ const voiceEls = this.modal.querySelectorAll(`.council-voice[data-level="${level}"]`);
444
+ voiceEls.forEach(el => {
445
+ const vid = el.dataset.voiceId;
446
+ if (this._voiceStates[vid] !== 'completed') {
447
+ this._setVoiceState(vid, 'failed', {});
448
+ }
449
+ });
450
+ }
451
+
452
+ _cancelAllVoicesForLevel(level) {
453
+ const voiceEls = this.modal.querySelectorAll(`.council-voice[data-level="${level}"]`);
454
+ voiceEls.forEach(el => {
455
+ const vid = el.dataset.voiceId;
456
+ if (this._voiceStates[vid] !== 'completed') {
457
+ this._setVoiceState(vid, 'cancelled', {});
458
+ }
459
+ });
460
+ }
461
+
462
+ // ---------------------------------------------------------------------------
463
+ // Level header state
464
+ // ---------------------------------------------------------------------------
465
+
466
+ /**
467
+ * Refresh the level header icon/status based on child voice states.
468
+ */
469
+ _refreshLevelHeader(level) {
470
+ const header = this.modal.querySelector(`.council-level-header[data-level="${level}"]`);
471
+ if (!header) return;
472
+
473
+ const iconEl = header.querySelector('.council-level-icon');
474
+ const statusEl = header.querySelector('.council-level-status');
475
+ if (!iconEl || !statusEl) return;
476
+
477
+ // Check if this level is skipped
478
+ if (header.dataset.skipped === 'true') return;
479
+
480
+ const voiceEls = this.modal.querySelectorAll(`.council-voice[data-level="${level}"]`);
481
+ if (voiceEls.length === 0) return;
482
+
483
+ const states = Array.from(voiceEls).map(el => this._voiceStates[el.dataset.voiceId] || 'pending');
484
+
485
+ const allComplete = states.every(s => s === 'completed');
486
+ const anyRunning = states.some(s => s === 'running');
487
+ const anyFailed = states.some(s => s === 'failed');
488
+ const allCancelled = states.every(s => s === 'cancelled');
489
+
490
+ let derivedState;
491
+ if (allComplete) {
492
+ derivedState = 'completed';
493
+ } else if (allCancelled) {
494
+ derivedState = 'cancelled';
495
+ } else if (anyFailed) {
496
+ derivedState = 'failed';
497
+ } else if (anyRunning) {
498
+ derivedState = 'running';
499
+ } else {
500
+ derivedState = 'pending';
501
+ }
502
+
503
+ this._renderState(iconEl, statusEl, derivedState, 'council-level');
504
+ }
505
+
506
+ // ---------------------------------------------------------------------------
507
+ // Voice-centric progress updates
508
+ // ---------------------------------------------------------------------------
509
+
510
+ /**
511
+ * Update voice-centric DOM from level-first SSE data.
512
+ * Transposes levels -> voices: for each level in SSE data, update the
513
+ * corresponding level-child under each voice parent.
514
+ * @param {Object} status - SSE status object with levels map
515
+ */
516
+ _updateVoiceCentric(status) {
517
+ for (let level = 1; level <= 3; level++) {
518
+ const levelStatus = status.levels[level];
519
+ if (!levelStatus) continue;
520
+
521
+ if (levelStatus.voices) {
522
+ // Update each voice's level child
523
+ for (const [voiceId, vStatus] of Object.entries(levelStatus.voices)) {
524
+ this._setVoiceCentricLevelState(voiceId, level, vStatus.status || 'running', vStatus);
525
+ }
526
+ }
527
+
528
+ // Update stream event text for the currently active voice
529
+ if (levelStatus.streamEvent?.text && levelStatus.voiceId) {
530
+ this._setVoiceCentricStreamText(levelStatus.voiceId, level, levelStatus.streamEvent.text);
531
+ }
532
+ }
533
+
534
+ // Handle per-voice orchestration updates (level 4):
535
+ // In voice-centric mode, each reviewer has a consolidation child at data-vc-level="4".
536
+ // The backend tracks per-voice orchestration state in levels[4].voices.
537
+ const level4 = status.levels[4];
538
+ if (level4) {
539
+ if (level4.voices) {
540
+ for (const [voiceId, vStatus] of Object.entries(level4.voices)) {
541
+ this._setVoiceCentricLevelState(voiceId, 4, vStatus.status || 'running', vStatus);
542
+ }
543
+ }
544
+
545
+ // Update stream event text for the active voice's orchestration step
546
+ if (level4.streamEvent?.text && level4.voiceId) {
547
+ this._setVoiceCentricStreamText(level4.voiceId, 4, level4.streamEvent.text);
548
+ }
549
+
550
+ // Handle cross-reviewer consolidation section (no voiceId — shared consolidation)
551
+ this._updateConsolidation(level4);
552
+ }
553
+
554
+ // Refresh all voice headers based on their children states
555
+ this._refreshAllVoiceHeaders();
556
+ }
557
+
558
+ /**
559
+ * Set the state of a level-child element within a voice-centric parent.
560
+ * @param {string} voiceId - Voice key (e.g. 'claude-opus')
561
+ * @param {number} level - Level number (1-4)
562
+ * @param {string} state - State string
563
+ * @param {Object} levelStatus - Level status data (may contain streamEvent)
564
+ */
565
+ _setVoiceCentricLevelState(voiceId, level, state, levelStatus) {
566
+ const el = this.modal.querySelector(`[data-vc-voice="${voiceId}"][data-vc-level="${level}"]`);
567
+ if (!el) return;
568
+
569
+ // Track state
570
+ const stateKey = `${voiceId}:${level}`;
571
+ this._voiceStates[stateKey] = state;
572
+
573
+ const iconEl = el.querySelector('.council-voice-icon');
574
+ const statusEl = el.querySelector('.council-voice-status');
575
+ this._renderState(iconEl, statusEl, state, 'council-voice');
576
+
577
+ // Show/hide snippet
578
+ const snippetEl = el.querySelector('.council-voice-snippet');
579
+ if (snippetEl) {
580
+ if (state === 'running' && levelStatus?.streamEvent?.text) {
581
+ snippetEl.textContent = levelStatus.streamEvent.text;
582
+ snippetEl.style.display = 'block';
583
+ } else if (state !== 'running') {
584
+ snippetEl.style.display = 'none';
585
+ }
586
+ }
587
+ }
588
+
589
+ /**
590
+ * Update the stream text snippet for a voice-centric level child.
591
+ * @param {string} voiceId - Voice key
592
+ * @param {number} level - Level number
593
+ * @param {string} text - Stream event text
594
+ */
595
+ _setVoiceCentricStreamText(voiceId, level, text) {
596
+ const el = this.modal.querySelector(`[data-vc-voice="${voiceId}"][data-vc-level="${level}"]`);
597
+ if (!el) return;
598
+ const snippetEl = el.querySelector('.council-voice-snippet');
599
+ if (snippetEl) {
600
+ snippetEl.textContent = text;
601
+ snippetEl.style.display = 'block';
602
+ }
603
+ }
604
+
605
+ /**
606
+ * Refresh all voice parent headers based on the aggregate state
607
+ * of their level children.
608
+ */
609
+ _refreshAllVoiceHeaders() {
610
+ const voiceContainers = this.modal.querySelectorAll('[data-voice-key]');
611
+ voiceContainers.forEach(container => {
612
+ const voiceKey = container.dataset.voiceKey;
613
+ const header = container.querySelector('.council-level-header');
614
+ if (!header) return;
615
+
616
+ const iconEl = header.querySelector('.council-level-icon');
617
+ const statusEl = header.querySelector('.council-level-status');
618
+ if (!iconEl || !statusEl) return;
619
+
620
+ // Collect states for all level children of this voice
621
+ const levelEls = container.querySelectorAll('[data-vc-level]');
622
+ const states = Array.from(levelEls).map(el => {
623
+ const key = `${voiceKey}:${el.dataset.vcLevel}`;
624
+ return this._voiceStates[key] || 'pending';
625
+ });
626
+
627
+ const allComplete = states.every(s => s === 'completed');
628
+ const anyRunning = states.some(s => s === 'running');
629
+ const anyFailed = states.some(s => s === 'failed');
630
+ const allCancelled = states.every(s => s === 'cancelled');
631
+
632
+ let derivedState;
633
+ if (allComplete) {
634
+ derivedState = 'completed';
635
+ } else if (allCancelled) {
636
+ derivedState = 'cancelled';
637
+ } else if (anyFailed) {
638
+ derivedState = 'failed';
639
+ } else if (anyRunning) {
640
+ derivedState = 'running';
641
+ } else {
642
+ derivedState = states.some(s => s !== 'pending') ? 'running' : 'pending';
643
+ }
644
+
645
+ this._renderState(iconEl, statusEl, derivedState, 'council-level');
646
+ });
647
+ }
648
+
649
+ // ---------------------------------------------------------------------------
650
+ // Consolidation section
651
+ // ---------------------------------------------------------------------------
652
+
653
+ _updateConsolidation(level4Status) {
654
+ const section = this.modal.querySelector('.council-consolidation');
655
+ if (!section) return;
656
+
657
+ const iconEl = section.querySelector('.council-level-icon');
658
+ const statusEl = section.querySelector('.council-level-status');
659
+
660
+ const state = level4Status.status;
661
+ if (state === 'pending') return; // default DOM state
662
+
663
+ // Per-step updates: update individual consolidation children based on
664
+ // the steps map or consolidationStep identifier
665
+ if (level4Status.steps) {
666
+ this._updateConsolidationChildrenFromSteps(level4Status.steps);
667
+ } else if (level4Status.consolidationStep) {
668
+ this._updateConsolidationChild(level4Status.consolidationStep, state);
669
+ } else {
670
+ // Terminal states (completed/failed/cancelled): bulk-update all children
671
+ this._updateConsolidationChildrenBulk(state);
672
+ }
673
+
674
+ // Derive the parent header state from children
675
+ this._refreshConsolidationHeader(iconEl, statusEl, state);
676
+ }
677
+
678
+ /**
679
+ * Update individual consolidation children based on per-step status map.
680
+ * @param {Object} steps - Map of step ID (e.g. 'L1', 'orchestration') to { status, progress }
681
+ */
682
+ _updateConsolidationChildrenFromSteps(steps) {
683
+ for (const [stepId, stepStatus] of Object.entries(steps)) {
684
+ this._updateConsolidationChild(stepId, stepStatus.status || 'running');
685
+ }
686
+ }
687
+
688
+ /**
689
+ * Update a single consolidation child by its step ID.
690
+ * @param {string} stepId - 'L1', 'L2', 'L3', or 'orchestration'
691
+ * @param {string} state - 'running', 'completed', 'failed', etc.
692
+ */
693
+ _updateConsolidationChild(stepId, state) {
694
+ const child = this.modal.querySelector(`.council-consolidation-child[data-consolidation="${stepId}"]`);
695
+ if (!child) return;
696
+
697
+ const iconEl = child.querySelector('.council-voice-icon');
698
+ const statusEl = child.querySelector('.council-voice-status');
699
+ if (!iconEl || !statusEl) return;
700
+
701
+ this._renderState(iconEl, statusEl, state, 'council-voice');
702
+ }
703
+
704
+ /**
705
+ * Bulk-update all consolidation children with the same state.
706
+ * Used for terminal states (completed, failed, cancelled).
707
+ * @param {string} state
708
+ */
709
+ _updateConsolidationChildrenBulk(state) {
710
+ const children = this.modal.querySelectorAll('.council-consolidation-child');
711
+ children.forEach(child => {
712
+ const iconEl = child.querySelector('.council-voice-icon');
713
+ const statusEl = child.querySelector('.council-voice-status');
714
+ if (!iconEl || !statusEl) return;
715
+
716
+ this._renderState(iconEl, statusEl, state, 'council-voice');
717
+ });
718
+ }
719
+
720
+ /**
721
+ * Refresh the consolidation header state based on children states.
722
+ * @param {HTMLElement} iconEl - Header icon element
723
+ * @param {HTMLElement} statusEl - Header status element
724
+ * @param {string} fallbackState - State to use if no children exist
725
+ */
726
+ _refreshConsolidationHeader(iconEl, statusEl, fallbackState) {
727
+ if (!iconEl || !statusEl) return;
728
+
729
+ const children = this.modal.querySelectorAll('.council-consolidation-child');
730
+ if (children.length === 0) {
731
+ // No children (simple consolidation) — use the state directly
732
+ this._renderState(iconEl, statusEl, fallbackState, 'council-level');
733
+ return;
734
+ }
735
+
736
+ // Derive state from children via CSS classes (set by _renderState)
737
+ const childStates = Array.from(children).map(child => {
738
+ const childStatusEl = child.querySelector('.council-voice-status');
739
+ if (!childStatusEl) return 'pending';
740
+ if (childStatusEl.classList.contains('completed')) return 'completed';
741
+ if (childStatusEl.classList.contains('running')) return 'running';
742
+ if (childStatusEl.classList.contains('failed')) return 'failed';
743
+ if (childStatusEl.classList.contains('cancelled')) return 'cancelled';
744
+ if (childStatusEl.classList.contains('skipped')) return 'skipped';
745
+ return 'pending';
746
+ });
747
+
748
+ const allComplete = childStates.every(s => s === 'completed');
749
+ const anyRunning = childStates.some(s => s === 'running');
750
+ const anyFailed = childStates.some(s => s === 'failed');
751
+ const allCancelled = childStates.every(s => s === 'cancelled');
752
+
753
+ let derivedState;
754
+ if (allComplete) {
755
+ derivedState = 'completed';
756
+ } else if (allCancelled) {
757
+ derivedState = 'cancelled';
758
+ } else if (anyFailed) {
759
+ derivedState = 'failed';
760
+ } else if (anyRunning) {
761
+ derivedState = 'running';
762
+ } else {
763
+ derivedState = childStates.some(s => s !== 'pending') ? 'running' : 'pending';
764
+ }
765
+
766
+ this._renderState(iconEl, statusEl, derivedState, 'council-level');
767
+ }
768
+
769
+ // ---------------------------------------------------------------------------
770
+ // Terminal state handlers
771
+ // ---------------------------------------------------------------------------
772
+
773
+ _handleCompletion(status) {
774
+ if (this._renderMode === 'council') {
775
+ // Voice-centric: mark all voice-level children as complete
776
+ const vcEls = this.modal.querySelectorAll('[data-vc-voice][data-vc-level]');
777
+ vcEls.forEach(el => {
778
+ const key = `${el.dataset.vcVoice}:${el.dataset.vcLevel}`;
779
+ if (this._voiceStates[key] !== 'completed' && this._voiceStates[key] !== 'failed') {
780
+ this._voiceStates[key] = 'completed';
781
+ const iconEl = el.querySelector('.council-voice-icon');
782
+ const statusEl = el.querySelector('.council-voice-status');
783
+ this._renderState(iconEl, statusEl, 'completed', 'council-voice');
784
+ const snippetEl = el.querySelector('.council-voice-snippet');
785
+ if (snippetEl) snippetEl.style.display = 'none';
786
+ }
787
+ });
788
+ this._refreshAllVoiceHeaders();
789
+ } else {
790
+ // Level-centric / single: mark all remaining voices/levels as complete
791
+ for (let level = 1; level <= 3; level++) {
792
+ this._completeAllVoicesForLevel(level);
793
+ this._refreshLevelHeader(level);
794
+ }
795
+ }
796
+ this._updateConsolidation({ status: 'completed' });
797
+
798
+ // Update footer
799
+ const bgBtn = this.modal.querySelector('.council-bg-btn');
800
+ const cancelBtn = this.modal.querySelector('.council-cancel-btn');
801
+ if (bgBtn) {
802
+ bgBtn.textContent = 'Analysis Complete';
803
+ bgBtn.disabled = true;
804
+ }
805
+ if (cancelBtn) {
806
+ cancelBtn.textContent = 'Close';
807
+ }
808
+
809
+ // Update button
810
+ if (window.prManager) {
811
+ window.prManager.setButtonComplete();
812
+ }
813
+
814
+ // Reload suggestions
815
+ const manager = window.prManager || window.localManager;
816
+ if (manager && typeof manager.loadAISuggestions === 'function') {
817
+ const shouldSwitchToNew = this.isVisible;
818
+
819
+ const refreshHistory = async () => {
820
+ if (manager.analysisHistoryManager) {
821
+ const result = await manager.analysisHistoryManager.refresh({ switchToNew: shouldSwitchToNew });
822
+ return result.didSwitch;
823
+ }
824
+ return true;
825
+ };
826
+
827
+ refreshHistory()
828
+ .then((didSwitch) => {
829
+ if (didSwitch) {
830
+ return manager.loadAISuggestions();
831
+ }
832
+ console.log('Council analysis complete — user will see indicator on dropdown');
833
+ return Promise.resolve();
834
+ })
835
+ .then(() => {
836
+ console.log('AI suggestions reloaded after council analysis');
837
+ if (this.isVisible) {
838
+ setTimeout(() => this.hide(), 2000);
839
+ }
840
+ })
841
+ .catch(error => {
842
+ console.error('Error reloading AI suggestions:', error);
843
+ if (this.isVisible) {
844
+ setTimeout(() => this.hide(), 5000);
845
+ }
846
+ });
847
+ } else {
848
+ if (this.isVisible) {
849
+ setTimeout(() => this.hide(), 3000);
850
+ }
851
+ }
852
+ }
853
+
854
+ _handleFailure(_status) {
855
+ if (this._renderMode === 'council') {
856
+ // Voice-centric: mark all non-completed voice-level children as failed
857
+ const vcEls = this.modal.querySelectorAll('[data-vc-voice][data-vc-level]');
858
+ vcEls.forEach(el => {
859
+ const key = `${el.dataset.vcVoice}:${el.dataset.vcLevel}`;
860
+ if (this._voiceStates[key] !== 'completed') {
861
+ this._voiceStates[key] = 'failed';
862
+ const iconEl = el.querySelector('.council-voice-icon');
863
+ const statusEl = el.querySelector('.council-voice-status');
864
+ this._renderState(iconEl, statusEl, 'failed', 'council-voice');
865
+ const snippetEl = el.querySelector('.council-voice-snippet');
866
+ if (snippetEl) snippetEl.style.display = 'none';
867
+ }
868
+ });
869
+ this._refreshAllVoiceHeaders();
870
+ } else {
871
+ // Level-centric / single: clean up any remaining running voices
872
+ for (let level = 1; level <= 3; level++) {
873
+ this._failAllVoicesForLevel(level);
874
+ this._refreshLevelHeader(level);
875
+ }
876
+ }
877
+ this._updateConsolidation({ status: 'failed' });
878
+
879
+ const bgBtn = this.modal.querySelector('.council-bg-btn');
880
+ const cancelBtn = this.modal.querySelector('.council-cancel-btn');
881
+ if (bgBtn) {
882
+ bgBtn.textContent = 'Analysis Failed';
883
+ bgBtn.disabled = true;
884
+ }
885
+ if (cancelBtn) {
886
+ cancelBtn.textContent = 'Close';
887
+ }
888
+
889
+ if (window.prManager) {
890
+ window.prManager.resetButton();
891
+ }
892
+ }
893
+
894
+ _handleCancellation(_status) {
895
+ if (this._renderMode === 'council') {
896
+ // Voice-centric: mark all non-completed voice-level children as cancelled
897
+ const vcEls = this.modal.querySelectorAll('[data-vc-voice][data-vc-level]');
898
+ vcEls.forEach(el => {
899
+ const key = `${el.dataset.vcVoice}:${el.dataset.vcLevel}`;
900
+ if (this._voiceStates[key] !== 'completed') {
901
+ this._voiceStates[key] = 'cancelled';
902
+ const iconEl = el.querySelector('.council-voice-icon');
903
+ const statusEl = el.querySelector('.council-voice-status');
904
+ this._renderState(iconEl, statusEl, 'cancelled', 'council-voice');
905
+ const snippetEl = el.querySelector('.council-voice-snippet');
906
+ if (snippetEl) snippetEl.style.display = 'none';
907
+ }
908
+ });
909
+ this._refreshAllVoiceHeaders();
910
+ } else {
911
+ // Level-centric / single: clean up any remaining running voices
912
+ for (let level = 1; level <= 3; level++) {
913
+ this._cancelAllVoicesForLevel(level);
914
+ this._refreshLevelHeader(level);
915
+ }
916
+ }
917
+ this._updateConsolidation({ status: 'cancelled' });
918
+
919
+ const bgBtn = this.modal.querySelector('.council-bg-btn');
920
+ const cancelBtn = this.modal.querySelector('.council-cancel-btn');
921
+ if (bgBtn) {
922
+ bgBtn.textContent = 'Analysis Cancelled';
923
+ bgBtn.disabled = true;
924
+ }
925
+ if (cancelBtn) {
926
+ cancelBtn.textContent = 'Close';
927
+ }
928
+
929
+ if (window.prManager) {
930
+ window.prManager.resetButton();
931
+ }
932
+ if (window.aiPanel?.setAnalysisState) {
933
+ window.aiPanel.setAnalysisState('unknown');
934
+ }
935
+
936
+ if (this.isVisible) {
937
+ setTimeout(() => this.hide(), 1500);
938
+ }
939
+ }
940
+
941
+ // ---------------------------------------------------------------------------
942
+ // DOM construction
943
+ // ---------------------------------------------------------------------------
944
+
945
+ _createModal() {
946
+ const existing = document.getElementById('council-progress-modal');
947
+ if (existing) existing.remove();
948
+
949
+ const overlay = document.createElement('div');
950
+ overlay.id = 'council-progress-modal';
951
+ overlay.className = 'modal-overlay';
952
+ overlay.style.display = 'none';
953
+
954
+ overlay.innerHTML = `
955
+ <div class="modal-backdrop" data-action="close"></div>
956
+ <div class="modal-container council-progress-modal">
957
+ <div class="modal-header">
958
+ <h3 id="council-progress-title">Review progress</h3>
959
+ <button class="modal-close-btn" data-action="close" title="Close">
960
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
961
+ <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"/>
962
+ </svg>
963
+ </button>
964
+ </div>
965
+ <div class="modal-body council-progress-body">
966
+ <!-- Rebuilt by _rebuildBody -->
967
+ </div>
968
+ <div class="modal-footer">
969
+ <button class="btn btn-secondary council-bg-btn">Run in Background</button>
970
+ <button class="btn btn-danger council-cancel-btn">Cancel</button>
971
+ </div>
972
+ </div>
973
+ `;
974
+
975
+ document.body.appendChild(overlay);
976
+ this.modal = overlay;
977
+ }
978
+
979
+ _setupEventListeners() {
980
+ document.addEventListener('keydown', (e) => {
981
+ if (e.key === 'Escape' && this.isVisible) {
982
+ this.hide();
983
+ }
984
+ });
985
+
986
+ // Delegate clicks
987
+ document.addEventListener('click', (e) => {
988
+ if (!this.modal) return;
989
+
990
+ // Backdrop / close button
991
+ if (e.target.closest('#council-progress-modal [data-action="close"]')) {
992
+ this.hide();
993
+ return;
994
+ }
995
+
996
+ // Background button
997
+ if (e.target.closest('#council-progress-modal .council-bg-btn')) {
998
+ this.runInBackground();
999
+ return;
1000
+ }
1001
+
1002
+ // Cancel button
1003
+ if (e.target.closest('#council-progress-modal .council-cancel-btn')) {
1004
+ this.cancel();
1005
+ }
1006
+ });
1007
+ }
1008
+
1009
+ /**
1010
+ * Rebuild the modal body for the given council config.
1011
+ */
1012
+ _rebuildBody(config) {
1013
+ const body = this.modal.querySelector('.council-progress-body');
1014
+ if (!body) return;
1015
+
1016
+ body.innerHTML = this._buildTreeHTML(config);
1017
+ }
1018
+
1019
+ /**
1020
+ * Rebuild the modal body for voice-centric council mode.
1021
+ * Transposes the level-first config into a voice-first tree:
1022
+ * - Unique voices as parent rows
1023
+ * - Enabled levels (+ orchestration) as child rows under each voice
1024
+ * - Cross-Reviewer Consolidation section at the bottom
1025
+ *
1026
+ * @param {Object} config - Council config in levels-format
1027
+ */
1028
+ _rebuildBodyVoiceCentric(config) {
1029
+ const body = this.modal.querySelector('.council-progress-body');
1030
+ if (!body) return;
1031
+
1032
+ const levelNames = {
1033
+ 1: 'Changes in Isolation',
1034
+ 2: 'File Context',
1035
+ 3: 'Codebase Context'
1036
+ };
1037
+
1038
+ // Deduplicate voices across enabled levels using the same signature-based
1039
+ // approach as the backend (runReviewerCentricCouncil in analyzer.js).
1040
+ // The backend deduplicates by provider|model|tier|customInstructions, then
1041
+ // generates keys from the index into the global deduplicated array.
1042
+ // We must mirror this exactly so voice keys match SSE progress events.
1043
+ //
1044
+ // Voice-centric format: levels are booleans (e.g. { '1': true }), voices
1045
+ // are a top-level array (config.voices). Advanced format: levels are objects
1046
+ // with .enabled and .voices properties.
1047
+ const uniqueVoices = [];
1048
+ const seenSignatures = new Set();
1049
+ const enabledLevelNums = [];
1050
+ for (const levelKey of ['1', '2', '3']) {
1051
+ const levelConfig = config.levels?.[levelKey];
1052
+ // Support both voice-centric (boolean) and advanced (object with .enabled)
1053
+ const isEnabled = typeof levelConfig === 'boolean' ? levelConfig : levelConfig?.enabled;
1054
+ if (!isEnabled) continue;
1055
+ enabledLevelNums.push(parseInt(levelKey));
1056
+ // Voice-centric: voices are at config.voices (shared across all levels)
1057
+ // Advanced: voices are per-level at levelConfig.voices
1058
+ const voices = (typeof levelConfig === 'object' && levelConfig?.voices) || config.voices || [];
1059
+ for (const voice of voices) {
1060
+ const sig = `${voice.provider}|${voice.model}|${voice.tier || 'balanced'}|${voice.customInstructions || ''}`;
1061
+ if (!seenSignatures.has(sig)) {
1062
+ seenSignatures.add(sig);
1063
+ uniqueVoices.push(voice);
1064
+ }
1065
+ }
1066
+ }
1067
+
1068
+ // Build voiceMap: voiceKey -> { voice, levels } using deduplicated array indices
1069
+ const voiceMap = new Map();
1070
+ uniqueVoices.forEach((voice, idx) => {
1071
+ const voiceKey = `${voice.provider}-${voice.model}${idx > 0 ? `-${idx}` : ''}`;
1072
+ voiceMap.set(voiceKey, { voice, levels: enabledLevelNums });
1073
+ });
1074
+
1075
+ let html = '<div class="council-progress-tree">';
1076
+
1077
+ // Build a parent row for each unique voice
1078
+ for (const [voiceKey, { voice, levels }] of voiceMap) {
1079
+ const label = this._formatVoiceLabel(voice);
1080
+
1081
+ html += `
1082
+ <div class="council-level" data-voice-key="${voiceKey}">
1083
+ <div class="council-level-header">
1084
+ <span class="council-level-icon running"><span class="council-spinner"></span></span>
1085
+ <span class="council-level-title">${label}</span>
1086
+ <span class="council-level-status running"></span>
1087
+ </div>
1088
+ <div class="council-level-children">
1089
+ `;
1090
+
1091
+ // Level children (orchestration row is always last, added separately below)
1092
+ levels.forEach((levelNum) => {
1093
+ const connectorClass = 'connector-mid';
1094
+ html += `
1095
+ <div class="council-voice ${connectorClass}" data-vc-voice="${voiceKey}" data-vc-level="${levelNum}">
1096
+ <span class="council-voice-connector ${connectorClass}"></span>
1097
+ <span class="council-voice-icon running"><span class="council-spinner"></span></span>
1098
+ <span class="council-voice-label">Level ${levelNum} \u2014 ${levelNames[levelNum]}</span>
1099
+ <span class="council-voice-status running">Running...</span>
1100
+ <div class="council-voice-detail">
1101
+ <div class="council-voice-snippet" style="display: none;"></div>
1102
+ </div>
1103
+ </div>
1104
+ `;
1105
+ });
1106
+
1107
+ // Orchestration child (always last)
1108
+ html += `
1109
+ <div class="council-voice connector-last" data-vc-voice="${voiceKey}" data-vc-level="4">
1110
+ <span class="council-voice-connector connector-last"></span>
1111
+ <span class="council-voice-icon pending">\u25CB</span>
1112
+ <span class="council-voice-label">Consolidation</span>
1113
+ <span class="council-voice-status pending">Pending</span>
1114
+ </div>
1115
+ `;
1116
+
1117
+ html += `
1118
+ </div>
1119
+ </div>
1120
+ `;
1121
+ }
1122
+
1123
+ // Cross-Reviewer Consolidation section (simple, no per-level children)
1124
+ html += `
1125
+ <div class="council-consolidation council-level">
1126
+ <div class="council-level-header">
1127
+ <span class="council-level-icon pending">\u25CB</span>
1128
+ <span class="council-level-title">Cross-Reviewer Consolidation</span>
1129
+ <span class="council-level-status pending">Pending</span>
1130
+ </div>
1131
+ </div>
1132
+ `;
1133
+
1134
+ html += '</div>';
1135
+ body.innerHTML = html;
1136
+ }
1137
+
1138
+ /**
1139
+ * Rebuild the modal body for single-model analysis.
1140
+ * Shows a simple level list without voice nesting.
1141
+ * @param {Array<number>} enabledLevels - Which levels are enabled, e.g. [1, 2, 3]
1142
+ */
1143
+ _rebuildBodySingleModel(enabledLevels) {
1144
+ const body = this.modal.querySelector('.council-progress-body');
1145
+ if (!body) return;
1146
+
1147
+ const levelNames = {
1148
+ 1: 'Changes in Isolation',
1149
+ 2: 'File Context',
1150
+ 3: 'Codebase Context'
1151
+ };
1152
+
1153
+ let html = '<div class="council-progress-tree">';
1154
+
1155
+ for (const level of [1, 2, 3]) {
1156
+ const enabled = enabledLevels.includes(level);
1157
+ const title = `Level ${level} \u2014 ${levelNames[level]}`;
1158
+
1159
+ if (!enabled) {
1160
+ html += `
1161
+ <div class="council-level" data-level="${level}">
1162
+ <div class="council-level-header" data-level="${level}" data-skipped="true">
1163
+ <span class="council-level-icon skipped">\u2014</span>
1164
+ <span class="council-level-title">${title}</span>
1165
+ <span class="council-level-status skipped">Skipped</span>
1166
+ </div>
1167
+ </div>
1168
+ `;
1169
+ } else {
1170
+ html += `
1171
+ <div class="council-level" data-level="${level}">
1172
+ <div class="council-level-header" data-level="${level}">
1173
+ <span class="council-level-icon pending">\u25CB</span>
1174
+ <span class="council-level-title">${title}</span>
1175
+ <span class="council-level-status pending">Pending</span>
1176
+ </div>
1177
+ <div class="council-level-snippet" style="display: none;"></div>
1178
+ </div>
1179
+ `;
1180
+ }
1181
+ }
1182
+
1183
+ // Orchestration row
1184
+ html += `
1185
+ <div class="council-consolidation council-level">
1186
+ <div class="council-level-header">
1187
+ <span class="council-level-icon pending">\u25CB</span>
1188
+ <span class="council-level-title">Consolidation</span>
1189
+ <span class="council-level-status pending">Pending</span>
1190
+ </div>
1191
+ </div>
1192
+ `;
1193
+
1194
+ html += '</div>';
1195
+ body.innerHTML = html;
1196
+ }
1197
+
1198
+ _resetFooter() {
1199
+ const bgBtn = this.modal.querySelector('.council-bg-btn');
1200
+ const cancelBtn = this.modal.querySelector('.council-cancel-btn');
1201
+ if (bgBtn) {
1202
+ bgBtn.textContent = 'Run in Background';
1203
+ bgBtn.disabled = false;
1204
+ }
1205
+ if (cancelBtn) {
1206
+ cancelBtn.textContent = 'Cancel';
1207
+ }
1208
+ this.isRunningInBackground = false;
1209
+ }
1210
+
1211
+ // ---------------------------------------------------------------------------
1212
+ // HTML generation
1213
+ // ---------------------------------------------------------------------------
1214
+
1215
+ _buildTreeHTML(config) {
1216
+ const levelNames = {
1217
+ 1: 'Changes in Isolation',
1218
+ 2: 'File Context',
1219
+ 3: 'Codebase Context'
1220
+ };
1221
+
1222
+ let html = '<div class="council-progress-tree">';
1223
+
1224
+ // Levels 1-3
1225
+ for (const levelKey of ['1', '2', '3']) {
1226
+ const levelConfig = config.levels?.[levelKey];
1227
+ // Support both advanced format (object with .enabled) and voice-centric format (boolean)
1228
+ const enabled = typeof levelConfig === 'boolean' ? levelConfig : levelConfig?.enabled;
1229
+ const levelNum = parseInt(levelKey);
1230
+ const title = `Level ${levelKey} \u2014 ${levelNames[levelNum]}`;
1231
+
1232
+ if (!enabled) {
1233
+ html += this._buildSkippedLevel(levelNum, title);
1234
+ } else {
1235
+ // In advanced format, voices are per-level; in voice-centric format (boolean levels),
1236
+ // voices are at config.voices (shared across all enabled levels)
1237
+ const voices = (typeof levelConfig === 'object' && levelConfig?.voices) || config.voices || [];
1238
+ html += this._buildActiveLevel(levelNum, title, voices);
1239
+ }
1240
+ }
1241
+
1242
+ // Consolidation section
1243
+ html += this._buildConsolidationSection(config);
1244
+
1245
+ html += '</div>';
1246
+ return html;
1247
+ }
1248
+
1249
+ _buildSkippedLevel(level, title) {
1250
+ return `
1251
+ <div class="council-level" data-level="${level}">
1252
+ <div class="council-level-header" data-level="${level}" data-skipped="true">
1253
+ <span class="council-level-icon skipped">\u2014</span>
1254
+ <span class="council-level-title">${title}</span>
1255
+ <span class="council-level-status skipped">Skipped</span>
1256
+ </div>
1257
+ </div>
1258
+ `;
1259
+ }
1260
+
1261
+ _buildActiveLevel(level, title, voices) {
1262
+ let html = `
1263
+ <div class="council-level" data-level="${level}">
1264
+ <div class="council-level-header" data-level="${level}">
1265
+ <span class="council-level-icon running"><span class="council-spinner"></span></span>
1266
+ <span class="council-level-title">${title}</span>
1267
+ <span class="council-level-status running"></span>
1268
+ </div>
1269
+ <div class="council-level-children">
1270
+ `;
1271
+
1272
+ voices.forEach((voice, idx) => {
1273
+ const voiceId = `L${level}-${voice.provider}-${voice.model}${idx > 0 ? `-${idx}` : ''}`;
1274
+ const label = this._formatVoiceLabel(voice);
1275
+ const isLast = idx === voices.length - 1;
1276
+
1277
+ html += this._buildVoiceRow(voiceId, label, level, isLast);
1278
+ });
1279
+
1280
+ html += `
1281
+ </div>
1282
+ </div>
1283
+ `;
1284
+ return html;
1285
+ }
1286
+
1287
+ _buildVoiceRow(voiceId, label, level, isLast) {
1288
+ const connectorClass = isLast ? 'connector-last' : 'connector-mid';
1289
+ return `
1290
+ <div class="council-voice ${connectorClass}" data-voice-id="${voiceId}" data-level="${level}">
1291
+ <span class="council-voice-connector ${connectorClass}"></span>
1292
+ <span class="council-voice-icon running"><span class="council-spinner"></span></span>
1293
+ <span class="council-voice-label">${label}</span>
1294
+ <span class="council-voice-status running">Running...</span>
1295
+ <div class="council-voice-detail">
1296
+ <div class="council-voice-snippet" style="display: none;"></div>
1297
+ </div>
1298
+ </div>
1299
+ `;
1300
+ }
1301
+
1302
+ _buildConsolidationSection(config) {
1303
+ // Determine which levels have > 1 voice (need intra-level consolidation)
1304
+ const levelsNeedingConsolidation = [];
1305
+ const enabledLevels = [];
1306
+ for (const [levelKey, levelConfig] of Object.entries(config.levels || {})) {
1307
+ // Support both advanced format (object with .enabled) and voice-centric format (boolean)
1308
+ const isEnabled = typeof levelConfig === 'boolean' ? levelConfig : levelConfig?.enabled;
1309
+ if (!isEnabled) continue;
1310
+ enabledLevels.push(parseInt(levelKey));
1311
+ const voices = (typeof levelConfig === 'object' && levelConfig?.voices) || config.voices || [];
1312
+ if (voices.length > 1) {
1313
+ levelsNeedingConsolidation.push(parseInt(levelKey));
1314
+ }
1315
+ }
1316
+
1317
+ const needsCrossLevel = enabledLevels.length > 1;
1318
+ const hasChildren = levelsNeedingConsolidation.length > 0 || needsCrossLevel;
1319
+
1320
+ if (!hasChildren) {
1321
+ // Single level, single voice — no consolidation needed, but still show the section
1322
+ // as a simple row for the orchestration step
1323
+ return `
1324
+ <div class="council-consolidation council-level">
1325
+ <div class="council-level-header">
1326
+ <span class="council-level-icon pending">\u25CB</span>
1327
+ <span class="council-level-title">Consolidation</span>
1328
+ <span class="council-level-status pending">Pending</span>
1329
+ </div>
1330
+ </div>
1331
+ `;
1332
+ }
1333
+
1334
+ let html = `
1335
+ <div class="council-consolidation council-level">
1336
+ <div class="council-level-header">
1337
+ <span class="council-level-icon pending">\u25CB</span>
1338
+ <span class="council-level-title">Consolidation</span>
1339
+ <span class="council-level-status pending">Pending</span>
1340
+ </div>
1341
+ <div class="council-level-children">
1342
+ `;
1343
+
1344
+ const totalChildren = levelsNeedingConsolidation.length + (needsCrossLevel ? 1 : 0);
1345
+ let childIdx = 0;
1346
+
1347
+ // Intra-level consolidation items
1348
+ for (const levelNum of levelsNeedingConsolidation) {
1349
+ childIdx++;
1350
+ const levelCfg = config.levels[String(levelNum)];
1351
+ const voiceCount = (typeof levelCfg === 'object' && levelCfg?.voices?.length) || (config.voices?.length) || 0;
1352
+ const isLast = childIdx === totalChildren;
1353
+ const connectorClass = isLast ? 'connector-last' : 'connector-mid';
1354
+ html += `
1355
+ <div class="council-voice council-consolidation-child ${connectorClass}" data-consolidation="L${levelNum}">
1356
+ <span class="council-voice-connector ${connectorClass}"></span>
1357
+ <span class="council-voice-icon pending">\u25CB</span>
1358
+ <span class="council-voice-label">Level ${levelNum} (${voiceCount} reviewers)</span>
1359
+ <span class="council-voice-status pending">Pending</span>
1360
+ </div>
1361
+ `;
1362
+ }
1363
+
1364
+ // Cross-level orchestration
1365
+ if (needsCrossLevel) {
1366
+ childIdx++;
1367
+ const isLast = childIdx === totalChildren;
1368
+ const connectorClass = isLast ? 'connector-last' : 'connector-mid';
1369
+ html += `
1370
+ <div class="council-voice council-consolidation-child ${connectorClass}" data-consolidation="orchestration">
1371
+ <span class="council-voice-connector ${connectorClass}"></span>
1372
+ <span class="council-voice-icon pending">\u25CB</span>
1373
+ <span class="council-voice-label">Cross-level consolidation</span>
1374
+ <span class="council-voice-status pending">Pending</span>
1375
+ </div>
1376
+ `;
1377
+ }
1378
+
1379
+ html += `
1380
+ </div>
1381
+ </div>
1382
+ `;
1383
+ return html;
1384
+ }
1385
+
1386
+ // ---------------------------------------------------------------------------
1387
+ // Formatting helpers
1388
+ // ---------------------------------------------------------------------------
1389
+
1390
+ /**
1391
+ * Format a voice config into a display label.
1392
+ * Example: { provider: 'claude', model: 'sonnet-4-5', tier: 'balanced' }
1393
+ * -> "Claude sonnet-4-5 (Balanced)"
1394
+ */
1395
+ _formatVoiceLabel(voice) {
1396
+ const provider = this._capitalize(voice.provider || 'unknown');
1397
+ const model = voice.model || 'default';
1398
+ const tier = this._capitalize(voice.tier || 'balanced');
1399
+ return `${provider} ${model} (${tier})`;
1400
+ }
1401
+
1402
+ _capitalize(str) {
1403
+ if (!str) return '';
1404
+ return str.charAt(0).toUpperCase() + str.slice(1);
1405
+ }
1406
+ }
1407
+
1408
+ // Initialize global instance
1409
+ if (typeof window !== 'undefined' && !window.councilProgressModal) {
1410
+ window.councilProgressModal = new CouncilProgressModal();
1411
+ }
1412
+
1413
+ // Export for testing
1414
+ if (typeof module !== 'undefined' && module.exports) {
1415
+ module.exports = { CouncilProgressModal };
1416
+ }