@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.
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/review-requests/SKILL.md +54 -0
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +1081 -54
- package/public/css/repo-settings.css +452 -140
- package/public/js/components/AdvancedConfigTab.js +1364 -0
- package/public/js/components/AnalysisConfigModal.js +488 -112
- package/public/js/components/CouncilProgressModal.js +1416 -0
- package/public/js/components/TextInputDialog.js +231 -0
- package/public/js/components/TimeoutSelect.js +367 -0
- package/public/js/components/VoiceCentricConfigTab.js +1334 -0
- package/public/js/local.js +162 -83
- package/public/js/modules/analysis-history.js +185 -11
- package/public/js/modules/comment-manager.js +13 -0
- package/public/js/modules/file-comment-manager.js +28 -0
- package/public/js/pr.js +233 -115
- package/public/js/repo-settings.js +575 -106
- package/public/local.html +11 -1
- package/public/pr.html +6 -1
- package/public/repo-settings.html +28 -21
- package/public/setup.html +8 -2
- package/src/ai/analyzer.js +1262 -111
- package/src/ai/claude-cli.js +2 -2
- package/src/ai/claude-provider.js +6 -6
- package/src/ai/codex-provider.js +6 -6
- package/src/ai/copilot-provider.js +3 -3
- package/src/ai/cursor-agent-provider.js +6 -6
- package/src/ai/gemini-provider.js +6 -6
- package/src/ai/opencode-provider.js +6 -6
- package/src/ai/pi-provider.js +6 -6
- package/src/ai/prompts/baseline/consolidation/balanced.js +208 -0
- package/src/ai/prompts/baseline/consolidation/fast.js +175 -0
- package/src/ai/prompts/baseline/consolidation/thorough.js +283 -0
- package/src/ai/prompts/config.js +1 -1
- package/src/ai/prompts/index.js +26 -2
- package/src/ai/provider.js +4 -2
- package/src/database.js +417 -14
- package/src/main.js +1 -1
- package/src/routes/analysis.js +495 -10
- package/src/routes/config.js +36 -15
- package/src/routes/councils.js +351 -0
- package/src/routes/local.js +33 -11
- package/src/routes/mcp.js +9 -2
- package/src/routes/setup.js +12 -2
- package/src/routes/shared.js +126 -13
- package/src/server.js +34 -4
- 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
|
+
}
|