@in-the-loop-labs/pair-review 1.4.3 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/plugin/skills/review-requests/SKILL.md +54 -0
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/public/css/pr.css +1081 -54
  6. package/public/css/repo-settings.css +452 -140
  7. package/public/js/components/AdvancedConfigTab.js +1364 -0
  8. package/public/js/components/AnalysisConfigModal.js +488 -112
  9. package/public/js/components/CouncilProgressModal.js +1416 -0
  10. package/public/js/components/TextInputDialog.js +231 -0
  11. package/public/js/components/TimeoutSelect.js +367 -0
  12. package/public/js/components/VoiceCentricConfigTab.js +1334 -0
  13. package/public/js/local.js +162 -83
  14. package/public/js/modules/analysis-history.js +185 -11
  15. package/public/js/modules/comment-manager.js +13 -0
  16. package/public/js/modules/file-comment-manager.js +28 -0
  17. package/public/js/pr.js +233 -115
  18. package/public/js/repo-settings.js +575 -106
  19. package/public/local.html +11 -1
  20. package/public/pr.html +6 -1
  21. package/public/repo-settings.html +28 -21
  22. package/public/setup.html +8 -2
  23. package/src/ai/analyzer.js +1262 -111
  24. package/src/ai/claude-cli.js +2 -2
  25. package/src/ai/claude-provider.js +6 -6
  26. package/src/ai/codex-provider.js +6 -6
  27. package/src/ai/copilot-provider.js +3 -3
  28. package/src/ai/cursor-agent-provider.js +6 -6
  29. package/src/ai/gemini-provider.js +6 -6
  30. package/src/ai/opencode-provider.js +6 -6
  31. package/src/ai/pi-provider.js +6 -6
  32. package/src/ai/prompts/baseline/consolidation/balanced.js +208 -0
  33. package/src/ai/prompts/baseline/consolidation/fast.js +175 -0
  34. package/src/ai/prompts/baseline/consolidation/thorough.js +283 -0
  35. package/src/ai/prompts/config.js +1 -1
  36. package/src/ai/prompts/index.js +26 -2
  37. package/src/ai/provider.js +4 -2
  38. package/src/database.js +417 -14
  39. package/src/main.js +1 -1
  40. package/src/routes/analysis.js +495 -10
  41. package/src/routes/config.js +36 -15
  42. package/src/routes/councils.js +351 -0
  43. package/src/routes/local.js +33 -11
  44. package/src/routes/mcp.js +9 -2
  45. package/src/routes/setup.js +12 -2
  46. package/src/routes/shared.js +126 -13
  47. package/src/server.js +34 -4
  48. package/src/utils/stats-calculator.js +2 -0
@@ -13,6 +13,7 @@ class RepoSettingsPage {
13
13
  this.hasUnsavedChanges = false;
14
14
  this.providers = {};
15
15
  this.selectedProvider = null;
16
+ this.councils = [];
16
17
 
17
18
  this.init();
18
19
  }
@@ -33,6 +34,9 @@ class RepoSettingsPage {
33
34
  // Load providers first (needed to render model cards)
34
35
  await this.loadProviders();
35
36
 
37
+ // Load councils (for default council dropdown)
38
+ await this.loadCouncils();
39
+
36
40
  // Load settings
37
41
  await this.loadSettings();
38
42
  }
@@ -131,8 +135,60 @@ class RepoSettingsPage {
131
135
  }
132
136
 
133
137
  setupEventListeners() {
134
- // Model card and provider button listeners are attached dynamically
135
- // when renderProviderButtons() and renderModelCards() are called
138
+ // Provider select
139
+ const providerSelect = document.getElementById('provider-select');
140
+ if (providerSelect) {
141
+ providerSelect.addEventListener('change', () => {
142
+ const newProviderId = providerSelect.value;
143
+ const previousProvider = this.selectedProvider;
144
+ this.selectedProvider = newProviderId;
145
+ this.currentSettings.default_provider = newProviderId;
146
+
147
+ // Re-render model select for the new provider
148
+ this.renderModelSelect();
149
+
150
+ // Try to map the model tier to the new provider
151
+ if (previousProvider && previousProvider !== newProviderId) {
152
+ const oldProvider = this.providers[previousProvider];
153
+ const newProvider = this.providers[newProviderId];
154
+
155
+ if (oldProvider && newProvider) {
156
+ const currentModel = oldProvider.models.find(m => m.id === this.currentSettings.default_model);
157
+ if (currentModel) {
158
+ const matchingModel = newProvider.models.find(m => m.tier === currentModel.tier);
159
+ const defaultModel = newProvider.models.find(m => m.default);
160
+ const fallbackModel = matchingModel || defaultModel || newProvider.models[0];
161
+ this.currentSettings.default_model = fallbackModel.id;
162
+ } else {
163
+ const defaultModel = newProvider.models.find(m => m.default) || newProvider.models[0];
164
+ this.currentSettings.default_model = defaultModel.id;
165
+ }
166
+ } else if (newProvider) {
167
+ const defaultModel = newProvider.models.find(m => m.default) || newProvider.models[0];
168
+ this.currentSettings.default_model = defaultModel.id;
169
+ }
170
+
171
+ // Update model select and card to reflect the mapped model
172
+ const modelSelect = document.getElementById('model-select');
173
+ if (modelSelect) {
174
+ modelSelect.value = this.currentSettings.default_model;
175
+ }
176
+ this.renderModelCard();
177
+ }
178
+
179
+ this.checkForChanges();
180
+ });
181
+ }
182
+
183
+ // Model select
184
+ const modelSelect = document.getElementById('model-select');
185
+ if (modelSelect) {
186
+ modelSelect.addEventListener('change', () => {
187
+ this.currentSettings.default_model = modelSelect.value;
188
+ this.renderModelCard();
189
+ this.checkForChanges();
190
+ });
191
+ }
136
192
 
137
193
  // Instructions textarea
138
194
  const textarea = document.getElementById('default-instructions');
@@ -144,6 +200,18 @@ class RepoSettingsPage {
144
200
  });
