@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
@@ -7,6 +7,8 @@
7
7
  * - Hiding GitHub-specific UI elements
8
8
  * - Adapting the UI for local uncommitted changes review
9
9
  */
10
+ // STALE_TIMEOUT is declared in pr.js (shared global scope via script tags)
11
+
10
12
  class LocalManager {
11
13
  /**
12
14
  * Create LocalManager instance.
@@ -67,6 +69,18 @@ class LocalManager {
67
69
  // Load local review data
68
70
  await this.loadLocalReview();
69
71
 
72
+ // Auto-trigger analysis if ?analyze=true is present
73
+ const autoAnalyze = new URLSearchParams(window.location.search).get('analyze');
74
+ if (autoAnalyze === 'true' && !window.prManager.isAnalyzing) {
75
+ try {
76
+ await this.startLocalAnalysis(null, {});
77
+ } finally {
78
+ const cleanUrl = new URL(window.location);
79
+ cleanUrl.searchParams.delete('analyze');
80
+ history.replaceState(null, '', cleanUrl);
81
+ }
82
+ }
83
+
70
84
  this.isInitialized = true;
71
85
  }
72
86
 
@@ -289,6 +303,10 @@ class LocalManager {
289
303
 
290
304
  // Override triggerAIAnalysis for local mode
291
305
  manager.triggerAIAnalysis = async function() {
306
+ // Timeout (ms) for stale check — git commands can hang on locked repos.
307
+ // Defined locally to avoid relying on cross-script const from pr.js.
308
+ const STALE_TIMEOUT = 2000;
309
+
292
310
  if (manager.isAnalyzing) {
293
311
  manager.reopenProgressModal();
294
312
  return;
@@ -304,61 +322,41 @@ class LocalManager {
304
322
  return;
305
323
  }
306
324
 
307
- // Run stale check and settings fetch in parallel to minimize dialog delay.
308
- // Use AbortController so the fetch is truly cancelled on timeout,
309
- // freeing the HTTP connection for subsequent requests.
310
- const STALE_TIMEOUT = 2000;
311
- const staleAbort = new AbortController();
312
- const staleTimer = setTimeout(() => {
313
- console.debug(`[Analyze] stale-check timed out after ${STALE_TIMEOUT}ms, aborting`);
314
- staleAbort.abort();
315
- }, STALE_TIMEOUT);
316
- const staleCheckPromise = fetch(`/api/local/${reviewId}/check-stale`, { signal: staleAbort.signal })
317
- .then(r => {
318
- clearTimeout(staleTimer);
319
- if (!r.ok) {
320
- console.warn(`Stale check failed with status ${r.status}`);
321
- return { _fetchError: true, status: r.status };
322
- }
323
- return r.json();
324
- })
325
- .catch(err => {
326
- clearTimeout(staleTimer);
327
- if (err.name === 'AbortError') {
328
- console.debug('[Analyze] stale-check aborted (timeout)');
329
- } else {
330
- console.warn('[Analyze] stale-check fetch error:', err);
331
- }
332
- return null;
333
- });
334
-
335
325
  try {
336
326
  // Show analysis config modal
337
327
  if (!manager.analysisConfigModal) {
338
- clearTimeout(staleTimer);
339
328
  console.warn('AnalysisConfigModal not initialized, proceeding without config');
340
329
  await self.startLocalAnalysis(btn, {});
341
330
  return;
342
331
  }
343
332
 
344
- // Fetch stale check, repo settings, and last instructions in parallel
345
- const [staleResult, repoSettings, lastInstructions] = await Promise.all([
346
- staleCheckPromise,
333
+ // Run stale check and settings fetch in parallel to minimize dialog delay
334
+ // Use AbortController so the fetch is truly cancelled on timeout,
335
+ // freeing the HTTP connection for subsequent requests.
336
+ const _tParallel0 = performance.now();
337
+ const staleAbort = new AbortController();
338
+ const staleTimer = setTimeout(() => {
339
+ console.debug(`[Analyze] stale-check timed out after ${STALE_TIMEOUT}ms, aborting`);
340
+ staleAbort.abort();
341
+ }, STALE_TIMEOUT);
342
+ const staleCheckWithTimeout = fetch(`/api/local/${reviewId}/check-stale`, { signal: staleAbort.signal })
343
+ .then(r => r.ok ? r.json() : null)
344
+ .then(result => { clearTimeout(staleTimer); return result; })
345
+ .catch(() => { clearTimeout(staleTimer); return null; });
346
+ const [staleResult, repoSettings, reviewSettings] = await Promise.all([
347
+ staleCheckWithTimeout,
347
348
  manager.fetchRepoSettings().catch(() => null),
348
- manager.fetchLastCustomInstructions().catch(() => '')
349
+ manager.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null }))
349
350
  ]);
351
+ console.debug(`[Analyze] parallel-fetch (stale+settings): ${Math.round(performance.now() - _tParallel0)}ms`);
350
352
 
351
- // Process stale check result
352
- try {
353
- if (staleResult === null) {
354
- // Timed out or network error — fail open
353
+ // Handle staleness result — check for expected properties to distinguish
354
+ // a valid response from a failed/timed-out fetch (which resolves to null)
355
+ if (staleResult && 'isStale' in staleResult) {
356
+ if (staleResult.isStale === null && staleResult.error) {
355
357
  if (window.toast) {
356
358
  window.toast.showWarning('Could not verify working directory is current.');
357
359
  }
358
- } else if (staleResult._fetchError) {
359
- if (window.toast) {
360
- window.toast.showWarning(`Could not verify working directory is current (${staleResult.status}).`);
361
- }
362
360
  } else if (staleResult.isStale === true) {
363
361
  if (window.confirmDialog) {
364
362
  const choice = await window.confirmDialog.show({
@@ -376,46 +374,57 @@ class LocalManager {
376
374
  return;
377
375
  }
378
376
  }
379
- } else if (staleResult.isStale === null && staleResult.error) {
380
- if (window.toast) {
381
- window.toast.showWarning('Could not verify working directory is current.');
382
- }
383
377
  }
384
- } catch (staleError) {
385
- console.warn('[Local] Error processing staleness:', staleError);
378
+ } else {
379
+ // Network error, HTTP error, or timeout — fail open with warning
386
380
  if (window.toast) {
387
381
  window.toast.showWarning('Could not verify working directory is current.');
388
382
  }
389
383
  }
390
384
 
391
- // Determine model and provider
392
- const modelStorageKey = `pair-review-model:local-${reviewId}`;
393
- const providerStorageKey = `pair-review-provider:local-${reviewId}`;
394
- const rememberedModel = localStorage.getItem(modelStorageKey);
395
- const rememberedProvider = localStorage.getItem(providerStorageKey);
396
- const currentModel = rememberedModel || repoSettings?.default_model || 'opus';
397
- const currentProvider = rememberedProvider || repoSettings?.default_provider || 'claude';
385
+ const lastCouncilId = reviewSettings.last_council_id;
386
+
387
+ // Determine model and provider (priority: repo default > defaults)
388
+ const currentModel = repoSettings?.default_model || 'opus';
389
+ const currentProvider = repoSettings?.default_provider || 'claude';
390
+
391
+ // Determine default tab (priority: localStorage > repo settings > 'single')
392
+ const tabStorageKey = `pair-review-tab:local-${reviewId}`;
393
+ const rememberedTab = localStorage.getItem(tabStorageKey);
394
+ const defaultTab = rememberedTab || repoSettings?.default_tab || 'single';
395
+
396
+ // Restore custom instructions (priority: database > localStorage)
397
+ const instructionsStorageKey = `pair-review-instructions:local-${reviewId}`;
398
+ const lastInstructions = reviewSettings.custom_instructions
399
+ ?? localStorage.getItem(instructionsStorageKey)
400
+ ?? '';
401
+
402
+ // Save tab selection to localStorage when user switches tabs
403
+ manager.analysisConfigModal.onTabChange = (tabId) => {
404
+ localStorage.setItem(tabStorageKey, tabId);
405
+ };
398
406
 
399
407
  // Show config modal
400
408
  const config = await manager.analysisConfigModal.show({
401
409
  currentModel,
402
410
  currentProvider,
411
+ defaultTab,
403
412
  repoInstructions: repoSettings?.default_instructions || '',
404
413
  lastInstructions: lastInstructions,
405
- rememberModel: !!(rememberedModel || rememberedProvider)
414
+ lastCouncilId,
415
+ defaultCouncilId: repoSettings?.default_council_id || null
406
416
  });
407
417
 
408
418
  if (!config) {
409
419
  return;
410
420
  }
411
421
 
412
- // Save preferences if requested
413
- if (config.rememberModel) {
414
- localStorage.setItem(modelStorageKey, config.model);
415
- localStorage.setItem(providerStorageKey, config.provider);
422
+ // Persist custom instructions to localStorage for immediate recall on next dialog open
423
+ const submittedInstructions = config.customInstructions || '';
424
+ if (submittedInstructions) {
425
+ localStorage.setItem(instructionsStorageKey, submittedInstructions);
416
426
  } else {
417
- localStorage.removeItem(modelStorageKey);
418
- localStorage.removeItem(providerStorageKey);
427
+ localStorage.removeItem(instructionsStorageKey);
419
428
  }
420
429
 
421
430
  // Start analysis
@@ -440,8 +449,16 @@ class LocalManager {
440
449
  manager.isAnalyzing = true;
441
450
  manager.setButtonAnalyzing(data.analysisId);
442
451
 
443
- // Optionally reopen progress modal
444
- if (window.progressModal) {
452
+ // Show the appropriate progress modal
453
+ if (data.status?.isCouncil && window.councilProgressModal && data.status?.councilConfig) {
454
+ window.councilProgressModal.setLocalMode(reviewId);
455
+ window.councilProgressModal.show(
456
+ data.analysisId,
457
+ data.status.councilConfig,
458
+ null,
459
+ { configType: data.status.configType || 'advanced' }
460
+ );
461
+ } else if (window.progressModal) {
445
462
  // Update the SSE endpoint for progress modal
446
463
  self.patchProgressModalForLocal();
447
464
  window.progressModal.show(data.analysisId);
@@ -471,6 +488,14 @@ class LocalManager {
471
488
  return;
472
489
  }
473
490
 
491
+ // Prevent duplicate saves from rapid clicks or Cmd+Enter
492
+ const saveBtn = formRow?.querySelector('.save-comment-btn');
493
+ if (saveBtn?.dataset.saving === 'true') {
494
+ return;
495
+ }
496
+ if (saveBtn) saveBtn.dataset.saving = 'true';
497
+ if (saveBtn) saveBtn.disabled = true;
498
+
474
499
  try {
475
500
  const response = await fetch(`/api/local/${reviewId}/user-comments`, {
476
501
  method: 'POST',
@@ -532,6 +557,11 @@ class LocalManager {
532
557
  } catch (error) {
533
558
  console.error('Error saving user comment:', error);
534
559
  alert('Failed to save comment: ' + error.message);
560
+ // Re-enable save button on failure so the user can retry
561
+ if (saveBtn) {
562
+ saveBtn.dataset.saving = 'false';
563
+ saveBtn.disabled = false;
564
+ }
535
565
  }
536
566
  };
537
567
  }
@@ -699,6 +729,15 @@ class LocalManager {
699
729
  const originalSaveEditedUserComment = manager.saveEditedUserComment?.bind(manager);
700
730
  if (originalSaveEditedUserComment) {
701
731
  manager.saveEditedUserComment = async function(commentId) {
732
+ // Prevent duplicate saves from rapid clicks or Cmd+Enter
733
+ const editFormEl = document.querySelector(`#edit-comment-${commentId}`)?.closest('.user-comment-edit-form');
734
+ const saveBtnEl = editFormEl?.querySelector('.save-edit-btn');
735
+ if (saveBtnEl?.dataset.saving === 'true') {
736
+ return;
737
+ }
738
+ if (saveBtnEl) saveBtnEl.dataset.saving = 'true';
739
+ if (saveBtnEl) saveBtnEl.disabled = true;
740
+
702
741
  try {
703
742
  const textarea = document.getElementById(`edit-comment-${commentId}`);
704
743
  const editedText = textarea.value.trim();
@@ -706,6 +745,10 @@ class LocalManager {
706
745
  if (!editedText) {
707
746
  alert('Comment cannot be empty');
708
747
  textarea.focus();
748
+ if (saveBtnEl) {
749
+ saveBtnEl.dataset.saving = 'false';
750
+ saveBtnEl.disabled = false;
751
+ }
709
752
  return;
710
753
  }
711
754
 
@@ -750,6 +793,11 @@ class LocalManager {
750
793
  } catch (error) {
751
794
  console.error('Error saving comment:', error);
752
795
  alert('Failed to save comment');
796
+ // Re-enable save button on failure so the user can retry
797
+ if (saveBtnEl) {
798
+ saveBtnEl.dataset.saving = 'false';
799
+ saveBtnEl.disabled = false;
800
+ }
753
801
  }
754
802
  };
755
803
  }
@@ -915,21 +963,24 @@ class LocalManager {
915
963
  }
916
964
  };
917
965
 
918
- // Patch fetchLastCustomInstructions to use local API endpoint
966
+ // Patch fetchLastReviewSettings to use local API endpoint
919
967
  // Local mode uses a different endpoint pattern than PR mode because local reviews
920
968
  // don't have PR metadata (owner/repo/number). Instead, instructions are stored
921
969
  // directly on the review record and accessed via the review ID.
922
- manager.fetchLastCustomInstructions = async function() {
970
+ manager.fetchLastReviewSettings = async function() {
923
971
  try {
924
972
  const response = await fetch(`/api/local/${reviewId}/review-settings`);
925
973
  if (!response.ok) {
926
- return '';
974
+ return { custom_instructions: '', last_council_id: null };
927
975
  }
928
976
  const data = await response.json();
929
- return data.custom_instructions || '';
977
+ return {
978
+ custom_instructions: data.custom_instructions || '',
979
+ last_council_id: data.last_council_id || null
980
+ };
930
981
  } catch (error) {
931
982
  console.warn('Error fetching last custom instructions:', error);
932
- return '';
983
+ return { custom_instructions: '', last_council_id: null };
933
984
  }
934
985
  };
935
986
 
@@ -1049,23 +1100,39 @@ class LocalManager {
1049
1100
 
1050
1101
  // Staleness is now checked in triggerAIAnalysis before showing config modal
1051
1102
 
1052
- // Start AI analysis
1053
- const response = await fetch(`/api/local/${this.reviewId}/analyze`, {
1054
- method: 'POST',
1055
- headers: {
1056
- 'Content-Type': 'application/json'
1057
- },
1058
- body: JSON.stringify({
1103
+ // Determine endpoint and body based on whether this is a council analysis
1104
+ let analyzeUrl, analyzeBody;
1105
+ if (config.isCouncil) {
1106
+ analyzeUrl = `/api/local/${this.reviewId}/analyze/council`;
1107
+ analyzeBody = {
1108
+ councilId: config.councilId || undefined,
1109
+ councilConfig: config.councilConfig || undefined,
1110
+ configType: config.configType || 'advanced',
1111
+ customInstructions: config.customInstructions || null
1112
+ };
1113
+ } else {
1114
+ analyzeUrl = `/api/local/${this.reviewId}/analyze`;
1115
+ analyzeBody = {
1059
1116
  provider: config.provider || 'claude',
1060
1117
  model: config.model || 'opus',
1061
1118
  tier: config.tier || 'balanced',
1062
1119
  customInstructions: config.customInstructions || null,
1120
+ enabledLevels: config.enabledLevels || [1, 2, 3],
1063
1121
  skipLevel3: config.skipLevel3 || false
1064
- })
1122
+ };
1123
+ }
1124
+
1125
+ // Start AI analysis
1126
+ const response = await fetch(analyzeUrl, {
1127
+ method: 'POST',
1128
+ headers: {
1129
+ 'Content-Type': 'application/json'
1130
+ },
1131
+ body: JSON.stringify(analyzeBody)
1065
1132
  });
1066
1133
 
1067
1134
  if (!response.ok) {
1068
- const error = await response.json();
1135
+ const error = await response.json().catch(() => ({}));
1069
1136
  throw new Error(error.error || 'Failed to start AI analysis');
1070
1137
  }
1071
1138
 
@@ -1079,12 +1146,24 @@ class LocalManager {
1079
1146
  // Set analyzing state
1080
1147
  manager.setButtonAnalyzing(result.analysisId);
1081
1148
 
1082
- // Patch progress modal for local mode
1083
- this.patchProgressModalForLocal();
1084
-
1085
- // Show progress modal
1086
- if (window.progressModal) {
1087
- window.progressModal.show(result.analysisId);
1149
+ // Always use the unified progress modal
1150
+ if (window.councilProgressModal) {
1151
+ window.councilProgressModal.setLocalMode(this.reviewId);
1152
+ window.councilProgressModal.show(
1153
+ result.analysisId,
1154
+ config.isCouncil ? config.councilConfig : null,
1155
+ config.isCouncil ? config.councilName : null,
1156
+ {
1157
+ configType: config.isCouncil ? (config.configType || 'advanced') : 'single',
1158
+ enabledLevels: config.enabledLevels || [1, 2, 3]
1159
+ }
1160
+ );
1161
+ } else {
1162
+ // Fallback to old progress modal if unified modal not available
1163
+ this.patchProgressModalForLocal();
1164
+ if (window.progressModal) {
1165
+ window.progressModal.show(result.analysisId);
1166
+ }
1088
1167
  }
1089
1168
 
1090
1169
  } catch (error) {
@@ -33,6 +33,8 @@ class AnalysisHistoryManager {
33
33
  this.isDropdownOpen = false;
34
34
  this.previewingRunId = null; // Track which run ID is currently being previewed to prevent flicker
35
35
  this.newRunId = null; // Track a new run that user hasn't viewed yet
36
+ this.councilNameCache = {}; // Cache council name lookups by council UUID
37
+ this._renderVersion = 0; // Monotonic counter to prevent stale async DOM updates in renderDropdown
36
38
 
37
39
  // DOM elements (cached after init)
38
40
  this.container = null;
@@ -157,6 +159,48 @@ class AnalysisHistoryManager {
157
159
  }
158
160
  }
159
161
 
162
+ /**
163
+ * Resolve a council name from its UUID, with caching.
164
+ * Fetches from /api/councils/:id and caches the result.
165
+ * @param {string} councilId - The council UUID
166
+ * @returns {Promise<string>} The council name, or 'Unknown Council' on failure
167
+ */
168
+ async resolveCouncilName(councilId) {
169
+ if (!councilId) return 'Unknown Council';
170
+
171
+ if (this.councilNameCache[councilId] !== undefined) {
172
+ return this.councilNameCache[councilId];
173
+ }
174
+
175
+ try {
176
+ const response = await fetch(`/api/councils/${councilId}`);
177
+ if (!response.ok) {
178
+ this.councilNameCache[councilId] = 'Unknown Council';
179
+ return 'Unknown Council';
180
+ }
181
+ const data = await response.json();
182
+ const name = data.council?.name || 'Unknown Council';
183
+ this.councilNameCache[councilId] = name;
184
+ return name;
185
+ } catch {
186
+ this.councilNameCache[councilId] = 'Unknown Council';
187
+ return 'Unknown Council';
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Resolve a council name and patch a DOM element with the result.
193
+ * @param {HTMLElement} element - Element to update
194
+ * @param {string} councilId - Council ID to resolve
195
+ * @param {function} formatter - Takes the resolved name, returns the text to set on the element
196
+ */
197
+ async _patchCouncilName(element, councilId, formatter) {
198
+ const name = await this.resolveCouncilName(councilId);
199
+ if (element && element.isConnected) {
200
+ element.textContent = formatter(name);
201
+ }
202
+ }
203
+
160
204
  /**
161
205
  * Load analysis runs from the API (initial load only)
162
206
  *
@@ -202,23 +246,24 @@ class AnalysisHistoryManager {
202
246
  renderDropdown(runs) {
203
247
  if (!this.listElement) return;
204
248
 
249
+ const renderVersion = ++this._renderVersion;
250
+
205
251
  this.listElement.innerHTML = runs.map((run) => {
206
252
  const isSelected = String(run.id) === String(this.selectedRunId);
207
- const isNewRun = String(run.id) === String(this.newRunId);
208
253
  const timeAgo = this.formatRelativeTime(run.completed_at || run.started_at);
209
254
 
210
- const modelName = this.escapeHtml(run.model || 'Unknown');
255
+ const isCouncil = run.provider === 'council';
256
+ const modelName = isCouncil ? 'council' : this.escapeHtml(run.model || 'Unknown');
211
257
  const providerName = this.escapeHtml(this.formatProviderName(run.provider));
212
- const fullTitle = `${this.formatProviderName(run.provider)} - ${run.model || 'Unknown'}`;
213
-
214
- const newBadge = isNewRun ? '<span class="analysis-history-new-badge">LATEST</span>' : '';
258
+ const fullTitle = isCouncil
259
+ ? 'council'
260
+ : `${this.formatProviderName(run.provider)} - ${run.model || 'Unknown'}`;
215
261
 
216
262
  return `
217
- <button class="analysis-history-item ${isSelected ? 'selected' : ''} ${isNewRun ? 'is-new' : ''}" data-run-id="${run.id}">
263
+ <button class="analysis-history-item ${isSelected ? 'selected' : ''}" data-run-id="${run.id}">
218
264
  <div class="analysis-history-item-main" title="${this.escapeHtml(fullTitle)}">
219
265
  <span class="analysis-history-item-provider">${providerName}</span>
220
- <span class="analysis-history-item-model">&middot; ${modelName}</span>
221
- ${newBadge}
266
+ <span class="analysis-history-item-model" ${isCouncil ? `data-council-id="${this.escapeHtml(run.model)}"` : ''}>&middot; ${modelName}</span>
222
267
  </div>
223
268
  <div class="analysis-history-item-meta">
224
269
  <span>${timeAgo}</span>
@@ -227,6 +272,24 @@ class AnalysisHistoryManager {
227
272
  `;
228
273
  }).join('');
229
274
 
275
+ // Async-resolve council names and patch dropdown items
276
+ const councilRuns = runs.filter(r => r.provider === 'council' && r.model);
277
+ if (councilRuns.length > 0) {
278
+ Promise.all(councilRuns.map(async (run) => {
279
+ const modelSpan = this.listElement.querySelector(
280
+ `.analysis-history-item[data-run-id="${run.id}"] .analysis-history-item-model[data-council-id]`
281
+ );
282
+ await this._patchCouncilName(modelSpan, run.model, n => `\u00B7 ${n}`);
283
+ // Skip title update if a newer render has occurred since we started
284
+ if (this._renderVersion !== renderVersion) return;
285
+ // Update the parent title too (name is now cached from _patchCouncilName)
286
+ const mainDiv = modelSpan?.closest('.analysis-history-item-main');
287
+ if (mainDiv && mainDiv.isConnected) {
288
+ mainDiv.title = `council \u00B7 ${this.councilNameCache[run.model] || 'Unknown Council'}`;
289
+ }
290
+ }));
291
+ }
292
+
230
293
  // Add click and hover handlers to items
231
294
  this.listElement.querySelectorAll('.analysis-history-item').forEach(item => {
232
295
  // Click to select
@@ -303,9 +366,15 @@ class AnalysisHistoryManager {
303
366
  const run = this.selectedRun;
304
367
  const timeAgo = this.formatRelativeTime(run.completed_at || run.started_at);
305
368
  const provider = this.formatProviderName(run.provider);
306
- const model = run.model || 'Unknown';
307
369
 
308
- this.historyLabel.textContent = `${timeAgo} \u00B7 ${provider} \u00B7 ${model}`;
370
+ if (run.provider === 'council' && run.model) {
371
+ // Show placeholder immediately, then resolve council name async
372
+ this.historyLabel.textContent = `${timeAgo} \u00B7 council`;
373
+ this._patchCouncilName(this.historyLabel, run.model, name => `${timeAgo} \u00B7 council \u00B7 ${name}`);
374
+ } else {
375
+ const model = run.model || 'Unknown';
376
+ this.historyLabel.textContent = `${timeAgo} \u00B7 ${provider} \u00B7 ${model}`;
377
+ }
309
378
  }
310
379
 
311
380
  /**
@@ -346,8 +415,21 @@ class AnalysisHistoryManager {
346
415
 
347
416
  // Format model in lowercase
348
417
  const modelDisplay = run.model ? run.model.toLowerCase() : 'unknown';
418
+ const isCouncil = run.provider === 'council';
419
+
420
+ let html = '';
349
421
 
350
- let html = `
422
+ if (isCouncil) {
423
+ // For council runs, show a single "Council" row with name (resolved async)
424
+ const cachedName = run.model ? this.councilNameCache[run.model] : null;
425
+ const councilDisplay = cachedName || 'Loading\u2026';
426
+ html += `
427
+ <div class="analysis-preview-row">
428
+ <span class="analysis-preview-label">Council</span>
429
+ <span class="analysis-preview-value analysis-preview-council-name" data-council-id="${this.escapeHtml(run.model || '')}">${this.escapeHtml(councilDisplay)}</span>
430
+ </div>`;
431
+ } else {
432
+ html += `
351
433
  <div class="analysis-preview-row">
352
434
  <span class="analysis-preview-label">Provider</span>
353
435
  <span class="analysis-preview-value">${this.escapeHtml(this.formatProviderName(run.provider))}</span>
@@ -359,6 +441,23 @@ class AnalysisHistoryManager {
359
441
  <div class="analysis-preview-row">
360
442
  <span class="analysis-preview-label">Tier</span>
361
443
  <span class="analysis-preview-value">${this.escapeHtml(tier || 'unknown')}</span>
444
+ </div>`;
445
+ }
446
+
447
+ // Config type row
448
+ const configLabel = this.getConfigTypeLabel(run);
449
+ const configDisplayMap = {
450
+ 'single': 'Single Model',
451
+ 'council': 'Council',
452
+ 'council-voice': 'Council Reviewer',
453
+ 'advanced': 'Advanced'
454
+ };
455
+ const configDisplay = configDisplayMap[configLabel] || configLabel;
456
+
457
+ html += `
458
+ <div class="analysis-preview-row">
459
+ <span class="analysis-preview-label">Config</span>
460
+ <span class="analysis-preview-value">${this.escapeHtml(configDisplay)}</span>
362
461
  </div>
363
462
  <div class="analysis-preview-row">
364
463
  <span class="analysis-preview-label">Status</span>
@@ -384,6 +483,16 @@ class AnalysisHistoryManager {
384
483
  ` : ''}
385
484
  `;
386
485
 
486
+ // Level indicators in preview
487
+ if (run.levels_config) {
488
+ html += `
489
+ <div class="analysis-preview-row">
490
+ <span class="analysis-preview-label">Levels</span>
491
+ <span class="analysis-preview-value">${this.renderLevelIndicators(run)}</span>
492
+ </div>
493
+ `;
494
+ }
495
+
387
496
  // Add collapsible summary section if present
388
497
  const hasSummary = run.summary && run.summary.trim();
389
498
  if (hasSummary) {
@@ -488,6 +597,12 @@ class AnalysisHistoryManager {
488
597
  }
489
598
 
490
599
  this.previewPanel.innerHTML = html;
600
+
601
+ // Async-resolve council name and patch the preview panel element
602
+ if (isCouncil && run.model && !this.councilNameCache[run.model]) {
603
+ const el = this.previewPanel.querySelector(`.analysis-preview-council-name[data-council-id="${run.model}"]`);
604
+ this._patchCouncilName(el, run.model, name => name);
605
+ }
491
606
  }
492
607
 
493
608
  /**
@@ -778,6 +893,65 @@ class AnalysisHistoryManager {
778
893
  return tier.charAt(0).toUpperCase() + tier.slice(1).toLowerCase();
779
894
  }
780
895
 
896
+ /**
897
+ * Determine the display config type for a run.
898
+ * @param {Object} run - Analysis run object
899
+ * @returns {string} One of 'single', 'council', 'council-voice', 'advanced'
900
+ */
901
+ getConfigTypeLabel(run) {
902
+ const configType = run.config_type || 'single';
903
+ if (configType === 'council' && run.parent_run_id) {
904
+ return 'council-voice';
905
+ }
906
+ return configType;
907
+ }
908
+
909
+ /**
910
+ * Render a config type badge for a run.
911
+ * @param {Object} run - Analysis run object
912
+ * @returns {string} HTML string for the badge
913
+ */
914
+ renderConfigTypeBadge(run) {
915
+ const label = this.getConfigTypeLabel(run);
916
+ const displayMap = {
917
+ 'single': 'Single',
918
+ 'council': 'Council',
919
+ 'council-voice': 'Reviewer',
920
+ 'advanced': 'Advanced'
921
+ };
922
+ const display = displayMap[label] || label;
923
+ if (label === 'single') return '';
924
+ return `<span class="analysis-history-config-badge analysis-history-config-${this.escapeHtml(label)}">${this.escapeHtml(display)}</span>`;
925
+ }
926
+
927
+ /**
928
+ * Render level indicators (L1/L2/L3) based on levels_config.
929
+ * @param {Object} run - Analysis run object with optional levels_config
930
+ * @returns {string} HTML string for level indicators
931
+ */
932
+ renderLevelIndicators(run) {
933
+ const levelsConfig = run.levels_config;
934
+ if (!levelsConfig) return '';
935
+
936
+ // levels_config can be:
937
+ // - An array like [1, 2] (voice-centric: enabled levels)
938
+ // - An object like { level1: true, level2: true, level3: false } (advanced)
939
+ const levels = [1, 2, 3];
940
+ const indicators = levels.map(level => {
941
+ let enabled;
942
+ if (Array.isArray(levelsConfig)) {
943
+ enabled = levelsConfig.includes(level);
944
+ } else {
945
+ const key = `level${level}`;
946
+ enabled = levelsConfig[key] !== false;
947
+ }
948
+ const cls = enabled ? 'level-on' : 'level-off';
949
+ const icon = enabled ? '\u2713' : '\u2717';
950
+ return `<span class="analysis-history-level ${cls}">L${level}${icon}</span>`;
951
+ });
952
+ return `<span class="analysis-history-levels">${indicators.join('')}</span>`;
953
+ }
954
+
781
955
  /**
782
956
  * Escape HTML special characters
783
957
  * @param {string} text - Text to escape