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