@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.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/plugin/skills/review-requests/SKILL.md +54 -0
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/public/css/pr.css +1081 -54
  6. package/public/css/repo-settings.css +452 -140
  7. package/public/js/components/AdvancedConfigTab.js +1364 -0
  8. package/public/js/components/AnalysisConfigModal.js +488 -112
  9. package/public/js/components/CouncilProgressModal.js +1416 -0
  10. package/public/js/components/TextInputDialog.js +231 -0
  11. package/public/js/components/TimeoutSelect.js +367 -0
  12. package/public/js/components/VoiceCentricConfigTab.js +1334 -0
  13. package/public/js/local.js +162 -83
  14. package/public/js/modules/analysis-history.js +185 -11
  15. package/public/js/modules/comment-manager.js +13 -0
  16. package/public/js/modules/file-comment-manager.js +28 -0
  17. package/public/js/pr.js +233 -115
  18. package/public/js/repo-settings.js +575 -106
  19. package/public/local.html +11 -1
  20. package/public/pr.html +6 -1
  21. package/public/repo-settings.html +28 -21
  22. package/public/setup.html +8 -2
  23. package/src/ai/analyzer.js +1262 -111
  24. package/src/ai/claude-cli.js +2 -2
  25. package/src/ai/claude-provider.js +6 -6
  26. package/src/ai/codex-provider.js +6 -6
  27. package/src/ai/copilot-provider.js +3 -3
  28. package/src/ai/cursor-agent-provider.js +6 -6
  29. package/src/ai/gemini-provider.js +6 -6
  30. package/src/ai/opencode-provider.js +6 -6
  31. package/src/ai/pi-provider.js +6 -6
  32. package/src/ai/prompts/baseline/consolidation/balanced.js +208 -0
  33. package/src/ai/prompts/baseline/consolidation/fast.js +175 -0
  34. package/src/ai/prompts/baseline/consolidation/thorough.js +283 -0
  35. package/src/ai/prompts/config.js +1 -1
  36. package/src/ai/prompts/index.js +26 -2
  37. package/src/ai/provider.js +4 -2
  38. package/src/database.js +417 -14
  39. package/src/main.js +1 -1
  40. package/src/routes/analysis.js +495 -10
  41. package/src/routes/config.js +36 -15
  42. package/src/routes/councils.js +351 -0
  43. package/src/routes/local.js +33 -11
  44. package/src/routes/mcp.js +9 -2
  45. package/src/routes/setup.js +12 -2
  46. package/src/routes/shared.js +126 -13
  47. package/src/server.js +34 -4
  48. package/src/utils/stats-calculator.js +2 -0
@@ -0,0 +1,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} &mdash; ${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">&minus;</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 &mdash; 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...&#10;&#10;Examples:&#10;&#8226; Pay extra attention to the authentication logic&#10;&#8226; Check for proper error handling in the API calls&#10;&#8226; 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
+ }