@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.
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/review-requests/SKILL.md +54 -0
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +1081 -54
- package/public/css/repo-settings.css +452 -140
- package/public/js/components/AdvancedConfigTab.js +1364 -0
- package/public/js/components/AnalysisConfigModal.js +488 -112
- package/public/js/components/CouncilProgressModal.js +1416 -0
- package/public/js/components/TextInputDialog.js +231 -0
- package/public/js/components/TimeoutSelect.js +367 -0
- package/public/js/components/VoiceCentricConfigTab.js +1334 -0
- package/public/js/local.js +162 -83
- package/public/js/modules/analysis-history.js +185 -11
- package/public/js/modules/comment-manager.js +13 -0
- package/public/js/modules/file-comment-manager.js +28 -0
- package/public/js/pr.js +233 -115
- package/public/js/repo-settings.js +575 -106
- package/public/local.html +11 -1
- package/public/pr.html +6 -1
- package/public/repo-settings.html +28 -21
- package/public/setup.html +8 -2
- package/src/ai/analyzer.js +1262 -111
- package/src/ai/claude-cli.js +2 -2
- package/src/ai/claude-provider.js +6 -6
- package/src/ai/codex-provider.js +6 -6
- package/src/ai/copilot-provider.js +3 -3
- package/src/ai/cursor-agent-provider.js +6 -6
- package/src/ai/gemini-provider.js +6 -6
- package/src/ai/opencode-provider.js +6 -6
- package/src/ai/pi-provider.js +6 -6
- package/src/ai/prompts/baseline/consolidation/balanced.js +208 -0
- package/src/ai/prompts/baseline/consolidation/fast.js +175 -0
- package/src/ai/prompts/baseline/consolidation/thorough.js +283 -0
- package/src/ai/prompts/config.js +1 -1
- package/src/ai/prompts/index.js +26 -2
- package/src/ai/provider.js +4 -2
- package/src/database.js +417 -14
- package/src/main.js +1 -1
- package/src/routes/analysis.js +495 -10
- package/src/routes/config.js +36 -15
- package/src/routes/councils.js +351 -0
- package/src/routes/local.js +33 -11
- package/src/routes/mcp.js +9 -2
- package/src/routes/setup.js +12 -2
- package/src/routes/shared.js +126 -13
- package/src/server.js +34 -4
- package/src/utils/stats-calculator.js +2 -0
package/public/js/local.js
CHANGED
|
@@ -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
|
-
//
|
|
345
|
-
|
|
346
|
-
|
|
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.
|
|
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
|
-
//
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
}
|
|
385
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
const
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
localStorage.setItem(
|
|
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(
|
|
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
|
-
//
|
|
444
|
-
if (window.
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
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
|
-
//
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
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
|
|
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 =
|
|
213
|
-
|
|
214
|
-
|
|
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' : ''}
|
|
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">· ${modelName}</span>
|
|
221
|
-
${newBadge}
|
|
266
|
+
<span class="analysis-history-item-model" ${isCouncil ? `data-council-id="${this.escapeHtml(run.model)}"` : ''}>· ${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
|
-
|
|
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
|
-
|
|
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
|