145
201
  }
146
202
 
203
+ // Analysis mode segmented control
204
+ const modeToggle = document.getElementById('analysis-mode-toggle');
205
+ if (modeToggle) {
206
+ modeToggle.addEventListener('click', (e) => {
207
+ const btn = e.target.closest('.mode-btn');
208
+ if (!btn) return;
209
+ this.setAnalysisMode(btn.dataset.mode);
210
+ });
211
+ }
212
+
213
+ // Council custom dropdown listeners are attached in renderCouncilDropdown()
214
+
147
215
  // Form submission
148
216
  const form = document.getElementById('settings-form');
149
217
  if (form) {
@@ -204,109 +272,340 @@ class RepoSettingsPage {
204
272
  }
205
273
 
206
274
  // Render provider buttons now that we have data
207
- this.renderProviderButtons();
275
+ this.renderProviderSelect();
208
276
 
209
277
  } catch (error) {
210
278
  console.error('Error loading providers:', error);
211
279
  // No hardcoded fallback — rely on the /api/providers endpoint as the single source of truth.
212
280
  // If the endpoint is unavailable, show an empty state rather than stale data.
213
281
  this.providers = {};
214
- this.renderProviderButtons();
282
+ this.renderProviderSelect();
215
283
  this.showToast('error', 'Failed to load AI providers. Please refresh the page.');
216
284
  }
217
285
  }
218
286
 
219
287
  /**
220
- * Render provider toggle buttons
288
+ * Load saved councils for the default council dropdown
221
289
  */
