@in-the-loop-labs/pair-review 1.4.4 → 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,1364 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Advanced (Level-Centric) Council Configuration Tab
|
|
4
|
+
*
|
|
5
|
+
* Provides the "Advanced" tab in the AnalysisConfigModal. An advanced council
|
|
6
|
+
* configuration that enables per-level, multi-voice, multi-provider analysis
|
|
7
|
+
* where each review level can have different participants.
|
|
8
|
+
*
|
|
9
|
+
* This was formerly the only council tab ("Review Council"); the simpler
|
|
10
|
+
* voice-centric tab is now the default "Council" tab.
|
|
11
|
+
*/
|
|
12
|
+
class AdvancedConfigTab {
|
|
13
|
+
/** Info circle SVG icon for section tooltips */
|
|
14
|
+
static INFO_ICON_SVG = `<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"/></svg>`;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build an info-tip toggle button
|
|
18
|
+
* @param {string} id - Unique identifier for aria-controls linkage
|
|
19
|
+
* @returns {string} HTML string
|
|
20
|
+
*/
|
|
21
|
+
static buildInfoTipButton(id) {
|
|
22
|
+
return `<button class="info-tip-toggle" aria-controls="info-tip-${id}" aria-expanded="false" title="More info">${AdvancedConfigTab.INFO_ICON_SVG}</button>`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build a hidden info-tip content block
|
|
27
|
+
* @param {string} id - Unique identifier matching the toggle button
|
|
28
|
+
* @param {string} text - Explanation text (may contain HTML)
|
|
29
|
+
* @returns {string} HTML string
|
|
30
|
+
*/
|
|
31
|
+
static buildInfoTipContent(id, text) {
|
|
32
|
+
return `<div class="info-tip-content" id="info-tip-${id}" style="display:none">${text}</div>`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Speech bubble SVG icon (outline) used for per-participant and custom instruction rows */
|
|
36
|
+
static SPEECH_BUBBLE_SVG = `<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.5 0v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25H2.75a.25.25 0 0 0-.25.25Z"/></svg>`;
|
|
37
|
+
|
|
38
|
+
/** Speech bubble SVG icon (solid/filled) — indicates instructions are present */
|
|
39
|
+
static SPEECH_BUBBLE_SVG_SOLID = `<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2.75 1C1.784 1 1 1.784 1 2.75v7.5c0 .966.784 1.75 1.75 1.75H4v1.543a1.458 1.458 0 0 0 2.487 1.03L9.06 12h4.19A1.75 1.75 0 0 0 15 10.25v-7.5A1.75 1.75 0 0 0 13.25 1H2.75Z"/></svg>`;
|
|
40
|
+
|
|
41
|
+
/** Clock SVG icon for per-voice timeout toggle */
|
|
42
|
+
static CLOCK_SVG = `<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm7-3.25v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5a.75.75 0 0 1 1.5 0Z"/></svg>`;
|
|
43
|
+
|
|
44
|
+
/** Default timeout in milliseconds (10 minutes) */
|
|
45
|
+
static DEFAULT_TIMEOUT = 600000;
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
constructor(modal) {
|
|
49
|
+
this.modal = modal;
|
|
50
|
+
this.councils = [];
|
|
51
|
+
this.selectedCouncilId = null;
|
|
52
|
+
this.providers = {};
|
|
53
|
+
this._injected = false;
|
|
54
|
+
this._councilsLoaded = false;
|
|
55
|
+
|
|
56
|
+
// Dirty state tracking
|
|
57
|
+
this._isDirty = false;
|
|
58
|
+
|
|
59
|
+
// Character limit constants for council custom instructions
|
|
60
|
+
this.CHAR_LIMIT = 5000;
|
|
61
|
+
this.CHAR_WARNING_THRESHOLD = 4500;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Inject the advanced council panel into the modal.
|
|
66
|
+
* Called by AnalysisConfigModal after the tab panels are created.
|
|
67
|
+
* @param {HTMLElement} panel - The #tab-panel-advanced element
|
|
68
|
+
*/
|
|
69
|
+
inject(panel) {
|
|
70
|
+
if (this._injected) return;
|
|
71
|
+
if (!panel) return;
|
|
72
|
+
|
|
73
|
+
panel.innerHTML = this._buildCouncilHTML();
|
|
74
|
+
this._mountTimeoutSelects(panel);
|
|
75
|
+
this._setupCouncilListeners(panel);
|
|
76
|
+
this._injected = true;
|
|
77
|
+
|
|
78
|
+
// Initial state: clean, buttons disabled
|
|
79
|
+
this._markClean();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Load providers data (reuses the modal's loaded providers)
|
|
84
|
+
* @param {Object} providers - Provider definitions from AnalysisConfigModal
|
|
85
|
+
*/
|
|
86
|
+
setProviders(providers) {
|
|
87
|
+
this.providers = providers || {};
|
|
88
|
+
if (this._injected) {
|
|
89
|
+
this._updateAllVoiceDropdowns();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Load saved councils from the API
|
|
95
|
+
*/
|
|
96
|
+
async loadCouncils() {
|
|
97
|
+
try {
|
|
98
|
+
const response = await fetch('/api/councils');
|
|
99
|
+
if (!response.ok) throw new Error('Failed to fetch councils');
|
|
100
|
+
const data = await response.json();
|
|
101
|
+
// Only show advanced (level-centric) councils, or councils with no type (legacy)
|
|
102
|
+
this.councils = (data.councils || []).filter(c => !c.type || c.type === 'advanced');
|
|
103
|
+
this._councilsLoaded = true;
|
|
104
|
+
this._renderCouncilSelector();
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.error('Error loading councils:', error);
|
|
107
|
+
this.councils = [];
|
|
108
|
+
if (window.toast) {
|
|
109
|
+
window.toast.showError('Failed to load saved councils');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get the current council config for submission
|
|
116
|
+
* @returns {Object} Council config
|
|
117
|
+
*/
|
|
118
|
+
getCouncilConfig() {
|
|
119
|
+
return this._readConfigFromUI();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Get selected council ID (if using a saved council)
|
|
124
|
+
* @returns {string|null}
|
|
125
|
+
*/
|
|
126
|
+
getSelectedCouncilId() {
|
|
127
|
+
return this.selectedCouncilId;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Set repo instructions in the council tab
|
|
132
|
+
* @param {string} text - Repository instructions text
|
|
133
|
+
*/
|
|
134
|
+
setRepoInstructions(text) {
|
|
135
|
+
const panel = this.modal.querySelector('#tab-panel-advanced');
|
|
136
|
+
if (!panel) return;
|
|
137
|
+
|
|
138
|
+
const banner = panel.querySelector('#council-repo-instructions-banner');
|
|
139
|
+
const repoText = panel.querySelector('#council-repo-instructions-text');
|
|
140
|
+
|
|
141
|
+
if (text) {
|
|
142
|
+
if (banner) banner.style.display = 'flex';
|
|
143
|
+
if (repoText) repoText.textContent = text;
|
|
144
|
+
} else {
|
|
145
|
+
if (banner) banner.style.display = 'none';
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Set last used custom instructions in the council tab
|
|
151
|
+
* @param {string} text - Last used custom instructions
|
|
152
|
+
*/
|
|
153
|
+
setLastInstructions(text) {
|
|
154
|
+
const panel = this.modal.querySelector('#tab-panel-advanced');
|
|
155
|
+
if (!panel) return;
|
|
156
|
+
|
|
157
|
+
const textarea = panel.querySelector('#council-custom-instructions');
|
|
158
|
+
if (textarea) {
|
|
159
|
+
textarea.value = text || '';
|
|
160
|
+
this._updateCouncilCharCount(textarea.value.length);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Set default orchestration provider/model for new councils.
|
|
166
|
+
* Falls back to 'claude'/'sonnet' if not provided.
|
|
167
|
+
* @param {string} provider - Default provider ID (e.g., 'claude', 'gemini')
|
|
168
|
+
* @param {string} model - Default model ID (e.g., 'sonnet', 'opus')
|
|
169
|
+
*/
|
|
170
|
+
setDefaultOrchestration(provider, model) {
|
|
171
|
+
this._defaultProvider = provider || 'claude';
|
|
172
|
+
this._defaultModel = model || 'sonnet';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Set the default council ID to pre-select when councils load.
|
|
177
|
+
* Stores the ID as pending; it will be applied in _renderCouncilSelector().
|
|
178
|
+
* @param {string} councilId - Council ID to pre-select
|
|
179
|
+
*/
|
|
180
|
+
setDefaultCouncilId(councilId) {
|
|
181
|
+
this._pendingDefaultCouncilId = councilId;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Validate council config. At least one level must be enabled.
|
|
186
|
+
* @param {Object} config - Council config to validate
|
|
187
|
+
* @returns {{ valid: boolean, error: string|null }}
|
|
188
|
+
*/
|
|
189
|
+
_validateConfig(config) {
|
|
190
|
+
const hasEnabledLevel = Object.values(config.levels).some(l => l.enabled);
|
|
191
|
+
if (!hasEnabledLevel) {
|
|
192
|
+
return { valid: false, error: 'At least one review level must be enabled.' };
|
|
193
|
+
}
|
|
194
|
+
return { valid: true, error: null };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Validate the current council configuration.
|
|
199
|
+
* Shows a warning toast if invalid.
|
|
200
|
+
* @returns {boolean} true if valid
|
|
201
|
+
*/
|
|
202
|
+
validate() {
|
|
203
|
+
const config = this._readConfigFromUI();
|
|
204
|
+
const result = this._validateConfig(config);
|
|
205
|
+
if (!result.valid && window.toast) {
|
|
206
|
+
window.toast.showWarning(result.error);
|
|
207
|
+
}
|
|
208
|
+
return result.valid;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Auto-save council if there are unsaved changes.
|
|
213
|
+
* Called before analysis starts. Errors are caught and logged, never block analysis.
|
|
214
|
+
* Always saves unsaved councils so the config is persisted for history/reuse.
|
|
215
|
+
* @returns {Promise<void>}
|
|
216
|
+
*/
|
|
217
|
+
async autoSaveIfDirty() {
|
|
218
|
+
// Skip saving when the council is clean AND already persisted (has an ID).
|
|
219
|
+
// Unsaved councils (no selectedCouncilId) always proceed so the config is persisted.
|
|
220
|
+
if (!this._isDirty && this.selectedCouncilId) return;
|
|
221
|
+
|
|
222
|
+
const config = this._readConfigFromUI();
|
|
223
|
+
const { valid } = this._validateConfig(config);
|
|
224
|
+
if (!valid) return; // Don't auto-save invalid configs
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const timestamp = this._formatTimestamp(new Date());
|
|
228
|
+
|
|
229
|
+
let name;
|
|
230
|
+
if (this.selectedCouncilId) {
|
|
231
|
+
// Fork: create new council based on existing, don't mutate the original
|
|
232
|
+
const existing = this.councils.find(c => c.id === this.selectedCouncilId);
|
|
233
|
+
const baseName = (existing?.name || 'Config').replace(/\s*\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}$/, '').trim();
|
|
234
|
+
name = `${baseName} ${timestamp}`;
|
|
235
|
+
} else {
|
|
236
|
+
name = `Config ${timestamp}`;
|
|
237
|
+
}
|
|
238
|
+
await this._postCouncil(name, config);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
console.error('Auto-save council failed (non-blocking):', error);
|
|
241
|
+
if (window.toast) {
|
|
242
|
+
window.toast.showWarning('Council auto-save failed');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// --- Private methods ---
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Format a Date as "YYYY-MM-DD HH:MM" for council naming.
|
|
251
|
+
* @param {Date} date
|
|
252
|
+
* @returns {string}
|
|
253
|
+
*/
|
|
254
|
+
_formatTimestamp(date) {
|
|
255
|
+
const pad = n => String(n).padStart(2, '0');
|
|
256
|
+
return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Mount all TimeoutSelect instances on the panel.
|
|
261
|
+
* Called after HTML is injected into the DOM.
|
|
262
|
+
* @param {HTMLElement} panel
|
|
263
|
+
*/
|
|
264
|
+
_mountTimeoutSelects(panel) {
|
|
265
|
+
// Orchestration timeout (mounted first so its mount-point is removed from the DOM)
|
|
266
|
+
const orchMount = panel.querySelector('#adv-orchestration-timeout-mount');
|
|
267
|
+
if (orchMount) {
|
|
268
|
+
TimeoutSelect.mount(orchMount, {
|
|
269
|
+
className: 'adv-timeout',
|
|
270
|
+
id: 'adv-orchestration-timeout',
|
|
271
|
+
title: 'Orchestration timeout',
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Per-reviewer timeouts (any that exist from default config).
|
|
276
|
+
// The orchestration mount is already removed above, so no exclusion needed.
|
|
277
|
+
panel.querySelectorAll('.adv-timeout-mount').forEach(mount => {
|
|
278
|
+
TimeoutSelect.mount(mount, {
|
|
279
|
+
className: 'adv-timeout',
|
|
280
|
+
title: 'Per-reviewer timeout',
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
_defaultConfig() {
|
|
286
|
+
return {
|
|
287
|
+
levels: {
|
|
288
|
+
'1': { enabled: true, voices: [] },
|
|
289
|
+
'2': { enabled: true, voices: [] },
|
|
290
|
+
'3': { enabled: true, voices: [] }
|
|
291
|
+
},
|
|
292
|
+
consolidation: { provider: this._defaultProvider || 'claude', model: this._defaultModel || 'sonnet', tier: 'balanced', timeout: AdvancedConfigTab.DEFAULT_TIMEOUT }
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
_buildCouncilHTML() {
|
|
297
|
+
return `
|
|
298
|
+
<section class="config-section">
|
|
299
|
+
<h4 class="section-title">Council ${AdvancedConfigTab.buildInfoTipButton('council')}</h4>
|
|
300
|
+
${AdvancedConfigTab.buildInfoTipContent('council', 'An advanced council configuration that runs your code review through multiple AI models in parallel at each review level, then consolidates their findings. Different models catch different issues, giving you broader coverage than a single reviewer.')}
|
|
301
|
+
<div class="council-selector-row">
|
|
302
|
+
<select id="council-selector" class="council-select new-council-selected">
|
|
303
|
+
<option value="" class="council-option-new">+ New Council</option>
|
|
304
|
+
</select>
|
|
305
|
+
<button class="btn btn-sm btn-secondary" id="council-save-btn" title="Save" disabled>Save</button>
|
|
306
|
+
<button class="btn btn-sm btn-secondary" id="council-save-as-btn" title="Save As" disabled>Save As</button>
|
|
307
|
+
<button class="btn btn-sm btn-secondary" id="council-export-btn" title="Copy config JSON to clipboard">Export</button>
|
|
308
|
+
<button class="btn btn-sm btn-icon-danger" id="council-delete-btn" title="Delete council" disabled>
|
|
309
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
310
|
+
<path d="M11 1.75V3h2.25a.75.75 0 010 1.5H2.75a.75.75 0 010-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM6.5 1.75a.25.25 0 01.25-.25h2.5a.25.25 0 01.25.25V3h-3V1.75zM4.496 6.675l.66 6.6a.25.25 0 00.249.225h5.19a.25.25 0 00.249-.225l.66-6.6a.75.75 0 011.492.149l-.66 6.6A1.748 1.748 0 0110.595 15h-5.19a1.75 1.75 0 01-1.741-1.575l-.66-6.6a.75.75 0 111.492-.15z"/>
|
|
311
|
+
</svg>
|
|
312
|
+
</button>
|
|
313
|
+
</div>
|
|
314
|
+
</section>
|
|
315
|
+
|
|
316
|
+
${this._buildLevelSection(1, 'Changes in Isolation', true)}
|
|
317
|
+
${this._buildLevelSection(2, 'File Context', true)}
|
|
318
|
+
${this._buildLevelSection(3, 'Codebase Context', true)}
|
|
319
|
+
|
|
320
|
+
<section class="config-section">
|
|
321
|
+
<h4 class="section-title">Consolidation ${AdvancedConfigTab.buildInfoTipButton('orchestration')}</h4>
|
|
322
|
+
${AdvancedConfigTab.buildInfoTipContent('orchestration', 'The consolidation model merges findings from all reviewers into a single coherent review.')}
|
|
323
|
+
<p class="section-hint-text">Model used for consolidation passes</p>
|
|
324
|
+
<div class="participant-wrapper consolidation-wrapper" id="adv-orchestration-card">
|
|
325
|
+
<div class="participant-card">
|
|
326
|
+
<div class="voice-row" id="orchestration-voice">
|
|
327
|
+
<select class="voice-provider" data-target="orchestration"></select>
|
|
328
|
+
<select class="voice-model" data-target="orchestration"></select>
|
|
329
|
+
<select class="voice-tier" data-target="orchestration">
|
|
330
|
+
<option value="fast">Fast</option>
|
|
331
|
+
<option value="balanced" selected>Balanced</option>
|
|
332
|
+
<option value="thorough">Thorough</option>
|
|
333
|
+
</select>
|
|
334
|
+
<span class="adv-timeout-mount" id="adv-orchestration-timeout-mount"></span>
|
|
335
|
+
<button class="toggle-timeout-icon" id="adv-orchestration-timeout-toggle" title="Orchestration timeout">${AdvancedConfigTab.CLOCK_SVG}</button>
|
|
336
|
+
<button class="toggle-instructions-icon" id="adv-orchestration-instructions-toggle" title="Orchestration instructions">${AdvancedConfigTab.SPEECH_BUBBLE_SVG}</button>
|
|
337
|
+
</div>
|
|
338
|
+
<div class="voice-instructions-area" id="adv-orchestration-instructions-area" style="display:none">
|
|
339
|
+
<textarea class="voice-instructions-input" id="adv-orchestration-instructions" placeholder="Orchestration instructions (e.g., Prefer security findings over style nits)" rows="2"></textarea>
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
<div class="remove-voice-btn-spacer"></div>
|
|
343
|
+
</div>
|
|
344
|
+
</section>
|
|
345
|
+
|
|
346
|
+
${this._buildInstructionsHTML()}
|
|
347
|
+
`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Build the level section with slider toggle instead of checkbox
|
|
352
|
+
*/
|
|
353
|
+
_buildLevelSection(level, description, enabledByDefault) {
|
|
354
|
+
const levelTips = {
|
|
355
|
+
1: 'Analyzes only the changed lines themselves. Catches bugs, typos, and logic errors in the diff without needing surrounding context.',
|
|
356
|
+
2: 'Analyzes changes within their full file context. Catches inconsistencies with nearby code, naming conventions, and patterns within the same file.',
|
|
357
|
+
3: 'Analyzes changes against the broader codebase. Catches architectural issues, duplicated logic elsewhere, and violations of project-wide conventions.'
|
|
358
|
+
};
|
|
359
|
+
return `
|
|
360
|
+
<section class="config-section council-level-section" data-level="${level}">
|
|
361
|
+
<h4 class="section-title">
|
|
362
|
+
<label class="remember-toggle level-toggle">
|
|
363
|
+
<input type="checkbox" class="level-checkbox" data-level="${level}" ${enabledByDefault ? 'checked' : ''} />
|
|
364
|
+
<span class="toggle-switch"></span>
|
|
365
|
+
<span class="toggle-label">Level ${level} — ${description}</span>
|
|
366
|
+
</label>
|
|
367
|
+
${AdvancedConfigTab.buildInfoTipButton('level-' + level)}
|
|
368
|
+
</h4>
|
|
369
|
+
${AdvancedConfigTab.buildInfoTipContent('level-' + level, levelTips[level])}
|
|
370
|
+
<div class="level-voices" id="level-${level}-voices" ${!enabledByDefault ? 'style="display:none"' : ''}>
|
|
371
|
+
<div class="voice-list" id="level-${level}-voice-list">
|
|
372
|
+
${enabledByDefault ? this._buildVoiceRowHTML(level, 0) : ''}
|
|
373
|
+
</div>
|
|
374
|
+
<button class="btn btn-sm btn-icon add-voice-btn" data-level="${level}" title="Add Reviewer">+</button>
|
|
375
|
+
</div>
|
|
376
|
+
</section>
|
|
377
|
+
`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Build a single participant row with card container layout.
|
|
382
|
+
* Includes a clock icon that toggles an inline timeout dropdown.
|
|
383
|
+
*/
|
|
384
|
+
_buildVoiceRowHTML(level, index) {
|
|
385
|
+
return `
|
|
386
|
+
<div class="participant-wrapper" data-level="${level}" data-index="${index}">
|
|
387
|
+
<div class="participant-card">
|
|
388
|
+
<div class="voice-row" data-level="${level}" data-index="${index}">
|
|
389
|
+
<select class="voice-provider" data-level="${level}" data-index="${index}"></select>
|
|
390
|
+
<select class="voice-model" data-level="${level}" data-index="${index}"></select>
|
|
391
|
+
<select class="voice-tier" data-level="${level}" data-index="${index}">
|
|
392
|
+
<option value="fast">Fast</option>
|
|
393
|
+
<option value="balanced" selected>Balanced</option>
|
|
394
|
+
<option value="thorough">Thorough</option>
|
|
395
|
+
</select>
|
|
396
|
+
<span class="adv-timeout-mount" data-level="${level}" data-index="${index}"></span>
|
|
397
|
+
<button class="toggle-timeout-icon" data-level="${level}" data-index="${index}" title="Per-reviewer timeout">${AdvancedConfigTab.CLOCK_SVG}</button>
|
|
398
|
+
<button class="toggle-instructions-icon" data-level="${level}" data-index="${index}" title="Per-reviewer instructions">${AdvancedConfigTab.SPEECH_BUBBLE_SVG}</button>
|
|
399
|
+
</div>
|
|
400
|
+
<div class="voice-instructions-area" data-level="${level}" data-index="${index}" style="display:none">
|
|
401
|
+
<textarea class="voice-instructions-input" data-level="${level}" data-index="${index}" placeholder="Per-reviewer instructions (e.g., Focus on security)" rows="2"></textarea>
|
|
402
|
+
</div>
|
|
403
|
+
</div>
|
|
404
|
+
<button class="btn btn-sm btn-icon remove-voice-btn" data-level="${level}" data-index="${index}" title="Remove Reviewer">−</button>
|
|
405
|
+
</div>
|
|
406
|
+
`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Build the Custom Instructions + Repo Instructions section for council tab
|
|
411
|
+
*/
|
|
412
|
+
_buildInstructionsHTML() {
|
|
413
|
+
return `
|
|
414
|
+
<div class="council-review-divider">
|
|
415
|
+
<span class="divider-label">This Review</span>
|
|
416
|
+
</div>
|
|
417
|
+
<section class="config-section">
|
|
418
|
+
<h4 class="section-title">
|
|
419
|
+
Custom Instructions
|
|
420
|
+
<span class="section-hint">(optional)</span>
|
|
421
|
+
${AdvancedConfigTab.buildInfoTipButton('custom-instructions')}
|
|
422
|
+
</h4>
|
|
423
|
+
${AdvancedConfigTab.buildInfoTipContent('custom-instructions', 'Free-form guidance sent to every reviewer in this review. Use this to focus the review on what matters most — e.g., "Pay extra attention to error handling" or "This is a security-critical change."')}
|
|
424
|
+
<div class="instructions-container">
|
|
425
|
+
<div class="repo-instructions-banner" id="council-repo-instructions-banner" style="display: none;">
|
|
426
|
+
<div class="banner-icon">
|
|
427
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
|
428
|
+
<path d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1h-8a1 1 0 00-1 1v6.708A2.486 2.486 0 014.5 9h8V1.5zM5 12.25v3.25a.25.25 0 00.4.2l1.45-1.087a.25.25 0 01.3 0L8.6 15.7a.25.25 0 00.4-.2v-3.25a.25.25 0 00-.25-.25h-3.5a.25.25 0 00-.25.25z"/>
|
|
429
|
+
</svg>
|
|
430
|
+
</div>
|
|
431
|
+
<div class="banner-content">
|
|
432
|
+
<span class="banner-label">Repository default instructions active</span>
|
|
433
|
+
<button class="banner-toggle" id="council-toggle-repo-instructions" title="Show repository instructions">
|
|
434
|
+
<span>View</span>
|
|
435
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
|
|
436
|
+
<path d="M6.22 3.22a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 010-1.06z"/>
|
|
437
|
+
</svg>
|
|
438
|
+
</button>
|
|
439
|
+
</div>
|
|
440
|
+
</div>
|
|
441
|
+
<div class="repo-instructions-expanded" id="council-repo-instructions-expanded" style="display: none;">
|
|
442
|
+
<div class="expanded-header">
|
|
443
|
+
<span>Repository Instructions</span>
|
|
444
|
+
<button class="collapse-btn" id="council-collapse-repo-instructions" title="Collapse">
|
|
445
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
|
|
446
|
+
<path d="M3.72 8.72a.75.75 0 011.06 0L8 11.94l3.22-3.22a.75.75 0 111.06 1.06l-4.25 4.25a.75.75 0 01-1.06 0L3.72 9.78a.75.75 0 010-1.06z"/>
|
|
447
|
+
</svg>
|
|
448
|
+
</button>
|
|
449
|
+
</div>
|
|
450
|
+
<div class="expanded-content" id="council-repo-instructions-text"></div>
|
|
451
|
+
</div>
|
|
452
|
+
<textarea
|
|
453
|
+
id="council-custom-instructions"
|
|
454
|
+
class="instructions-textarea"
|
|
455
|
+
data-no-dirty
|
|
456
|
+
placeholder="Add specific guidance for this review... Examples: • Pay extra attention to the authentication logic • Check for proper error handling in the API calls • This is a performance-critical section"
|
|
457
|
+
rows="4"
|
|
458
|
+
></textarea>
|
|
459
|
+
<div class="instructions-footer">
|
|
460
|
+
<span class="char-count" id="council-char-count-container">
|
|
461
|
+
<span id="council-char-count">0</span> / 5,000 characters
|
|
462
|
+
</span>
|
|
463
|
+
</div>
|
|
464
|
+
</div>
|
|
465
|
+
</section>
|
|
466
|
+
`;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
_setupCouncilListeners(panel) {
|
|
470
|
+
// Council selector
|
|
471
|
+
panel.querySelector('#council-selector')?.addEventListener('change', (e) => {
|
|
472
|
+
this.selectedCouncilId = e.target.value || null;
|
|
473
|
+
e.target.classList.toggle('new-council-selected', !this.selectedCouncilId);
|
|
474
|
+
if (this.selectedCouncilId) {
|
|
475
|
+
const council = this.councils.find(c => c.id === this.selectedCouncilId);
|
|
476
|
+
if (council) {
|
|
477
|
+
this._applyConfigToUI(council.config);
|
|
478
|
+
this._markClean();
|
|
479
|
+
}
|
|
480
|
+
} else {
|
|
481
|
+
// "New Council" selected — reset UI to blank defaults
|
|
482
|
+
this._applyConfigToUI(this._defaultConfig());
|
|
483
|
+
this._markDirty();
|
|
484
|
+
}
|
|
485
|
+
this._updateSaveButtonStates();
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// Save button
|
|
489
|
+
panel.querySelector('#council-save-btn')?.addEventListener('click', () => this._saveCouncil());
|
|
490
|
+
|
|
491
|
+
// Save As button
|
|
492
|
+
panel.querySelector('#council-save-as-btn')?.addEventListener('click', () => this._saveCouncilAs());
|
|
493
|
+
|
|
494
|
+
// Export button
|
|
495
|
+
panel.querySelector('#council-export-btn')?.addEventListener('click', () => this._exportCouncil());
|
|
496
|
+
|
|
497
|
+
// Delete button
|
|
498
|
+
panel.querySelector('#council-delete-btn')?.addEventListener('click', () => this._deleteCouncil());
|
|
499
|
+
|
|
500
|
+
// Footer save button (lives in modal footer, not council panel)
|
|
501
|
+
this.modal.querySelector('#council-footer-save-btn')?.addEventListener('click', () => {
|
|
502
|
+
if (this.selectedCouncilId) {
|
|
503
|
+
this._saveCouncil();
|
|
504
|
+
} else {
|
|
505
|
+
this._saveCouncilAs();
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// Level toggles (slider toggles that still use .level-checkbox class)
|
|
510
|
+
panel.querySelectorAll('.level-checkbox').forEach(checkbox => {
|
|
511
|
+
checkbox.addEventListener('change', (e) => {
|
|
512
|
+
const level = e.target.dataset.level;
|
|
513
|
+
const voicesContainer = panel.querySelector(`#level-${level}-voices`);
|
|
514
|
+
if (voicesContainer) {
|
|
515
|
+
voicesContainer.style.display = e.target.checked ? '' : 'none';
|
|
516
|
+
}
|
|
517
|
+
// Add a default voice if enabling a level with no voices
|
|
518
|
+
if (e.target.checked) {
|
|
519
|
+
const voiceList = panel.querySelector(`#level-${level}-voice-list`);
|
|
520
|
+
if (voiceList && voiceList.children.length === 0) {
|
|
521
|
+
this._addVoice(level);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
this._markDirty();
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
// Add voice buttons
|
|
529
|
+
panel.querySelectorAll('.add-voice-btn').forEach(btn => {
|
|
530
|
+
btn.addEventListener('click', () => this._addVoice(btn.dataset.level));
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
// Delegate remove voice, toggle instructions icon, and toggle timeout icon
|
|
534
|
+
panel.addEventListener('click', (e) => {
|
|
535
|
+
const removeBtn = e.target.closest('.remove-voice-btn');
|
|
536
|
+
if (removeBtn) {
|
|
537
|
+
this._removeVoice(removeBtn.dataset.level, removeBtn.dataset.index);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const toggleBtn = e.target.closest('.toggle-instructions-icon');
|
|
541
|
+
if (toggleBtn) {
|
|
542
|
+
// Orchestration instructions toggle (no data-level)
|
|
543
|
+
if (toggleBtn.id === 'adv-orchestration-instructions-toggle') {
|
|
544
|
+
const area = panel.querySelector('#adv-orchestration-instructions-area');
|
|
545
|
+
if (area) {
|
|
546
|
+
const isHidden = area.style.display === 'none';
|
|
547
|
+
area.style.display = isHidden ? '' : 'none';
|
|
548
|
+
if (isHidden) {
|
|
549
|
+
const textarea = area.querySelector('#adv-orchestration-instructions');
|
|
550
|
+
if (textarea) textarea.focus();
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
} else {
|
|
554
|
+
const { level, index } = toggleBtn.dataset;
|
|
555
|
+
const wrapper = panel.querySelector(`.participant-wrapper[data-level="${level}"][data-index="${index}"]`);
|
|
556
|
+
const area = wrapper?.querySelector(`.voice-instructions-area[data-level="${level}"][data-index="${index}"]`);
|
|
557
|
+
if (area) {
|
|
558
|
+
const isHidden = area.style.display === 'none';
|
|
559
|
+
area.style.display = isHidden ? '' : 'none';
|
|
560
|
+
// Focus textarea when opening
|
|
561
|
+
if (isHidden) {
|
|
562
|
+
const textarea = area.querySelector('.voice-instructions-input');
|
|
563
|
+
if (textarea) textarea.focus();
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Toggle timeout dropdown via clock icon
|
|
570
|
+
const clockBtn = e.target.closest('.toggle-timeout-icon');
|
|
571
|
+
if (clockBtn) {
|
|
572
|
+
// Orchestration timeout toggle (no data-level)
|
|
573
|
+
if (clockBtn.id === 'adv-orchestration-timeout-toggle') {
|
|
574
|
+
const timeoutEl = panel.querySelector('#adv-orchestration-timeout');
|
|
575
|
+
if (timeoutEl) {
|
|
576
|
+
const isHidden = timeoutEl.style.display === 'none';
|
|
577
|
+
timeoutEl.style.display = isHidden ? '' : 'none';
|
|
578
|
+
}
|
|
579
|
+
} else {
|
|
580
|
+
const { level, index } = clockBtn.dataset;
|
|
581
|
+
const wrapper = panel.querySelector(`.participant-wrapper[data-level="${level}"][data-index="${index}"]`);
|
|
582
|
+
const timeoutEl = wrapper?.querySelector(`.adv-timeout[data-level="${level}"][data-index="${index}"]`);
|
|
583
|
+
if (timeoutEl) {
|
|
584
|
+
const isHidden = timeoutEl.style.display === 'none';
|
|
585
|
+
timeoutEl.style.display = isHidden ? '' : 'none';
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Info-tip toggle (section help icons)
|
|
591
|
+
const infoBtn = e.target.closest('.info-tip-toggle');
|
|
592
|
+
if (infoBtn) {
|
|
593
|
+
const targetId = infoBtn.getAttribute('aria-controls');
|
|
594
|
+
const content = panel.querySelector(`#${targetId}`);
|
|
595
|
+
if (content) {
|
|
596
|
+
const isHidden = content.style.display === 'none';
|
|
597
|
+
content.style.display = isHidden ? '' : 'none';
|
|
598
|
+
infoBtn.setAttribute('aria-expanded', isHidden ? 'true' : 'false');
|
|
599
|
+
infoBtn.classList.toggle('active', isHidden);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
// Update speech bubble icon (outline vs solid) based on textarea content
|
|
605
|
+
panel.addEventListener('input', (e) => {
|
|
606
|
+
if (e.target.classList.contains('voice-instructions-input')) {
|
|
607
|
+
// Orchestration instructions textarea
|
|
608
|
+
if (e.target.id === 'adv-orchestration-instructions') {
|
|
609
|
+
this._updateOrchestrationInstructionsIcon(panel, e.target.value);
|
|
610
|
+
} else {
|
|
611
|
+
const { level, index } = e.target.dataset;
|
|
612
|
+
this._updateInstructionsIcon(panel, level, index, e.target.value);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
// Provider change -> update model dropdowns
|
|
618
|
+
panel.addEventListener('change', (e) => {
|
|
619
|
+
if (e.target.classList.contains('voice-provider')) {
|
|
620
|
+
this._updateModelDropdown(e.target);
|
|
621
|
+
}
|
|
622
|
+
// Model change -> update tier to match model's recommended tier
|
|
623
|
+
if (e.target.classList.contains('voice-model')) {
|
|
624
|
+
this._syncTierToModel(e.target);
|
|
625
|
+
}
|
|
626
|
+
// Timeout change -> update clock icon styling
|
|
627
|
+
if (e.target.classList.contains('adv-timeout')) {
|
|
628
|
+
// Orchestration timeout (no data-level)
|
|
629
|
+
if (e.target.id === 'adv-orchestration-timeout') {
|
|
630
|
+
this._updateOrchestrationTimeoutIcon(panel, e.target.value);
|
|
631
|
+
} else {
|
|
632
|
+
const { level, index } = e.target.dataset;
|
|
633
|
+
this._updateTimeoutIcon(panel, level, index, e.target.value);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
// Dirty state tracking via event delegation
|
|
639
|
+
panel.addEventListener('change', (e) => {
|
|
640
|
+
if (e.target.matches('select, input[type="checkbox"]') || e.target.classList.contains('adv-timeout')) {
|
|
641
|
+
this._markDirty();
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
panel.addEventListener('input', (e) => {
|
|
645
|
+
// Mark dirty for per-participant instruction textareas (part of council config),
|
|
646
|
+
// but NOT textareas with data-no-dirty (e.g., per-request custom instructions)
|
|
647
|
+
if (e.target.matches('textarea') && !('noDirty' in e.target.dataset)) {
|
|
648
|
+
this._markDirty();
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
// Council custom instructions character count
|
|
653
|
+
const councilTextarea = panel.querySelector('#council-custom-instructions');
|
|
654
|
+
councilTextarea?.addEventListener('input', () => {
|
|
655
|
+
this._updateCouncilCharCount(councilTextarea.value.length);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
// Council repo instructions toggle
|
|
659
|
+
panel.querySelector('#council-toggle-repo-instructions')?.addEventListener('click', () => {
|
|
660
|
+
panel.querySelector('#council-repo-instructions-banner').style.display = 'none';
|
|
661
|
+
panel.querySelector('#council-repo-instructions-expanded').style.display = 'block';
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
panel.querySelector('#council-collapse-repo-instructions')?.addEventListener('click', () => {
|
|
665
|
+
panel.querySelector('#council-repo-instructions-banner').style.display = 'flex';
|
|
666
|
+
panel.querySelector('#council-repo-instructions-expanded').style.display = 'none';
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
_renderCouncilSelector() {
|
|
671
|
+
const selector = this.modal.querySelector('#council-selector');
|
|
672
|
+
if (!selector) return;
|
|
673
|
+
|
|
674
|
+
const currentValue = selector.value;
|
|
675
|
+
selector.innerHTML = '<option value="" class="council-option-new">+ New Council</option>';
|
|
676
|
+
for (const council of this.councils) {
|
|
677
|
+
const opt = document.createElement('option');
|
|
678
|
+
opt.value = council.id;
|
|
679
|
+
opt.textContent = council.name;
|
|
680
|
+
selector.appendChild(opt);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Apply pending default council ID if set (from last-used or repo default)
|
|
684
|
+
if (this._pendingDefaultCouncilId) {
|
|
685
|
+
const pendingId = this._pendingDefaultCouncilId;
|
|
686
|
+
this._pendingDefaultCouncilId = null;
|
|
687
|
+
|
|
688
|
+
// Only apply if the council exists in the loaded list (handles deleted councils gracefully)
|
|
689
|
+
const council = this.councils.find(c => c.id === pendingId);
|
|
690
|
+
if (council) {
|
|
691
|
+
selector.value = pendingId;
|
|
692
|
+
this.selectedCouncilId = pendingId;
|
|
693
|
+
selector.classList.remove('new-council-selected');
|
|
694
|
+
this._applyConfigToUI(council.config);
|
|
695
|
+
this._markClean();
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (currentValue) selector.value = currentValue;
|
|
701
|
+
selector.classList.toggle('new-council-selected', !selector.value);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
_updateAllVoiceDropdowns() {
|
|
705
|
+
const panel = this.modal.querySelector('#tab-panel-advanced');
|
|
706
|
+
if (!panel) return;
|
|
707
|
+
|
|
708
|
+
panel.querySelectorAll('.voice-provider').forEach(select => {
|
|
709
|
+
this._populateProviderDropdown(select);
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
_populateProviderDropdown(select) {
|
|
714
|
+
const currentValue = select.value;
|
|
715
|
+
select.innerHTML = '';
|
|
716
|
+
const providerIds = Object.keys(this.providers).filter(id => {
|
|
717
|
+
const p = this.providers[id];
|
|
718
|
+
return !p.availability || p.availability.available;
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
for (const id of providerIds) {
|
|
722
|
+
const opt = document.createElement('option');
|
|
723
|
+
opt.value = id;
|
|
724
|
+
opt.textContent = this.providers[id].name;
|
|
725
|
+
select.appendChild(opt);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (currentValue && providerIds.includes(currentValue)) {
|
|
729
|
+
select.value = currentValue;
|
|
730
|
+
} else if (providerIds.length > 0) {
|
|
731
|
+
select.value = providerIds[0];
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
this._updateModelDropdown(select);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
_updateModelDropdown(providerSelect) {
|
|
738
|
+
const providerId = providerSelect.value;
|
|
739
|
+
const provider = this.providers[providerId];
|
|
740
|
+
if (!provider) return;
|
|
741
|
+
|
|
742
|
+
// Find sibling model select
|
|
743
|
+
const container = providerSelect.closest('.voice-row');
|
|
744
|
+
const modelSelect = container?.querySelector('.voice-model');
|
|
745
|
+
if (!modelSelect) return;
|
|
746
|
+
|
|
747
|
+
const currentModel = modelSelect.value;
|
|
748
|
+
const models = provider.models || [];
|
|
749
|
+
modelSelect.innerHTML = '';
|
|
750
|
+
for (const model of models) {
|
|
751
|
+
const opt = document.createElement('option');
|
|
752
|
+
opt.value = model.id;
|
|
753
|
+
opt.textContent = model.name;
|
|
754
|
+
opt.dataset.tier = model.tier;
|
|
755
|
+
modelSelect.appendChild(opt);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Try to preserve current selection or use default
|
|
759
|
+
if (currentModel && models.some(m => m.id === currentModel)) {
|
|
760
|
+
modelSelect.value = currentModel;
|
|
761
|
+
} else {
|
|
762
|
+
const defaultModel = models.find(m => m.default) || models[0];
|
|
763
|
+
if (defaultModel) modelSelect.value = defaultModel.id;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Auto-set tier based on model
|
|
767
|
+
const tierSelect = container?.querySelector('.voice-tier');
|
|
768
|
+
if (tierSelect) {
|
|
769
|
+
const selectedModel = models.find(m => m.id === modelSelect.value);
|
|
770
|
+
if (selectedModel) tierSelect.value = selectedModel.tier || 'balanced';
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Sync the tier dropdown to the selected model's recommended tier.
|
|
776
|
+
* Called when the user manually changes the model dropdown.
|
|
777
|
+
* @param {HTMLSelectElement} modelSelect - The model dropdown that changed
|
|
778
|
+
*/
|
|
779
|
+
_syncTierToModel(modelSelect) {
|
|
780
|
+
const container = modelSelect.closest('.voice-row');
|
|
781
|
+
const tierSelect = container?.querySelector('.voice-tier');
|
|
782
|
+
if (!tierSelect) return;
|
|
783
|
+
|
|
784
|
+
const selectedOption = modelSelect.options[modelSelect.selectedIndex];
|
|
785
|
+
const tier = selectedOption?.dataset?.tier;
|
|
786
|
+
if (tier) {
|
|
787
|
+
tierSelect.value = tier;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
_addVoice(level) {
|
|
792
|
+
const voiceList = this.modal.querySelector(`#level-${level}-voice-list`);
|
|
793
|
+
if (!voiceList) return;
|
|
794
|
+
|
|
795
|
+
// Count existing participant wrappers
|
|
796
|
+
const existingWrappers = voiceList.querySelectorAll(`.participant-wrapper[data-level="${level}"]`);
|
|
797
|
+
const index = existingWrappers.length;
|
|
798
|
+
|
|
799
|
+
const wrapper = document.createElement('div');
|
|
800
|
+
wrapper.innerHTML = this._buildVoiceRowHTML(level, index);
|
|
801
|
+
// The _buildVoiceRowHTML returns a single .participant-wrapper, append it
|
|
802
|
+
while (wrapper.firstChild) {
|
|
803
|
+
voiceList.appendChild(wrapper.firstChild);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Mount the TimeoutSelect for the new voice
|
|
807
|
+
const mount = voiceList.querySelector(`.adv-timeout-mount[data-level="${level}"][data-index="${index}"]`);
|
|
808
|
+
if (mount) {
|
|
809
|
+
TimeoutSelect.mount(mount, { className: 'adv-timeout', title: 'Per-reviewer timeout' });
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Populate the new provider dropdown
|
|
813
|
+
const newProviderSelect = voiceList.querySelector(`.voice-provider[data-level="${level}"][data-index="${index}"]`);
|
|
814
|
+
if (newProviderSelect) {
|
|
815
|
+
this._populateProviderDropdown(newProviderSelect);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Update remove button visibility for this level
|
|
819
|
+
this._updateRemoveButtonVisibility(level);
|
|
820
|
+
|
|
821
|
+
// Mark dirty
|
|
822
|
+
this._markDirty();
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
_removeVoice(level, index) {
|
|
826
|
+
const voiceList = this.modal.querySelector(`#level-${level}-voice-list`);
|
|
827
|
+
if (!voiceList) return;
|
|
828
|
+
|
|
829
|
+
// Don't remove if it's the last voice
|
|
830
|
+
const wrappers = voiceList.querySelectorAll(`.participant-wrapper[data-level="${level}"]`);
|
|
831
|
+
if (wrappers.length <= 1) return;
|
|
832
|
+
|
|
833
|
+
// Remove the participant wrapper (card + remove button)
|
|
834
|
+
const wrapper = voiceList.querySelector(`.participant-wrapper[data-level="${level}"][data-index="${index}"]`);
|
|
835
|
+
if (wrapper) wrapper.remove();
|
|
836
|
+
|
|
837
|
+
// Re-index remaining voices so indices are sequential starting from 0
|
|
838
|
+
this._reindexVoices(level);
|
|
839
|
+
|
|
840
|
+
// Update remove button visibility for this level
|
|
841
|
+
this._updateRemoveButtonVisibility(level);
|
|
842
|
+
|
|
843
|
+
// Mark dirty
|
|
844
|
+
this._markDirty();
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
_reindexVoices(level) {
|
|
848
|
+
const voiceList = this.modal.querySelector(`#level-${level}-voice-list`);
|
|
849
|
+
if (!voiceList) return;
|
|
850
|
+
|
|
851
|
+
const wrappers = voiceList.querySelectorAll(`.participant-wrapper[data-level="${level}"]`);
|
|
852
|
+
wrappers.forEach((wrapper, newIndex) => {
|
|
853
|
+
const oldIndex = wrapper.dataset.index;
|
|
854
|
+
if (String(newIndex) === oldIndex) return;
|
|
855
|
+
|
|
856
|
+
// Update the wrapper itself
|
|
857
|
+
wrapper.dataset.index = newIndex;
|
|
858
|
+
|
|
859
|
+
// Update all child elements with data-index within this wrapper
|
|
860
|
+
wrapper.querySelectorAll('[data-index]').forEach(el => {
|
|
861
|
+
el.dataset.index = newIndex;
|
|
862
|
+
});
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Update remove button visibility - hide when only 1 participant in level.
|
|
868
|
+
* Uses visibility: hidden to preserve layout
|
|
869
|
+
*/
|
|
870
|
+
_updateRemoveButtonVisibility(level) {
|
|
871
|
+
const voiceList = this.modal.querySelector(`#level-${level}-voice-list`);
|
|
872
|
+
if (!voiceList) return;
|
|
873
|
+
|
|
874
|
+
const wrappers = voiceList.querySelectorAll(`.participant-wrapper[data-level="${level}"]`);
|
|
875
|
+
const singleParticipant = wrappers.length <= 1;
|
|
876
|
+
|
|
877
|
+
wrappers.forEach(wrapper => {
|
|
878
|
+
const removeBtn = wrapper.querySelector('.remove-voice-btn');
|
|
879
|
+
if (removeBtn) {
|
|
880
|
+
removeBtn.style.visibility = singleParticipant ? 'hidden' : 'visible';
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Update the instructions icon for a participant to outline or solid
|
|
887
|
+
* based on whether the textarea has content.
|
|
888
|
+
* @param {Element} panel - The council panel element
|
|
889
|
+
* @param {string} level - Level number
|
|
890
|
+
* @param {string} index - Voice index
|
|
891
|
+
* @param {string} value - Current textarea value
|
|
892
|
+
*/
|
|
893
|
+
_updateInstructionsIcon(panel, level, index, value) {
|
|
894
|
+
const wrapper = panel.querySelector(`.participant-wrapper[data-level="${level}"][data-index="${index}"]`);
|
|
895
|
+
const iconBtn = wrapper?.querySelector(`.toggle-instructions-icon[data-level="${level}"][data-index="${index}"]`);
|
|
896
|
+
if (!iconBtn) return;
|
|
897
|
+
|
|
898
|
+
const hasContent = value.trim().length > 0;
|
|
899
|
+
iconBtn.innerHTML = hasContent
|
|
900
|
+
? AdvancedConfigTab.SPEECH_BUBBLE_SVG_SOLID
|
|
901
|
+
: AdvancedConfigTab.SPEECH_BUBBLE_SVG;
|
|
902
|
+
iconBtn.classList.toggle('has-instructions', hasContent);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Update the clock/timeout icon styling to indicate non-default timeout.
|
|
907
|
+
* @param {Element} panel - The council panel element
|
|
908
|
+
* @param {string} level - Level number
|
|
909
|
+
* @param {string} index - Voice index
|
|
910
|
+
* @param {string} value - Current timeout value (as string of ms)
|
|
911
|
+
*/
|
|
912
|
+
_updateTimeoutIcon(panel, level, index, value) {
|
|
913
|
+
const wrapper = panel.querySelector(`.participant-wrapper[data-level="${level}"][data-index="${index}"]`);
|
|
914
|
+
const iconBtn = wrapper?.querySelector(`.toggle-timeout-icon[data-level="${level}"][data-index="${index}"]`);
|
|
915
|
+
if (!iconBtn) return;
|
|
916
|
+
|
|
917
|
+
const isNonDefault = parseInt(value, 10) !== AdvancedConfigTab.DEFAULT_TIMEOUT;
|
|
918
|
+
iconBtn.classList.toggle('has-custom-timeout', isNonDefault);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
_updateOrchestrationTimeoutIcon(panel, value) {
|
|
922
|
+
const iconBtn = panel.querySelector('#adv-orchestration-timeout-toggle');
|
|
923
|
+
if (!iconBtn) return;
|
|
924
|
+
|
|
925
|
+
const isNonDefault = parseInt(value, 10) !== AdvancedConfigTab.DEFAULT_TIMEOUT;
|
|
926
|
+
iconBtn.classList.toggle('has-custom-timeout', isNonDefault);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
_updateOrchestrationInstructionsIcon(panel, value) {
|
|
930
|
+
const iconBtn = panel.querySelector('#adv-orchestration-instructions-toggle');
|
|
931
|
+
if (!iconBtn) return;
|
|
932
|
+
|
|
933
|
+
const hasContent = value.trim().length > 0;
|
|
934
|
+
iconBtn.innerHTML = hasContent
|
|
935
|
+
? AdvancedConfigTab.SPEECH_BUBBLE_SVG_SOLID
|
|
936
|
+
: AdvancedConfigTab.SPEECH_BUBBLE_SVG;
|
|
937
|
+
iconBtn.classList.toggle('has-instructions', hasContent);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// --- Dirty state tracking ---
|
|
941
|
+
|
|
942
|
+
_markDirty() {
|
|
943
|
+
this._isDirty = true;
|
|
944
|
+
this._updateSaveButtonStates();
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
_markClean() {
|
|
948
|
+
this._isDirty = false;
|
|
949
|
+
this._updateSaveButtonStates();
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
_updateSaveButtonStates() {
|
|
953
|
+
const panel = this.modal.querySelector('#tab-panel-advanced');
|
|
954
|
+
if (!panel) return;
|
|
955
|
+
|
|
956
|
+
const saveBtn = panel.querySelector('#council-save-btn');
|
|
957
|
+
const saveAsBtn = panel.querySelector('#council-save-as-btn');
|
|
958
|
+
const deleteBtn = panel.querySelector('#council-delete-btn');
|
|
959
|
+
|
|
960
|
+
if (saveBtn) {
|
|
961
|
+
saveBtn.disabled = !this._isDirty || !this.selectedCouncilId;
|
|
962
|
+
}
|
|
963
|
+
if (saveAsBtn) {
|
|
964
|
+
// Reuse _validateConfig to keep enablement in sync with actual save validation
|
|
965
|
+
const config = this._readConfigFromUI();
|
|
966
|
+
const { valid } = this._validateConfig(config);
|
|
967
|
+
saveAsBtn.disabled = !valid;
|
|
968
|
+
}
|
|
969
|
+
if (deleteBtn) {
|
|
970
|
+
// Delete is only available when viewing a saved council
|
|
971
|
+
deleteBtn.disabled = !this.selectedCouncilId;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// Toggle the "unsaved changes" hint in the modal footer
|
|
975
|
+
this._updateDirtyHint();
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Toggle the "unsaved changes" hint + save button container in the modal footer.
|
|
980
|
+
* Visible only when council tab is active AND config is dirty.
|
|
981
|
+
*/
|
|
982
|
+
_updateDirtyHint() {
|
|
983
|
+
const container = this.modal.querySelector('#council-footer-left');
|
|
984
|
+
if (!container) return;
|
|
985
|
+
container.style.display = this._isDirty ? '' : 'none';
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Update council custom instructions character count
|
|
990
|
+
* @param {number} count - Current character count
|
|
991
|
+
*/
|
|
992
|
+
_updateCouncilCharCount(count) {
|
|
993
|
+
const panel = this.modal.querySelector('#tab-panel-advanced');
|
|
994
|
+
if (!panel) return;
|
|
995
|
+
|
|
996
|
+
const charCountEl = panel.querySelector('#council-char-count');
|
|
997
|
+
const charCountContainer = panel.querySelector('#council-char-count-container');
|
|
998
|
+
const textarea = panel.querySelector('#council-custom-instructions');
|
|
999
|
+
const submitBtn = this.modal.querySelector('[data-action="submit"]');
|
|
1000
|
+
|
|
1001
|
+
if (charCountEl) {
|
|
1002
|
+
charCountEl.textContent = count.toLocaleString();
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const isOverLimit = count > this.CHAR_LIMIT;
|
|
1006
|
+
const isNearLimit = count > this.CHAR_WARNING_THRESHOLD && count <= this.CHAR_LIMIT;
|
|
1007
|
+
|
|
1008
|
+
if (charCountContainer) {
|
|
1009
|
+
charCountContainer.classList.remove('char-count-warning', 'char-count-error');
|
|
1010
|
+
if (isOverLimit) {
|
|
1011
|
+
charCountContainer.classList.add('char-count-error');
|
|
1012
|
+
} else if (isNearLimit) {
|
|
1013
|
+
charCountContainer.classList.add('char-count-warning');
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
if (textarea) {
|
|
1018
|
+
textarea.classList.remove('textarea-warning', 'textarea-error');
|
|
1019
|
+
if (isOverLimit) {
|
|
1020
|
+
textarea.classList.add('textarea-error');
|
|
1021
|
+
} else if (isNearLimit) {
|
|
1022
|
+
textarea.classList.add('textarea-warning');
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
if (submitBtn) {
|
|
1027
|
+
submitBtn.disabled = isOverLimit;
|
|
1028
|
+
if (isOverLimit) {
|
|
1029
|
+
submitBtn.title = 'Custom instructions exceed 5,000 character limit';
|
|
1030
|
+
} else {
|
|
1031
|
+
submitBtn.title = 'Start Analysis (Cmd/Ctrl+Enter)';
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
_readConfigFromUI() {
|
|
1037
|
+
const panel = this.modal.querySelector('#tab-panel-advanced');
|
|
1038
|
+
if (!panel) return this._defaultConfig();
|
|
1039
|
+
|
|
1040
|
+
const config = { levels: {}, consolidation: {} };
|
|
1041
|
+
|
|
1042
|
+
for (const level of [1, 2, 3]) {
|
|
1043
|
+
const checkbox = panel.querySelector(`.level-checkbox[data-level="${level}"]`);
|
|
1044
|
+
const enabled = checkbox?.checked || false;
|
|
1045
|
+
const voices = [];
|
|
1046
|
+
|
|
1047
|
+
if (enabled) {
|
|
1048
|
+
const wrappers = panel.querySelectorAll(`.participant-wrapper[data-level="${level}"]`);
|
|
1049
|
+
wrappers.forEach(wrapper => {
|
|
1050
|
+
const row = wrapper.querySelector('.voice-row');
|
|
1051
|
+
const provider = row?.querySelector('.voice-provider')?.value;
|
|
1052
|
+
const model = row?.querySelector('.voice-model')?.value;
|
|
1053
|
+
const tier = row?.querySelector('.voice-tier')?.value;
|
|
1054
|
+
const timeoutSelect = row?.querySelector('.adv-timeout');
|
|
1055
|
+
const timeout = timeoutSelect ? parseInt(timeoutSelect.value, 10) : AdvancedConfigTab.DEFAULT_TIMEOUT;
|
|
1056
|
+
const idx = wrapper.dataset.index;
|
|
1057
|
+
const instructionsArea = wrapper.querySelector(`.voice-instructions-input[data-level="${level}"][data-index="${idx}"]`);
|
|
1058
|
+
const customInstructions = instructionsArea?.value?.trim() || undefined;
|
|
1059
|
+
|
|
1060
|
+
if (provider && model) {
|
|
1061
|
+
const voice = { provider, model, tier, timeout };
|
|
1062
|
+
if (customInstructions) voice.customInstructions = customInstructions;
|
|
1063
|
+
voices.push(voice);
|
|
1064
|
+
}
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
config.levels[String(level)] = { enabled, voices };
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// Orchestration
|
|
1072
|
+
const orchRow = panel.querySelector('#orchestration-voice');
|
|
1073
|
+
const orchTimeoutSelect = panel.querySelector('#adv-orchestration-timeout');
|
|
1074
|
+
const orchInstrInput = panel.querySelector('#adv-orchestration-instructions');
|
|
1075
|
+
const orchTimeout = orchTimeoutSelect ? parseInt(orchTimeoutSelect.value, 10) : AdvancedConfigTab.DEFAULT_TIMEOUT;
|
|
1076
|
+
const orchCustomInstructions = orchInstrInput?.value?.trim() || undefined;
|
|
1077
|
+
if (orchRow) {
|
|
1078
|
+
config.consolidation = {
|
|
1079
|
+
provider: orchRow.querySelector('.voice-provider')?.value || 'claude',
|
|
1080
|
+
model: orchRow.querySelector('.voice-model')?.value || 'sonnet',
|
|
1081
|
+
tier: orchRow.querySelector('.voice-tier')?.value || 'balanced',
|
|
1082
|
+
timeout: orchTimeout,
|
|
1083
|
+
...(orchCustomInstructions ? { customInstructions: orchCustomInstructions } : {})
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
return config;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
_applyConfigToUI(config) {
|
|
1091
|
+
const panel = this.modal.querySelector('#tab-panel-advanced');
|
|
1092
|
+
if (!panel) return;
|
|
1093
|
+
|
|
1094
|
+
for (const level of [1, 2, 3]) {
|
|
1095
|
+
const levelConfig = config.levels?.[String(level)];
|
|
1096
|
+
const checkbox = panel.querySelector(`.level-checkbox[data-level="${level}"]`);
|
|
1097
|
+
const voicesContainer = panel.querySelector(`#level-${level}-voices`);
|
|
1098
|
+
const voiceList = panel.querySelector(`#level-${level}-voice-list`);
|
|
1099
|
+
|
|
1100
|
+
if (checkbox) checkbox.checked = !!levelConfig?.enabled;
|
|
1101
|
+
if (voicesContainer) voicesContainer.style.display = levelConfig?.enabled ? '' : 'none';
|
|
1102
|
+
|
|
1103
|
+
if (voiceList && levelConfig?.voices?.length > 0) {
|
|
1104
|
+
voiceList.innerHTML = '';
|
|
1105
|
+
levelConfig.voices.forEach((voice, i) => {
|
|
1106
|
+
const wrapper = document.createElement('div');
|
|
1107
|
+
wrapper.innerHTML = this._buildVoiceRowHTML(level, i);
|
|
1108
|
+
while (wrapper.firstChild) {
|
|
1109
|
+
voiceList.appendChild(wrapper.firstChild);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// Set values after adding to DOM
|
|
1113
|
+
const participantWrapper = voiceList.querySelector(`.participant-wrapper[data-level="${level}"][data-index="${i}"]`);
|
|
1114
|
+
const row = participantWrapper?.querySelector('.voice-row');
|
|
1115
|
+
const providerSelect = row?.querySelector('.voice-provider');
|
|
1116
|
+
if (providerSelect) {
|
|
1117
|
+
this._populateProviderDropdown(providerSelect);
|
|
1118
|
+
providerSelect.value = voice.provider;
|
|
1119
|
+
this._updateModelDropdown(providerSelect);
|
|
1120
|
+
const modelSelect = row.querySelector('.voice-model');
|
|
1121
|
+
if (modelSelect) modelSelect.value = voice.model;
|
|
1122
|
+
const tierSelect = row.querySelector('.voice-tier');
|
|
1123
|
+
if (tierSelect) tierSelect.value = voice.tier || 'balanced';
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// Mount and restore timeout value
|
|
1127
|
+
const mount = row?.querySelector(`.adv-timeout-mount[data-level="${level}"][data-index="${i}"]`);
|
|
1128
|
+
if (mount) {
|
|
1129
|
+
TimeoutSelect.mount(mount, { className: 'adv-timeout', title: 'Per-reviewer timeout' });
|
|
1130
|
+
}
|
|
1131
|
+
const timeoutEl = row?.querySelector('.adv-timeout');
|
|
1132
|
+
if (timeoutEl && voice.timeout) {
|
|
1133
|
+
timeoutEl.value = String(voice.timeout);
|
|
1134
|
+
// Show the dropdown if non-default
|
|
1135
|
+
if (voice.timeout !== AdvancedConfigTab.DEFAULT_TIMEOUT) {
|
|
1136
|
+
timeoutEl.style.display = '';
|
|
1137
|
+
}
|
|
1138
|
+
this._updateTimeoutIcon(panel, String(level), String(i), String(voice.timeout));
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
if (voice.customInstructions) {
|
|
1142
|
+
const instrInput = participantWrapper?.querySelector(`.voice-instructions-input[data-level="${level}"][data-index="${i}"]`);
|
|
1143
|
+
if (instrInput) instrInput.value = voice.customInstructions;
|
|
1144
|
+
const instrArea = participantWrapper?.querySelector(`.voice-instructions-area[data-level="${level}"][data-index="${i}"]`);
|
|
1145
|
+
if (instrArea) instrArea.style.display = '';
|
|
1146
|
+
// Set solid icon to indicate instructions are present
|
|
1147
|
+
this._updateInstructionsIcon(panel, String(level), String(i), voice.customInstructions);
|
|
1148
|
+
}
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
// Update remove button visibility after loading
|
|
1152
|
+
this._updateRemoveButtonVisibility(level);
|
|
1153
|
+
} else if (voiceList) {
|
|
1154
|
+
voiceList.innerHTML = '';
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// Consolidation (read from 'consolidation' key, fall back to legacy 'orchestration')
|
|
1159
|
+
const consolSection = config.consolidation || config.orchestration;
|
|
1160
|
+
if (consolSection) {
|
|
1161
|
+
const orchRow = panel.querySelector('#orchestration-voice');
|
|
1162
|
+
if (orchRow) {
|
|
1163
|
+
const providerSelect = orchRow.querySelector('.voice-provider');
|
|
1164
|
+
if (providerSelect) {
|
|
1165
|
+
this._populateProviderDropdown(providerSelect);
|
|
1166
|
+
providerSelect.value = consolSection.provider;
|
|
1167
|
+
this._updateModelDropdown(providerSelect);
|
|
1168
|
+
const modelSelect = orchRow.querySelector('.voice-model');
|
|
1169
|
+
if (modelSelect) modelSelect.value = consolSection.model;
|
|
1170
|
+
const tierSelect = orchRow.querySelector('.voice-tier');
|
|
1171
|
+
if (tierSelect) tierSelect.value = consolSection.tier || 'balanced';
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// Restore consolidation timeout
|
|
1176
|
+
const orchTimeoutSelect = panel.querySelector('#adv-orchestration-timeout');
|
|
1177
|
+
if (orchTimeoutSelect && consolSection.timeout) {
|
|
1178
|
+
orchTimeoutSelect.value = String(consolSection.timeout);
|
|
1179
|
+
// Show the dropdown if non-default
|
|
1180
|
+
if (consolSection.timeout !== AdvancedConfigTab.DEFAULT_TIMEOUT) {
|
|
1181
|
+
orchTimeoutSelect.style.display = '';
|
|
1182
|
+
}
|
|
1183
|
+
this._updateOrchestrationTimeoutIcon(panel, String(consolSection.timeout));
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// Restore consolidation custom instructions
|
|
1187
|
+
const orchInstrInput = panel.querySelector('#adv-orchestration-instructions');
|
|
1188
|
+
const orchInstrArea = panel.querySelector('#adv-orchestration-instructions-area');
|
|
1189
|
+
if (consolSection.customInstructions) {
|
|
1190
|
+
if (orchInstrInput) orchInstrInput.value = consolSection.customInstructions;
|
|
1191
|
+
if (orchInstrArea) orchInstrArea.style.display = '';
|
|
1192
|
+
this._updateOrchestrationInstructionsIcon(panel, consolSection.customInstructions);
|
|
1193
|
+
} else {
|
|
1194
|
+
if (orchInstrInput) orchInstrInput.value = '';
|
|
1195
|
+
if (orchInstrArea) orchInstrArea.style.display = 'none';
|
|
1196
|
+
this._updateOrchestrationInstructionsIcon(panel, '');
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
async _saveCouncil() {
|
|
1202
|
+
const config = this._readConfigFromUI();
|
|
1203
|
+
const { valid } = this._validateConfig(config);
|
|
1204
|
+
if (!valid) {
|
|
1205
|
+
if (window.toast) window.toast.showWarning('At least one review level must be enabled.');
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
if (this.selectedCouncilId) {
|
|
1209
|
+
try {
|
|
1210
|
+
await this._putCouncil(this.selectedCouncilId, config);
|
|
1211
|
+
} catch (error) {
|
|
1212
|
+
console.error('Error saving council:', error);
|
|
1213
|
+
if (window.toast) {
|
|
1214
|
+
window.toast.showError('Failed to save council');
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
} else {
|
|
1218
|
+
this._saveCouncilAs();
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
async _saveCouncilAs() {
|
|
1223
|
+
const config = this._readConfigFromUI();
|
|
1224
|
+
const { valid } = this._validateConfig(config);
|
|
1225
|
+
if (!valid) {
|
|
1226
|
+
if (window.toast) window.toast.showWarning('At least one review level must be enabled.');
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const dialog = window.textInputDialog;
|
|
1231
|
+
if (!dialog) return;
|
|
1232
|
+
const currentCouncil = this.selectedCouncilId
|
|
1233
|
+
? this.councils.find(c => c.id === this.selectedCouncilId)
|
|
1234
|
+
: null;
|
|
1235
|
+
let name;
|
|
1236
|
+
while (true) {
|
|
1237
|
+
name = await dialog.show({
|
|
1238
|
+
title: 'Save Council As',
|
|
1239
|
+
label: 'Council name',
|
|
1240
|
+
placeholder: 'Enter a name for this council',
|
|
1241
|
+
value: name || currentCouncil?.name || '',
|
|
1242
|
+
confirmText: 'Save',
|
|
1243
|
+
confirmClass: 'btn-primary'
|
|
1244
|
+
});
|
|
1245
|
+
if (!name) return;
|
|
1246
|
+
const duplicate = this.councils.find(c => c.name.toLowerCase() === name.toLowerCase());
|
|
1247
|
+
if (!duplicate) break;
|
|
1248
|
+
if (window.toast) window.toast.showWarning('A council with that name already exists.');
|
|
1249
|
+
}
|
|
1250
|
+
try {
|
|
1251
|
+
await this._postCouncil(name, config);
|
|
1252
|
+
} catch (error) {
|
|
1253
|
+
console.error('Error saving council:', error);
|
|
1254
|
+
if (window.toast) {
|
|
1255
|
+
window.toast.showError('Failed to save council');
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* PUT (update) an existing council by ID.
|
|
1262
|
+
* Handles fetch, response check, markClean, and selector refresh.
|
|
1263
|
+
* @param {string} councilId - The council ID to update
|
|
1264
|
+
* @param {Object} config - The council configuration to save
|
|
1265
|
+
*/
|
|
1266
|
+
async _putCouncil(councilId, config) {
|
|
1267
|
+
const response = await fetch(`/api/councils/${councilId}`, {
|
|
1268
|
+
method: 'PUT',
|
|
1269
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1270
|
+
body: JSON.stringify({ config, type: 'advanced' })
|
|
1271
|
+
});
|
|
1272
|
+
if (!response.ok) {
|
|
1273
|
+
throw new Error(`PUT /api/councils/${councilId} failed: ${response.status}`);
|
|
1274
|
+
}
|
|
1275
|
+
this._markClean();
|
|
1276
|
+
await this.loadCouncils();
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* POST (create) a new council with the given name.
|
|
1281
|
+
* Handles fetch, response check, markClean, selector refresh, and selection update.
|
|
1282
|
+
* @param {string} name - The name for the new council
|
|
1283
|
+
* @param {Object} config - The council configuration to save
|
|
1284
|
+
*/
|
|
1285
|
+
async _postCouncil(name, config) {
|
|
1286
|
+
const response = await fetch('/api/councils', {
|
|
1287
|
+
method: 'POST',
|
|
1288
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1289
|
+
body: JSON.stringify({ name, config, type: 'advanced' })
|
|
1290
|
+
});
|
|
1291
|
+
if (!response.ok) {
|
|
1292
|
+
throw new Error(`POST /api/councils failed: ${response.status}`);
|
|
1293
|
+
}
|
|
1294
|
+
const data = await response.json();
|
|
1295
|
+
this.selectedCouncilId = data.council.id;
|
|
1296
|
+
this._markClean();
|
|
1297
|
+
await this.loadCouncils();
|
|
1298
|
+
const selector = this.modal.querySelector('#council-selector');
|
|
1299
|
+
if (selector) {
|
|
1300
|
+
selector.value = this.selectedCouncilId;
|
|
1301
|
+
selector.classList.remove('new-council-selected');
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
async _exportCouncil() {
|
|
1306
|
+
const config = this._readConfigFromUI();
|
|
1307
|
+
try {
|
|
1308
|
+
await navigator.clipboard.writeText(JSON.stringify(config, null, 2));
|
|
1309
|
+
if (window.toast) window.toast.showSuccess('Council config copied to clipboard');
|
|
1310
|
+
} catch (error) {
|
|
1311
|
+
console.error('Failed to copy to clipboard:', error);
|
|
1312
|
+
if (window.toast) window.toast.showError('Failed to copy to clipboard');
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
async _deleteCouncil() {
|
|
1317
|
+
if (!this.selectedCouncilId) return;
|
|
1318
|
+
|
|
1319
|
+
const council = this.councils.find(c => c.id === this.selectedCouncilId);
|
|
1320
|
+
const councilName = council?.name || 'this council';
|
|
1321
|
+
|
|
1322
|
+
const confirmDlg = window.confirmDialog;
|
|
1323
|
+
if (!confirmDlg) return;
|
|
1324
|
+
const result = await confirmDlg.show({
|
|
1325
|
+
title: 'Delete Council',
|
|
1326
|
+
message: `Are you sure you want to delete "${councilName}"?`,
|
|
1327
|
+
confirmText: 'Delete',
|
|
1328
|
+
confirmClass: 'btn-danger'
|
|
1329
|
+
});
|
|
1330
|
+
if (result !== 'confirm') return;
|
|
1331
|
+
|
|
1332
|
+
try {
|
|
1333
|
+
const response = await fetch(`/api/councils/${this.selectedCouncilId}`, {
|
|
1334
|
+
method: 'DELETE'
|
|
1335
|
+
});
|
|
1336
|
+
if (!response.ok) {
|
|
1337
|
+
throw new Error(`DELETE /api/councils/${this.selectedCouncilId} failed: ${response.status}`);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// Reset to "+ New Council" state
|
|
1341
|
+
this.selectedCouncilId = null;
|
|
1342
|
+
this._applyConfigToUI(this._defaultConfig());
|
|
1343
|
+
this._markClean();
|
|
1344
|
+
await this.loadCouncils();
|
|
1345
|
+
|
|
1346
|
+
const selector = this.modal.querySelector('#council-selector');
|
|
1347
|
+
if (selector) {
|
|
1348
|
+
selector.value = '';
|
|
1349
|
+
selector.classList.add('new-council-selected');
|
|
1350
|
+
}
|
|
1351
|
+
this._updateSaveButtonStates();
|
|
1352
|
+
|
|
1353
|
+
if (window.toast) window.toast.showSuccess('Council deleted');
|
|
1354
|
+
} catch (error) {
|
|
1355
|
+
console.error('Error deleting council:', error);
|
|
1356
|
+
if (window.toast) window.toast.showError('Failed to delete council');
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// Export for use in other modules
|
|
1362
|
+
if (typeof window !== 'undefined') {
|
|
1363
|
+
window.AdvancedConfigTab = AdvancedConfigTab;
|
|
1364
|
+
}
|