222
- renderProviderButtons() {
223
- const container = document.getElementById('provider-toggle');
290
+ async loadCouncils() {
291
+ const container = document.getElementById('default-council-dropdown');
224
292
  if (!container) return;
225
293
 
226
- const providerIds = Object.keys(this.providers);
227
- container.innerHTML = providerIds.map(providerId => {
228
- const provider = this.providers[providerId];
229
- return `
230
- <button type="button" class="provider-btn ${providerId === this.selectedProvider ? 'selected' : ''}" data-provider="${providerId}">
231
- ${provider.name}
232
- </button>
233
- `;
234
- }).join('');
294
+ try {
295
+ const response = await fetch('/api/councils');
296
+ if (!response.ok) throw new Error('Failed to fetch councils');
297
+ const data = await response.json();
298
+ this.councils = data.councils || [];
299
+ } catch (error) {
300
+ console.error('Error loading councils:', error);
301
+ this.councils = [];
302
+ }
303
+ }
235
304
 
236
- // Attach event listeners
237
- container.querySelectorAll('.provider-btn').forEach(btn => {
238
- btn.addEventListener('click', () => this.selectProvider(btn.dataset.provider));
239
- });
305
+ /**
306
+ * Get the display label for a council type
307
+ * @param {string} type - Council type ('council' or 'advanced')
308
+ * @returns {{ label: string, cssClass: string }}
309
+ */
310
+ getCouncilTypeBadge(type) {
311
+ if (type === 'advanced') {
312
+ return { label: 'Advanced', cssClass: 'badge-advanced' };
313
+ }
314
+ return { label: 'Standard', cssClass: 'badge-standard' };
240
315
  }
241
316
 
242
317
  /**
243
- * Select a provider and update model cards
318
+ * Render the custom council dropdown
244
319
  */
245
- selectProvider(providerId, markAsChanged = true) {
246
- if (!this.providers[providerId]) return;
320
+ renderCouncilDropdown() {
321
+ const container = document.getElementById('default-council-dropdown');
322
+ if (!container) return;
247
323
 
248
- const previousProvider = this.selectedProvider;
249
- this.selectedProvider = providerId;
324
+ const selectedId = this.currentSettings.default_council_id || '';
325
+ const selectedCouncil = this.councils.find(c => c.id === selectedId);
326
+
327
+ // Build trigger display
328
+ let triggerHTML;
329
+ if (selectedCouncil) {
330
+ const badge = this.getCouncilTypeBadge(selectedCouncil.type);
331
+ triggerHTML = `<span class="trigger-text">${this.escapeHtml(selectedCouncil.name)}</span>
332
+ <span class="council-type-badge ${badge.cssClass}">${badge.label}</span>`;
333
+ } else if (this.councils.length > 0) {
334
+ triggerHTML = '<span class="trigger-text placeholder">Select a council...</span>';
335
+ } else {
336
+ triggerHTML = '<span class="trigger-text placeholder">No councils yet — create one from the analysis config</span>';
337
+ }
250
338
 
251
- if (markAsChanged) {
252
- this.currentSettings.default_provider = providerId;
339
+ // Build option list — sort alphabetically by name
340
+ const sortedCouncils = [...this.councils].sort((a, b) =>
341
+ (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' })
342
+ );
343
+ let optionsHTML = '';
344
+
345
+ for (const council of sortedCouncils) {
346
+ const badge = this.getCouncilTypeBadge(council.type);
347
+ const isSelected = council.id === selectedId;
348
+ optionsHTML += `<div class="custom-dropdown-option${isSelected ? ' selected' : ''}" data-value="${this.escapeHtml(council.id)}" role="option" aria-selected="${isSelected}">
349
+ <span class="option-name">${this.escapeHtml(council.name)}</span>
350
+ <span class="council-type-badge ${badge.cssClass}">${badge.label}</span>
351
+ </div>`;
253
352
  }
254
353
 
255
- // Update provider buttons UI
256
- document.querySelectorAll('.provider-btn').forEach(btn => {
257
- btn.classList.toggle('selected', btn.dataset.provider === providerId);
354
+ container.innerHTML = `
355
+ <button type="button" class="custom-dropdown-trigger" aria-haspopup="listbox" aria-expanded="false">
356
+ ${triggerHTML}
357
+ </button>
358
+ <div class="custom-dropdown-list" role="listbox">
359
+ ${optionsHTML}
360
+ </div>
361
+ `;
362
+
363
+ // Attach event listeners for the dropdown
364
+ this.setupCouncilDropdownListeners(container);
365
+ }
366
+
367
+ /**
368
+ * Set up event listeners for the custom council dropdown
369
+ * @param {HTMLElement} container - The dropdown container element
370
+ */
371
+ setupCouncilDropdownListeners(container) {
372
+ const trigger = container.querySelector('.custom-dropdown-trigger');
373
+ const list = container.querySelector('.custom-dropdown-list');
374
+ if (!trigger || !list) return;
375
+
376
+ // Track focused option index for keyboard navigation
377
+ let focusedIndex = -1;
378
+ const getOptions = () => Array.from(list.querySelectorAll('.custom-dropdown-option'));
379
+
380
+ const updateFocus = (options, index) => {
381
+ options.forEach(opt => opt.classList.remove('focused'));
382
+ if (index >= 0 && index < options.length) {
383
+ options[index].classList.add('focused');
384
+ options[index].scrollIntoView({ block: 'nearest' });
385
+ }
386
+ };
387
+
388
+ // Toggle dropdown on trigger click
389
+ trigger.addEventListener('click', () => {
390
+ const isOpen = container.classList.contains('open');
391
+ if (isOpen) {
392
+ this.closeCouncilDropdown(container);
393
+ } else {
394
+ this.openCouncilDropdown(container);
395
+ focusedIndex = -1;
396
+ }
258
397
  });
259
398
 
260
- // Re-render model cards for the new provider
261
- this.renderModelCards();
399
+ // Select option on click
400
+ list.addEventListener('click', (e) => {
401
+ const option = e.target.closest('.custom-dropdown-option');
402
+ if (!option) return;
403
+ this.selectCouncilOption(container, option.dataset.value);
404
+ });
262
405
 
263
- // If provider changed and we're tracking changes, try to map the model to the new provider
264
- if (markAsChanged && previousProvider && previousProvider !== providerId) {
265
- const oldProvider = this.providers[previousProvider];
266
- const newProvider = this.providers[providerId];
406
+ // Keyboard navigation
407
+ trigger.addEventListener('keydown', (e) => {
408
+ const isOpen = container.classList.contains('open');
267
409
 
268
- // If old provider no longer exists (e.g., was removed from available providers),
269
- // fall back to the new provider's default model
270
- if (!oldProvider) {
271
- // Guard against empty models array (shouldn't happen as we filter these out)
272
- if (!newProvider.models || newProvider.models.length === 0) {
273
- console.error(`Provider "${providerId}" has no models - this should not happen`);
274
- return;
275
- }
276
- const defaultModel = newProvider.models.find(m => m.default) || newProvider.models[0];
277
- this.selectModel(defaultModel.id, markAsChanged);
278
- this.checkForChanges();
410
+ if (e.key === 'Escape' && isOpen) {
411
+ e.preventDefault();
412
+ this.closeCouncilDropdown(container);
413
+ trigger.focus();
279
414
  return;
280
415
  }
281
416
 
282
- // Find the model with same tier as currently selected
283
- const currentModel = oldProvider.models.find(m => m.id === this.currentSettings.default_model);
284
-
285
- if (currentModel) {
286
- const matchingModel = newProvider.models.find(m => m.tier === currentModel.tier);
287
- const defaultModel = newProvider.models.find(m => m.default);
288
- const fallbackModel = matchingModel || defaultModel || newProvider.models[0];
417
+ if (e.key === 'Enter' || e.key === ' ') {
418
+ e.preventDefault();
419
+ if (!isOpen) {
420
+ this.openCouncilDropdown(container);
421
+ focusedIndex = -1;
422
+ } else {
423
+ const options = getOptions();
424
+ if (focusedIndex >= 0 && focusedIndex < options.length) {
425
+ this.selectCouncilOption(container, options[focusedIndex].dataset.value);
426
+ }
427
+ }
428
+ return;
429
+ }
289
430
 
290
- if (!matchingModel && !defaultModel) {
291
- console.warn(`No matching tier or default model found for provider "${providerId}", using first available model`);
431
+ if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && isOpen) {
432
+ e.preventDefault();
433
+ const options = getOptions();
434
+ if (e.key === 'ArrowDown') {
435
+ focusedIndex = Math.min(focusedIndex + 1, options.length - 1);
436
+ } else {
437
+ focusedIndex = Math.max(focusedIndex - 1, 0);
292
438
  }
439
+ updateFocus(options, focusedIndex);
440
+ return;
441
+ }
293
442
 
294
- this.selectModel(fallbackModel.id, markAsChanged);
295
- } else {
296
- // No current model selected, use default for new provider
297
- const defaultModel = newProvider.models.find(m => m.default) || newProvider.models[0];
298
- this.selectModel(defaultModel.id, markAsChanged);
443
+ // Open on arrow down when closed
444
+ if (e.key === 'ArrowDown' && !isOpen) {
445
+ e.preventDefault();
446
+ this.openCouncilDropdown(container);
447
+ focusedIndex = 0;
448
+ updateFocus(getOptions(), focusedIndex);
299
449
  }
450
+ });
451
+
452
+ // Close on click outside (remove previous handler to avoid accumulation on re-render)
453
+ if (this._councilDropdownOutsideClickHandler) {
454
+ document.removeEventListener('click', this._councilDropdownOutsideClickHandler);
455
+ }
456
+ this._councilDropdownOutsideClickHandler = (e) => {
457
+ if (!container.contains(e.target) && container.classList.contains('open')) {
458
+ this.closeCouncilDropdown(container);
459
+ }
460
+ };
461
+ document.addEventListener('click', this._councilDropdownOutsideClickHandler);
462
+ }
300
463
 
464
+ /**
465
+ * Set the analysis mode (single model vs council) in the segmented control
466
+ * @param {string} mode - 'single' or 'council'
467
+ * @param {boolean} markChanged - Whether to trigger change detection (default true)
468
+ */
469
+ setAnalysisMode(mode, markChanged = true) {
470
+ // Update button states
471
+ document.querySelectorAll('.mode-btn').forEach(btn => {
472
+ btn.classList.toggle('selected', btn.dataset.mode === mode);
473
+ });
474
+ // Show/hide panels
475
+ const singlePanel = document.getElementById('mode-panel-single');
476
+ const councilPanel = document.getElementById('mode-panel-council');
477
+ const cardPreview = document.getElementById('model-card-preview');
478
+ if (singlePanel) singlePanel.style.display = mode === 'single' ? '' : 'none';
479
+ if (councilPanel) councilPanel.style.display = mode === 'council' ? '' : 'none';
480
+ if (mode === 'single') {
481
+ if (cardPreview) cardPreview.style.display = '';
482
+ this.renderModelCard();
483
+ } else {
484
+ // Council mode: show council card if a council is selected
485
+ const councilId = this.currentSettings.default_council_id;
486
+ const council = councilId ? this.councils.find(c => c.id === councilId) : null;
487
+ if (council && cardPreview) {
488
+ cardPreview.style.display = '';
489
+ this.renderCouncilCard(council);
490
+ } else if (cardPreview) {
491
+ cardPreview.style.display = 'none';
492
+ }
493
+ }
494
+ // Map to default_tab: 'single' or 'council'
495
+ this.currentSettings.default_tab = mode === 'council' ? 'council' : 'single';
496
+ if (markChanged) {
301
497
  this.checkForChanges();
302
498
  }
303
499
  }
304
500
 
305
501
  /**
306
- * Render model cards for the currently selected provider
502
+ * Open the custom council dropdown
503
+ * @param {HTMLElement} container
504
+ */
505
+ openCouncilDropdown(container) {
506
+ container.classList.add('open');
507
+ const trigger = container.querySelector('.custom-dropdown-trigger');
508
+ if (trigger) trigger.setAttribute('aria-expanded', 'true');
509
+ }
510
+
511
+ /**
512
+ * Close the custom council dropdown
513
+ * @param {HTMLElement} container
514
+ */
515
+ closeCouncilDropdown(container) {
516
+ container.classList.remove('open');
517
+ const trigger = container.querySelector('.custom-dropdown-trigger');
518
+ if (trigger) trigger.setAttribute('aria-expanded', 'false');
519
+ // Clear focus highlights
520
+ container.querySelectorAll('.custom-dropdown-option.focused').forEach(
521
+ opt => opt.classList.remove('focused')
522
+ );
523
+ }
524
+
525
+ /**
526
+ * Select a council option in the custom dropdown
527
+ * @param {HTMLElement} container
528
+ * @param {string} value - Council ID or empty string for "None"
529
+ */
530
+ selectCouncilOption(container, value) {
531
+ this.currentSettings.default_council_id = value || null;
532
+
533
+ // Re-render the dropdown to update trigger display and selected state
534
+ this.renderCouncilDropdown();
535
+ this.closeCouncilDropdown(container);
536
+
537
+ // Render council card preview or hide it
538
+ const cardPreview = document.getElementById('model-card-preview');
539
+ const council = value ? this.councils.find(c => c.id === value) : null;
540
+ if (council && cardPreview) {
541
+ cardPreview.style.display = '';
542
+ this.renderCouncilCard(council);
543
+ } else if (cardPreview) {
544
+ cardPreview.style.display = 'none';
545
+ }
546
+
547
+ this.checkForChanges();
548
+ }
549
+
550
+ /**
551
+ * Escape HTML to prevent XSS in dynamic content
552
+ * @param {string} str
553
+ * @returns {string}
307
554
  */
308
- renderModelCards() {
309
- const container = document.getElementById('model-cards');
555
+ escapeHtml(str) {
556
+ if (!str) return '';
557
+ const div = document.createElement('div');
558
+ div.textContent = str;
559
+ return div.innerHTML;
560
+ }
561
+
562
+ /**
563
+ * Render provider select dropdown
564
+ */
565
+ renderProviderSelect() {
566
+ const select = document.getElementById('provider-select');
567
+ if (!select) return;
568
+
569
+ select.innerHTML = Object.entries(this.providers).map(([id, provider]) =>
570
+ `<option value="${id}" ${id === this.selectedProvider ? 'selected' : ''}>${provider.name}</option>`
571
+ ).join('');
572
+ }
573
+
574
+ /**
575
+ * Set the selected provider (used by updateUI for initial load)
576
+ */
577
+ selectProvider(providerId) {
578
+ if (!this.providers[providerId]) return;
579
+ this.selectedProvider = providerId;
580
+ }
581
+
582
+ /**
583
+ * Render model select dropdown for the currently selected provider
584
+ */
585
+ renderModelSelect() {
586
+ const select = document.getElementById('model-select');
587
+ if (!select) return;
588
+
589
+ const provider = this.providers[this.selectedProvider];
590
+ if (!provider) {
591
+ select.innerHTML = '';
592
+ this.renderModelCard();
593
+ return;
594
+ }
595
+
596
+ select.innerHTML = provider.models.map(model =>
597
+ `<option value="${model.id}" ${model.id === this.currentSettings.default_model ? 'selected' : ''}>${model.name}</option>`
598
+ ).join('');
599
+
600
+ this.renderModelCard();
601
+ }
602
+
603
+ /**
604
+ * Render a model card preview for the currently selected provider + model.
605
+ * Reuses the model-card design from the analysis config modal (styled in pr.css).
606
+ */
607
+ renderModelCard() {
608
+ const container = document.getElementById('model-card-preview');
310
609
  if (!container) return;
311
610
 
312
611
  const provider = this.providers[this.selectedProvider];
@@ -315,35 +614,185 @@ class RepoSettingsPage {
315
614
  return;
316
615
  }
317
616
 
318
- container.innerHTML = provider.models.map(model => `
319
- <button type="button" class="model-card ${model.id === this.currentSettings.default_model ? 'selected' : ''}" data-model="${model.id}" data-tier="${model.tier}">
320
- <div class="model-badge ${model.badgeClass || ''}">${model.badge || ''}</div>
321
- <div class="model-icon">${this.getModelIcon(model.tier)}</div>
617
+ // Find the selected model, fall back to default or first
618
+ const modelId = this.currentSettings.default_model;
619
+ const model = provider.models.find(m => m.id === modelId)
620
+ || provider.models.find(m => m.default)
621
+ || provider.models[0];
622
+
623
+ if (!model) {
624
+ container.innerHTML = '';
625
+ return;
626
+ }
627
+
628
+ const tierIcon = window.getTierIcon ? window.getTierIcon(model.tier) : '';
629
+
630
+ container.innerHTML = `
631
+ <div class="model-card selected settings-model-card-static" data-tier="${this.escapeHtml(model.tier || '')}">
632
+ <div class="model-badge ${this.escapeHtml(model.badgeClass || '')}">${this.escapeHtml(model.badge || '')}</div>
633
+ <div class="model-icon">${tierIcon}</div>
322
634
  <div class="model-info">
323
- <span class="model-name">${model.name}</span>
324
- <span class="model-tagline">${model.tagline || ''}</span>
635
+ <span class="model-name">${this.escapeHtml(model.name)}</span>
636
+ <span class="model-tagline">${this.escapeHtml(model.tagline || '')}</span>
325
637
  </div>
326
- <p class="model-description">${model.description || ''}</p>
327
- <div class="model-selected-indicator">
328
- <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
329
- <path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
330
- </svg>
331
- </div>
332
- </button>
333
- `).join('');
638
+ <p class="model-description">${this.escapeHtml(model.description || '')}</p>
639
+ </div>
640
+ `;
641
+ }
334
642
 
335
- // Attach event listeners
336
- container.querySelectorAll('.model-card').forEach(card => {
337
- card.addEventListener('click', () => this.selectModel(card.dataset.model));
338
- });
643
+ /**
644
+ * Resolve provider/model IDs to display names using loaded provider data
645
+ * @param {string} providerId
646
+ * @param {string} modelId
647
+ * @returns {{ providerName: string, modelName: string }}
648
+ */
649
+ resolveModelDisplay(providerId, modelId) {
650
+ const provider = this.providers[providerId];
651
+ if (!provider) {
652
+ return { providerName: providerId || 'Unknown', modelName: modelId || 'Unknown' };
653
+ }
654
+ const model = provider.models?.find(m => m.id === modelId);
655
+ return {
656
+ providerName: provider.name,
657
+ modelName: model ? model.name : (modelId || 'Unknown')
658
+ };
339
659
  }
340
660
 
341
661
  /**
342
- * Get model icon based on tier
343
- * Delegates to shared utility in utils/tier-icons.js
662
+ * Render a council card preview into #model-card-preview
663
+ * @param {object} council - Council object with id, name, type, config
344
664
  */
345
- getModelIcon(tier) {
346
- return window.getTierIcon(tier);
665
+ renderCouncilCard(council) {
666
+ if (!council) return;
667
+ if (council.type === 'advanced') {
668
+ this.renderAdvancedCouncilCard(council);
669
+ } else {
670
+ this.renderVoiceCouncilCard(council);
671
+ }
672
+ }
673
+
674
+ /**
675
+ * Render a standard (voice) council card
676
+ * @param {object} council
677
+ */
678
+ renderVoiceCouncilCard(council) {
679
+ const container = document.getElementById('model-card-preview');
680
+ if (!container) return;
681
+
682
+ const config = council.config || {};
683
+ const voices = config.voices || [];
684
+ const levels = config.levels || {};
685
+
686
+ // Build summary: "Levels 1, 2" for enabled levels
687
+ const enabledLevels = Object.entries(levels)
688
+ .filter(([, enabled]) => enabled)
689
+ .map(([level]) => level);
690
+ const summaryText = enabledLevels.length > 0
691
+ ? `Levels ${enabledLevels.join(', ')}`
692
+ : 'No levels configured';
693
+
694
+ // Build reviewer list
695
+ const reviewerLines = voices.map(voice => {
696
+ const display = this.resolveModelDisplay(voice.provider, voice.model);
697
+ const tierLabel = voice.tier ? `<span class="council-card-tier">${this.escapeHtml(voice.tier)}</span>` : '';
698
+ return `<div class="council-card-reviewer">
699
+ <span class="council-card-reviewer-name">${this.escapeHtml(display.providerName)} / ${this.escapeHtml(display.modelName)}</span>
700
+ ${tierLabel}
701
+ </div>`;
702
+ }).join('');
703
+
704
+ // Build consolidation section
705
+ let consolidationHTML = '';
706
+ if (config.consolidation && config.consolidation.provider) {
707
+ const consolDisplay = this.resolveModelDisplay(config.consolidation.provider, config.consolidation.model);
708
+ const consolTier = config.consolidation.tier ? `<span class="council-card-tier">${this.escapeHtml(config.consolidation.tier)}</span>` : '';
709
+ consolidationHTML = `
710
+ <div class="council-card-divider"></div>
711
+ <div class="council-card-consolidation">
712
+ <div class="council-card-consolidation-label">Consolidation</div>
713
+ <div class="council-card-reviewer">
714
+ <span class="council-card-reviewer-name">${this.escapeHtml(consolDisplay.providerName)} / ${this.escapeHtml(consolDisplay.modelName)}</span>
715
+ ${consolTier}
716
+ </div>
717
+ </div>`;
718
+ }
719
+
720
+ container.innerHTML = `
721
+ <div class="council-card">
722
+ <div class="council-card-name">${this.escapeHtml(council.name)}</div>
723
+ <div class="council-card-summary">${summaryText}</div>
724
+ <div class="council-card-reviewers">
725
+ ${reviewerLines}
726
+ </div>
727
+ ${consolidationHTML}
728
+ </div>
729
+ `;
730
+ }
731
+
732
+ /**
733
+ * Render an advanced council card with level-grouped reviewers
734
+ * @param {object} council
735
+ */
736
+ renderAdvancedCouncilCard(council) {
737
+ const container = document.getElementById('model-card-preview');
738
+ if (!container) return;
739
+
740
+ const config = council.config || {};
741
+ const levels = config.levels || {};
742
+
743
+ const levelLabels = {
744
+ '1': 'Level 1 — Isolation',
745
+ '2': 'Level 2 — File Context',
746
+ '3': 'Level 3 — Codebase'
747
+ };
748
+
749
+ // Build level groups for enabled levels
750
+ let levelGroupsHTML = '';
751
+ for (const [levelNum, levelConfig] of Object.entries(levels)) {
752
+ if (!levelConfig || !levelConfig.enabled) continue;
753
+ const voices = levelConfig.voices || [];
754
+ const header = levelLabels[levelNum] || `Level ${levelNum}`;
755
+ const voiceLines = voices.map(voice => {
756
+ const display = this.resolveModelDisplay(voice.provider, voice.model);
757
+ const tierLabel = voice.tier ? `<span class="council-card-tier">${this.escapeHtml(voice.tier)}</span>` : '';
758
+ return `<div class="council-card-reviewer">
759
+ <span class="council-card-reviewer-name">${this.escapeHtml(display.providerName)} / ${this.escapeHtml(display.modelName)}</span>
760
+ ${tierLabel}
761
+ </div>`;
762
+ }).join('');
763
+ levelGroupsHTML += `
764
+ <div class="council-card-level-header">${this.escapeHtml(header)}</div>
765
+ ${voiceLines}`;
766
+ }
767
+
768
+ // Build consolidation/orchestration section
769
+ let consolidationHTML = '';
770
+ if (config.consolidation && config.consolidation.provider) {
771
+ const consolDisplay = this.resolveModelDisplay(config.consolidation.provider, config.consolidation.model);
772
+ const consolTier = config.consolidation.tier ? `<span class="council-card-tier">${this.escapeHtml(config.consolidation.tier)}</span>` : '';
773
+ consolidationHTML = `
774
+ <div class="council-card-divider"></div>
775
+ <div class="council-card-consolidation">
776
+ <div class="council-card-consolidation-label">Orchestration</div>
777
+ <div class="council-card-reviewer">
778
+ <span class="council-card-reviewer-name">${this.escapeHtml(consolDisplay.providerName)} / ${this.escapeHtml(consolDisplay.modelName)}</span>
779
+ ${consolTier}
780
+ </div>
781
+ </div>`;
782
+ }
783
+
784
+ container.innerHTML = `
785
+ <div class="council-card">
786
+ <div class="council-card-name">
787
+ ${this.escapeHtml(council.name)}
788
+ <span class="council-card-badge-advanced">Advanced</span>
789
+ </div>
790
+ <div class="council-card-reviewers">
791
+ ${levelGroupsHTML}
792
+ </div>
793
+ ${consolidationHTML}
794
+ </div>
795
+ `;
347
796
  }
348
797
 
349
798
  async loadSettings() {
@@ -362,6 +811,8 @@ class RepoSettingsPage {
362
811
  this.originalSettings = {
363
812
  default_provider: settings.default_provider || null,
364
813
  default_model: settings.default_model || null,
814
+ default_tab: settings.default_tab || 'single',
815
+ default_council_id: settings.default_council_id || null,
365
816
  default_instructions: settings.default_instructions || '',
366
817
  local_path: settings.local_path || null
367
818
  };
@@ -378,6 +829,8 @@ class RepoSettingsPage {
378
829
  this.originalSettings = {
379
830
  default_provider: null,
380
831
  default_model: null,
832
+ default_tab: 'single',
833
+ default_council_id: null,
381
834
  default_instructions: '',
382
835
  local_path: null
383
836
  };
@@ -393,19 +846,40 @@ class RepoSettingsPage {
393
846
 
394
847
  if (!providerId || !this.providers[providerId]) {
395
848
  // Provider doesn't exist, fall back to first available
396
- // Update currentSettings directly so state is consistent for saves,
397
- // but don't mark as changed (no unsaved changes indicator)
849
+ // Update both settings so no false dirty state on load
398
850
  providerId = availableProviders[0] || 'claude';
399
851
  this.currentSettings.default_provider = providerId;
852
+ this.originalSettings.default_provider = providerId;
400
853
  }
401
854
 
402
- this.selectProvider(providerId, false);
855
+ this.selectProvider(providerId);
856
+ this.renderProviderSelect();
403
857
 
404
- // Update model selection
405
- if (this.currentSettings.default_model) {
406
- this.selectModel(this.currentSettings.default_model, false);
858
+ // Validate saved model exists in current provider
859
+ const provider = this.providers[this.selectedProvider];
860
+ if (provider) {
861
+ const modelExists = provider.models.some(m => m.id === this.currentSettings.default_model);
862
+ if (!modelExists) {
863
+ const fallbackModel = provider.models.find(m => m.default) || provider.models[0];
864
+ if (fallbackModel) {
865
+ this.currentSettings.default_model = fallbackModel.id;
866
+ this.originalSettings.default_model = fallbackModel.id;
867
+ const modelSelect = document.getElementById('model-select');
868
+ if (modelSelect) modelSelect.value = fallbackModel.id;
869
+ }
870
+ }
407
871
  }
408
872
 
873
+ this.renderModelSelect();
874
+
875
+ // Update analysis mode segmented control (map 'advanced' to 'council')
876
+ const defaultTab = this.currentSettings.default_tab || 'single';
877
+ const mode = (defaultTab === 'council' || defaultTab === 'advanced') ? 'council' : 'single';
878
+ this.setAnalysisMode(mode, false);
879
+
880
+ // Update council custom dropdown
881
+ this.renderCouncilDropdown();
882
+
409
883
  // Update instructions textarea
410
884
  const textarea = document.getElementById('default-instructions');
411
885
  if (textarea) {
@@ -442,19 +916,6 @@ class RepoSettingsPage {
442
916
  }
443
917
  }
444
918
 
445
- selectModel(modelId, markAsChanged = true) {
446
- // Update current settings
447
- if (markAsChanged) {
448
- this.currentSettings.default_model = modelId;
449
- this.checkForChanges();
450
- }
451
-
452
- // Update UI
453
- document.querySelectorAll('.model-card').forEach(card => {
454
- card.classList.toggle('selected', card.dataset.model === modelId);
455
- });
456
- }
457
-
458
919
  updateCharCount(count) {
459
920
  const charCountEl = document.getElementById('char-count');
460
921
  if (charCountEl) {
@@ -466,9 +927,11 @@ class RepoSettingsPage {
466
927
  // Use nullish coalescing to normalize null/undefined for consistent comparison
467
928
  const providerChanged = (this.currentSettings.default_provider ?? null) !== (this.originalSettings.default_provider ?? null);
468
929
  const modelChanged = (this.currentSettings.default_model ?? null) !== (this.originalSettings.default_model ?? null);
930
+ const tabChanged = (this.currentSettings.default_tab ?? 'single') !== (this.originalSettings.default_tab ?? 'single');
931
+ const councilChanged = (this.currentSettings.default_council_id ?? null) !== (this.originalSettings.default_council_id ?? null);
469
932
  const instructionsChanged = (this.currentSettings.default_instructions ?? '') !== (this.originalSettings.default_instructions ?? '');
470
933
 
471
- this.hasUnsavedChanges = providerChanged || modelChanged || instructionsChanged;
934
+ this.hasUnsavedChanges = providerChanged || modelChanged || tabChanged || councilChanged || instructionsChanged;
472
935
 
473
936
  // Show/hide action bar
474
937
  const actionBar = document.getElementById('action-bar');
@@ -500,6 +963,8 @@ class RepoSettingsPage {
500
963
  body: JSON.stringify({
501
964
  default_provider: this.currentSettings.default_provider,
502
965
  default_model: this.currentSettings.default_model,
966
+ default_tab: this.currentSettings.default_tab,
967
+ default_council_id: this.currentSettings.default_council_id,
503
968
  default_instructions: this.currentSettings.default_instructions
504
969
  })
505
970
  });
@@ -571,6 +1036,8 @@ class RepoSettingsPage {
571
1036
  body: JSON.stringify({
572
1037
  default_provider: null,
573
1038
  default_model: null,
1039
+ default_tab: null,
1040
+ default_council_id: null,
574
1041
  default_instructions: '',
575
1042
  local_path: null
576
1043
  })
@@ -584,6 +1051,8 @@ class RepoSettingsPage {
584
1051
  this.originalSettings = {
585
1052
  default_provider: null,
586
1053
  default_model: null,
1054
+ default_tab: 'single',
1055
+ default_council_id: null,
587
1056
  default_instructions: '',
588
1057
  local_path: null
589
1058
  };