diff --git a/package.json b/package.json index ccef3077b..d3dd202c6 100644 --- a/package.json +++ b/package.json @@ -537,6 +537,11 @@ "default": "", "description": "Name of the project" }, + "codex-project-manager.showHealthIndicators": { + "type": "boolean", + "default": false, + "description": "Show health indicators for translation cells and files" + }, "codex-project-manager.validationCount": { "type": "integer", "default": 1, diff --git a/src/activationHelpers/contextAware/contentIndexes/indexes/sqliteIndex.ts b/src/activationHelpers/contextAware/contentIndexes/indexes/sqliteIndex.ts index 0503d8080..2f83fce97 100644 --- a/src/activationHelpers/contextAware/contentIndexes/indexes/sqliteIndex.ts +++ b/src/activationHelpers/contextAware/contentIndexes/indexes/sqliteIndex.ts @@ -15,7 +15,7 @@ const debug = (message: string, ...args: any[]) => { }; // Schema version for migrations -export const CURRENT_SCHEMA_VERSION = 12; // Added milestone_index column for O(1) milestone lookup +export const CURRENT_SCHEMA_VERSION = 12; // Added milestone_index and t_health columns export class SQLiteIndexManager { private sql: SqlJsStatic | null = null; @@ -302,10 +302,13 @@ export class SQLiteIndexManager { t_audio_validation_count INTEGER DEFAULT 0, t_audio_validated_by TEXT, t_audio_is_fully_validated BOOLEAN DEFAULT FALSE, - + -- Milestone index for O(1) lookup (0-based milestone index, NULL if no milestone) milestone_index INTEGER, - + + -- Health score for translation quality (0.0-1.0, default 0.3 for unverified) + t_health REAL DEFAULT 0.3, + FOREIGN KEY (s_file_id) REFERENCES files(id) ON DELETE SET NULL, FOREIGN KEY (t_file_id) REFERENCES files(id) ON DELETE SET NULL ) @@ -866,7 +869,7 @@ export class SQLiteIndexManager { // Add target-specific metadata columns if (cellType === 'target') { columns.push('t_current_edit_timestamp', 't_validation_count', 't_validated_by', 't_is_fully_validated', - 't_audio_validation_count', 't_audio_validated_by', 't_audio_is_fully_validated'); + 't_audio_validation_count', 't_audio_validated_by', 't_audio_is_fully_validated', 't_health'); values.push( actualEditTimestamp, // Only t_current_edit_timestamp for target cells (no redundant t_updated_at) extractedMetadata.validationCount || 0, @@ -874,7 +877,8 @@ export class SQLiteIndexManager { extractedMetadata.isFullyValidated ? 1 : 0, extractedMetadata.audioValidationCount || 0, extractedMetadata.audioValidatedBy || null, - extractedMetadata.audioIsFullyValidated ? 1 : 0 + extractedMetadata.audioIsFullyValidated ? 1 : 0, + extractedMetadata.health ?? 0.3 ); } @@ -1034,7 +1038,7 @@ export class SQLiteIndexManager { // Add target-specific metadata columns if (cellType === 'target') { columns.push('t_current_edit_timestamp', 't_validation_count', 't_validated_by', 't_is_fully_validated', - 't_audio_validation_count', 't_audio_validated_by', 't_audio_is_fully_validated'); + 't_audio_validation_count', 't_audio_validated_by', 't_audio_is_fully_validated', 't_health'); values.push( actualEditTimestamp, // Only t_current_edit_timestamp for target cells (no redundant t_updated_at) extractedMetadata.validationCount || 0, @@ -1042,7 +1046,8 @@ export class SQLiteIndexManager { extractedMetadata.isFullyValidated ? 1 : 0, extractedMetadata.audioValidationCount || 0, extractedMetadata.audioValidatedBy || null, - extractedMetadata.audioIsFullyValidated ? 1 : 0 + extractedMetadata.audioIsFullyValidated ? 1 : 0, + extractedMetadata.health ?? 0.3 ); } @@ -1596,6 +1601,33 @@ export class SQLiteIndexManager { return null; } + // Get health values for multiple cells + async getCellsHealth(cellIds: string[]): Promise> { + if (!this.db || cellIds.length === 0) return new Map(); + + const placeholders = cellIds.map(() => '?').join(','); + const stmt = this.db.prepare(` + SELECT cell_id, t_health + FROM cells + WHERE cell_id IN (${placeholders}) + `); + + const result = new Map(); + try { + stmt.bind(cellIds); + while (stmt.step()) { + const row = stmt.getAsObject() as { cell_id: string; t_health: number | null; }; + const health = row.t_health ?? 0.3; + result.set(row.cell_id, health); + console.log(`[SQLiteIndex] getCellsHealth: ${row.cell_id} -> ${health} (raw: ${row.t_health})`); + } + } finally { + stmt.free(); + } + console.log(`[SQLiteIndex] getCellsHealth: queried ${cellIds.length} cells, found ${result.size}`); + return result; + } + // Get translation pair by cell ID async getTranslationPair(cellId: string): Promise { if (!this.db) return null; @@ -3582,6 +3614,7 @@ export class SQLiteIndexManager { audioValidationCount?: number; audioValidatedBy?: string; audioIsFullyValidated?: boolean; + health?: number; } { const result: { currentEditTimestamp?: number | null; @@ -3591,6 +3624,7 @@ export class SQLiteIndexManager { audioValidationCount?: number; audioValidatedBy?: string; audioIsFullyValidated?: boolean; + health?: number; } = {}; if (!metadata || typeof metadata !== "object" || cellType !== "target") { @@ -3645,6 +3679,13 @@ export class SQLiteIndexManager { result.currentEditTimestamp = audioDetails.latestTimestamp; } + // Extract health from metadata (if set) + // Health is optional - if not present, it will default to 0.3 when used + if (typeof metadata.health === 'number') { + result.health = metadata.health; + } + // No need to log when health is missing - it's expected for cells without health scores + return result; } diff --git a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts index 69f2835c1..26c69af87 100755 --- a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts @@ -46,6 +46,7 @@ import { isMatchingFilePair as isMatchingFilePairUtil, } from "../../utils/fileTypeUtils"; import { getCorrespondingSourceUri } from "../../utils/codexNotebookUtils"; +import { getSQLiteIndexManager } from "../../activationHelpers/contextAware/contentIndexes/indexes/sqliteIndexManager"; // Enable debug logging if needed const DEBUG_MODE = false; @@ -293,6 +294,18 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider("showHealthIndicators", false); + this.webviewPanels.forEach((panel) => { + this.postMessageToWebview(panel, { + type: "updateShowHealthIndicators", + showHealthIndicators: showHealthIndicators, + }); + }); + } }); this.context.subscriptions.push(configurationChangeDisposable); @@ -856,6 +869,11 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider("showHealthIndicators", false); + this.postMessageToWebview(webviewPanel, { type: "providerSendsInitialContentPaginated", rev, @@ -870,6 +888,7 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider 0) { + console.log("[CodexCellEditorProvider] Document change event:", { + editType: e.edits[0].type, + cellId: e.edits[0].cellId, + hasHealth: "health" in e.edits[0], + health: (e.edits[0] as any).health, + }); + } + // Check if this is a validation update if (e.edits && e.edits.length > 0 && e.edits[0].type === "validation") { // Broadcast the validation update to all webviews for this document @@ -1098,18 +1127,28 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider { if (docUri === document.uri.toString()) { - safePostMessageToPanel(panel, validationUpdate); + const sent = safePostMessageToPanel(panel, validationUpdate); + console.log("[CodexCellEditorProvider] Message sent:", sent, "to panel:", docUri); } }); - // Still update the current webview with the full content - updateWebview(); + // Note: Don't call updateWebview() here - the targeted validation message + // already includes health, and a full refresh would cause a race condition + // that overwrites the health update } else if (e.edits && e.edits.length > 0 && e.edits[0].type === "audioValidation") { const selectedAudioId = document.getExplicitAudioSelection(e.edits[0].cellId) ?? undefined; // Broadcast the audio validation update to all webviews for this document @@ -1119,6 +1158,7 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider 0) { + const indexManager = getSQLiteIndexManager(); + if (indexManager) { + const healthMap = await indexManager.getCellsHealth(exampleCellIds); + console.log(`[HealthCalc] Health map size: ${healthMap.size}, values:`, Array.from(healthMap.entries())); + if (healthMap.size > 0) { + const healthValues = Array.from(healthMap.values()); + const avgHealth = healthValues.reduce((a, b) => a + b, 0) / healthValues.length; + // Ensure health is at least 0.3 (baseline) - otherwise low-health examples + // would produce even lower health for new cells + calculatedHealth = Math.max(0.3, 0.9 * avgHealth); + console.log(`[HealthCalc] avgHealth: ${avgHealth}, calculatedHealth: ${calculatedHealth}`); + } + } else { + console.log(`[HealthCalc] No index manager available`); + } + } else { + console.log(`[HealthCalc] No example cell IDs provided`); + } + // If multiple variants are present, send to the webview for selection if (completionResult && Array.isArray((completionResult as any).variants) && (completionResult as any).variants.length > 1) { const { variants, testId, testName, isAttentionCheck, correctIndex, decoyCellId } = completionResult as any; @@ -3430,7 +3497,10 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider 0 && newContent !== ""; + if (health !== undefined && hasContent) { + cellToUpdate.metadata.health = health; + } else if (cellToUpdate.metadata.health === undefined && hasContent) { + // Initialize with base health if not set and cell has content + cellToUpdate.metadata.health = 0.3; + } else if (!hasContent) { + // Clear health if cell becomes empty + cellToUpdate.metadata.health = undefined; + } + + // For user edits, only add the edit if content has actually changed + if (editType === EditType.USER_EDIT && cellToUpdate.value === newContent) { + return; // Skip adding edit if normalized content hasn't changed + } // Special case: for non-persisting LLM previews, do not update the cell value // but DO record an LLM_GENERATION edit in metadata so history/auditing is preserved @@ -420,6 +438,8 @@ export class CodexCellDocument implements vscode.CustomDocument { isDeleted: false, }, ]; + // Set health to 100% when validation is retained + cellToUpdate.metadata.health = 1.0; } } } else { @@ -435,6 +455,8 @@ export class CodexCellDocument implements vscode.CustomDocument { isDeleted: false, }, ]; + // Set health to 100% when user edit creates a validation + cellToUpdate.metadata.health = 1.0; } } } @@ -607,6 +629,15 @@ export class CodexCellDocument implements vscode.CustomDocument { ? { ...currentCell.metadata, editType, lastUpdated: Date.now() } : { editType, lastUpdated: Date.now() }; + // Debug: Log health value being sent to SQLite + console.log(`[CodexDocument] 📊 addCellToIndexImmediately for ${cellId}:`, { + hasCurrentCell: !!currentCell, + hasMetadata: !!currentCell?.metadata, + healthInMetadata: currentCell?.metadata?.health, + healthInFullMetadata: fullMetadata.health, + fullMetadataKeys: Object.keys(fullMetadata) + }); + // IMMEDIATE AI KNOWLEDGE UPDATE with FTS synchronization const result = await this._indexManager.upsertCellWithFTSSync( cellId, @@ -1644,6 +1675,7 @@ export class CodexCellDocument implements vscode.CustomDocument { audioValidationLevels?: number[]; requiredTextValidations?: number; requiredAudioValidations?: number; + averageHealth?: number; }> { const progress: Record = {}; const cells = this._documentData.cells || []; @@ -1738,6 +1771,7 @@ export class CodexCellDocument implements vscode.CustomDocument { audioValidationLevels: [], requiredTextValidations: minimumValidationsRequired, requiredAudioValidations: minimumAudioValidationsRequired, + averageHealth: undefined, }; continue; } @@ -1799,12 +1833,35 @@ export class CodexCellDocument implements vscode.CustomDocument { fullyValidatedCells ); + // Backfill health for cells that predate the health system + for (let i = 0; i < subsectionCells.length; i++) { + const cell = subsectionCells[i]; + if (cell.metadata && cell.metadata.health === undefined && cell.cellContent && cell.cellContent.trim().length > 0 && cell.cellContent !== "") { + const hasActiveValidation = countNonDeleted(cellWithValidatedData[i].validatedBy) > 0; + cell.metadata.health = hasActiveValidation ? 1.0 : 0.3; + } + } + + // Calculate average health for cells with content + const healthValues = subsectionCells + .filter((cell) => + cell.cellContent && + cell.cellContent.trim().length > 0 && + cell.cellContent !== "" + ) + .map((cell) => cell.metadata?.health) + .filter((h): h is number => typeof h === 'number'); + const averageHealth = healthValues.length > 0 + ? healthValues.reduce((sum, h) => sum + h, 0) / healthValues.length + : undefined; + progress[subsectionIdx] = { ...progressPercentages, textValidationLevels, audioValidationLevels, requiredTextValidations: minimumValidationsRequired, requiredAudioValidations: minimumAudioValidationsRequired, + averageHealth, }; } @@ -2313,6 +2370,25 @@ export class CodexCellDocument implements vscode.CustomDocument { // The milestone structure doesn't change, but progress needs to be recalculated this.invalidateMilestoneIndexCache(); + // Update health based on validation state + if (validate) { + // Set health to 100% when validated (text validation = full confidence) + cellToUpdate.metadata.health = 1.0; + } else { + // When un-validating, check if any active validations remain + const activeValidations = latestEdit.validatedBy.filter( + (entry) => this.isValidValidationEntry(entry) && !entry.isDeleted + ); + if (activeValidations.length === 0) { + // No validations remain, reset health to baseline + cellToUpdate.metadata.health = 0.3; + } else { + // Still have validations, health should remain at 1.0 + // Explicitly set it to ensure it's updated in the event + cellToUpdate.metadata.health = 1.0; + } + } + // Mark document as dirty this._isDirty = true; @@ -2322,12 +2398,14 @@ export class CodexCellDocument implements vscode.CustomDocument { cellId, type: "validation", validatedBy: latestEdit.validatedBy, + health: cellToUpdate.metadata.health, }), edits: [ { cellId, type: "validation", validatedBy: latestEdit.validatedBy, + health: cellToUpdate.metadata.health, }, ], }); @@ -2338,10 +2416,16 @@ export class CodexCellDocument implements vscode.CustomDocument { username, validationCount: latestEdit.validatedBy.filter(entry => this.isValidValidationEntry(entry) && !entry.isDeleted).length, cellHasContent: !!(cellToUpdate.value && cellToUpdate.value.trim()), - editsCount: cellToUpdate.metadata.edits.length + editsCount: cellToUpdate.metadata.edits.length, + healthAfterValidation: cellToUpdate.metadata.health }); - // Database update will happen automatically when document is saved + // Immediately sync health to SQLite so example queries get up-to-date values + if (cellToUpdate.value) { + Promise.resolve(this.addCellToIndexImmediately(cellId, cellToUpdate.value, EditType.USER_EDIT)).catch(error => { + console.error(`[CodexDocument] Failed to sync health to SQLite for cell ${cellId}:`, error); + }); + } } // Method to validate a cell's audio by a user @@ -2422,6 +2506,21 @@ export class CodexCellDocument implements vscode.CustomDocument { } cellToUpdate.metadata.attachments[attachmentId] = attachment; + // Update health based on validation state + if (validate) { + // Set health to 100% when audio is validated (audio validation = full confidence) + cellToUpdate.metadata.health = 1.0; + } else { + // When un-validating, check if any active validations remain + const activeValidations = attachment.validatedBy.filter( + (entry: any) => this.isValidValidationEntry(entry) && !entry.isDeleted + ); + if (activeValidations.length === 0) { + // No audio validations remain, reset health to baseline + cellToUpdate.metadata.health = 0.3; + } + } + // Mark document as dirty this._isDirty = true; @@ -2431,17 +2530,24 @@ export class CodexCellDocument implements vscode.CustomDocument { cellId, type: "audioValidation", validatedBy: attachment.validatedBy, + health: cellToUpdate.metadata.health, }), edits: [ { cellId, type: "audioValidation", validatedBy: attachment.validatedBy, + health: cellToUpdate.metadata.health, }, ], }); - // Database update will happen automatically when document is saved + // Immediately sync health to SQLite so example queries get up-to-date values + if (cellToUpdate.value) { + Promise.resolve(this.addCellToIndexImmediately(cellId, cellToUpdate.value, EditType.USER_EDIT)).catch(error => { + console.error(`[CodexDocument] Failed to sync health to SQLite for cell ${cellId}:`, error); + }); + } } /** @@ -3336,6 +3442,7 @@ export class CodexCellDocument implements vscode.CustomDocument { selectionTimestamp: cell.metadata?.selectionTimestamp, type: cell.metadata?.type || null, lastUpdated: Date.now(), + health: cell.metadata?.health, }; // Check if this cell has text validation data for logging diff --git a/src/providers/codexCellEditorProvider/utils/cellUtils.ts b/src/providers/codexCellEditorProvider/utils/cellUtils.ts index 8e0ac14be..728aeacb8 100644 --- a/src/providers/codexCellEditorProvider/utils/cellUtils.ts +++ b/src/providers/codexCellEditorProvider/utils/cellUtils.ts @@ -76,6 +76,7 @@ export function convertCellToQuillContent(cell: CustomNotebookCellData): QuillCe selectionTimestamp: cell.metadata?.selectionTimestamp, parentId: cell.metadata?.parentId, isLocked: cell.metadata?.isLocked, + health: cell.metadata?.health, }, }; } diff --git a/src/providers/navigationWebview/navigationWebviewProvider.ts b/src/providers/navigationWebview/navigationWebviewProvider.ts index 0b08b225b..e7d3ea56a 100644 --- a/src/providers/navigationWebview/navigationWebviewProvider.ts +++ b/src/providers/navigationWebview/navigationWebviewProvider.ts @@ -571,6 +571,16 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { const textValidationLevels = computeLevelPercents(textValidationCounts, minimumValidationsRequired); const audioValidationLevels = computeLevelPercents(audioValidationCounts, minimumAudioValidationsRequired); + // Compute average health from cells - only include cells that have content + // Empty cells should not have health scores and should not count towards average + const healthValues = unmergedCells + .filter((cell) => cell.value && cell.value.trim().length > 0 && cell.value !== "") + .map((cell) => cell.metadata?.health) + .filter((h): h is number => typeof h === 'number'); + const averageHealth = healthValues.length > 0 + ? healthValues.reduce((sum, h) => sum + h, 0) / healthValues.length + : undefined; // No health data for files with only empty cells + const { percentTranslationsCompleted, percentAudioTranslationsCompleted, @@ -607,6 +617,7 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { audioValidationLevels, requiredTextValidations: minimumValidationsRequired, requiredAudioValidations: minimumAudioValidationsRequired, + averageHealth, }, sortOrder, fileDisplayName: metadata?.fileDisplayName, @@ -683,6 +694,14 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { const averageTextValidationLevels = avgArray('textValidationLevels', textLen); const averageAudioValidationLevels = avgArray('audioValidationLevels', audioLen); + // Compute average health for the corpus group - only from items that have health data + const healthValues = itemsInGroup + .map((item) => item.progress?.averageHealth) + .filter((h): h is number => typeof h === 'number'); + const groupAverageHealth = healthValues.length > 0 + ? healthValues.reduce((sum, h) => sum + h, 0) / healthValues.length + : undefined; // No health data if no items have health + const sortedItems = itemsInGroup.sort((a, b) => { if (a.sortOrder && b.sortOrder) { return a.sortOrder.localeCompare(b.sortOrder); @@ -707,6 +726,7 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { audioValidationLevels: averageAudioValidationLevels, requiredTextValidations: vscode.workspace.getConfiguration("codex-project-manager").get("validationCount", 1) || 1, requiredAudioValidations: vscode.workspace.getConfiguration("codex-project-manager").get("validationCountAudio", 1) || 1, + averageHealth: groupAverageHealth, }, }); }); @@ -836,10 +856,16 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { this.serializeItem(item) ); + // Get feature flag for health indicators + const showHealthIndicators = vscode.workspace + .getConfiguration("codex-project-manager") + .get("showHealthIndicators", false); + safePostMessageToView(this._view, { command: "updateItems", codexItems: serializedCodexItems, dictionaryItems: serializedDictItems, + showHealthIndicators, }); if (this.bibleBookMap) { diff --git a/src/providers/translationSuggestions/llmCompletion.ts b/src/providers/translationSuggestions/llmCompletion.ts index 17215eb4f..ee8bf8ba4 100644 --- a/src/providers/translationSuggestions/llmCompletion.ts +++ b/src/providers/translationSuggestions/llmCompletion.ts @@ -60,11 +60,13 @@ function handleABTestResult( isAttentionCheck?: boolean; correctIndex?: number; decoyCellId?: string; + names?: string[]; } | null, currentCellId: string, testIdPrefix: string, completionConfig: CompletionConfig, - returnHTML: boolean + returnHTML: boolean, + exampleCellIds?: string[] ): LLMCompletionResult | null { if (result && Array.isArray(result.variants) && result.variants.length === 2) { const allowHtml = Boolean(completionConfig.allowHtmlPredictions); @@ -77,6 +79,8 @@ function handleABTestResult( isAttentionCheck: result.isAttentionCheck, correctIndex: result.correctIndex, decoyCellId: result.decoyCellId, + names: result.names, + exampleCellIds, }; } return null; @@ -90,6 +94,8 @@ export interface LLMCompletionResult { isAttentionCheck?: boolean; correctIndex?: number; decoyCellId?: string; + names?: string[]; + exampleCellIds?: string[]; // IDs of cells used as few-shot examples } export async function llmCompletion( @@ -170,6 +176,10 @@ export async function llmCompletion( numberOfFewShotExamples, completionConfig.useOnlyValidatedExamples ); + + // Extract example cell IDs for health calculation + const exampleCellIds = finalExamples.map(ex => ex.cellId); + if (completionConfig.debugMode) { console.debug(`[llmCompletion] Retrieved ${finalExamples.length} few-shot examples:`, finalExamples.map(ex => ({ cellId: ex.cellId, source: ex.sourceCell?.content?.substring(0, 50) + '...', target: ex.targetCell?.content?.substring(0, 50) + '...' }))); } @@ -206,8 +216,8 @@ export async function llmCompletion( // Generate few-shot examples const fewShotExamples = buildFewShotExamplesText( - finalExamples, - Boolean(completionConfig.allowHtmlPredictions), + finalExamples, + Boolean(completionConfig.allowHtmlPredictions), fewShotExampleFormat || "source-and-target" ); console.log(`[llmCompletion] Built few-shot examples text (${fewShotExamples.length} chars, format: ${fewShotExampleFormat}):`, fewShotExamples.substring(0, 200) + '...'); @@ -302,7 +312,8 @@ export async function llmCompletion( currentCellId, "attention", completionConfig, - returnHTML + returnHTML, + exampleCellIds ); if (testResult) { @@ -335,6 +346,7 @@ export async function llmCompletion( return { variants, isABTest: false, // Identical variants – UI should hide A/B controls + exampleCellIds, }; } catch (error) { // Check if this is a cancellation error and re-throw as-is diff --git a/types/index.d.ts b/types/index.d.ts index 24a3cad98..bb83e50f2 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -949,6 +949,7 @@ type CustomCellMetaData = BaseCustomCellMetaData & { cellLabel?: string; selectedAudioId?: string; // Points to attachment key for explicit audio selection selectionTimestamp?: number; // Timestamp when selectedAudioId was last set + health?: number; // Cell health score 0.0-1.0 (0.3 for new/unverified cells, calculated from examples for LLM-generated) }; export type CustomNotebookCellData = Omit & { @@ -1993,6 +1994,7 @@ interface CodexItem { audioValidationLevels?: number[]; requiredTextValidations?: number; requiredAudioValidations?: number; + averageHealth?: number; // Average health score 0-1 for all cells in the file }; sortOrder?: string; isProjectDictionary?: boolean; @@ -2039,6 +2041,7 @@ type EditorReceiveMessages = validationCountAudio?: number; isAuthenticated?: boolean; userAccessLevel?: number; + showHealthIndicators?: boolean; } | { type: "providerSendsCellPage"; @@ -2252,6 +2255,7 @@ type EditorReceiveMessages = | { type: "currentUsername"; content: { username: string; }; } | { type: "validationCount"; content: number; } | { type: "validationCountAudio"; content: number; } + | { type: "updateShowHealthIndicators"; showHealthIndicators: boolean; } | { type: "configurationChanged"; } | { type: "validationInProgress"; diff --git a/webviews/codex-webviews/src/CodexCellEditor/AudioValidationButton.tsx b/webviews/codex-webviews/src/CodexCellEditor/AudioValidationButton.tsx index 756d9a390..c04572741 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/AudioValidationButton.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/AudioValidationButton.tsx @@ -1,10 +1,8 @@ import React, { useState, useEffect, useRef, Dispatch, SetStateAction } from "react"; import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"; -import { QuillCellContent, ValidationEntry } from "../../../../types"; -import { getCellValueData } from "@sharedUtils"; +import { QuillCellContent } from "../../../../types"; import { useMessageHandler } from "./hooks/useCentralizedMessageDispatcher"; import { processValidationQueue, enqueueValidation } from "./validationQueue"; -import { computeAudioValidationUpdate } from "./validationUtils"; import ValidationStatusIcon from "./AudioValidationStatusIcon"; import { useAudioValidationStatus } from "./hooks/useAudioValidationStatus"; import { audioPopoverTracker } from "./validationUtils"; @@ -22,6 +20,16 @@ interface AudioValidationButtonProps { setShowSparkleButton?: Dispatch>; } +/** + * AudioValidationButton - Provider is the source of truth + * + * This component relies entirely on: + * 1. The `cell` prop (updated by provider) + * 2. The `useAudioValidationStatus` hook (derives state from `cell`) + * 3. Provider messages for validation progress + * + * Local state is only used for UI-specific concerns (popover, keyboard focus, pending state). + */ const AudioValidationButton: React.FC = ({ cellId, cell, @@ -33,156 +41,97 @@ const AudioValidationButton: React.FC = ({ disabledReason, setShowSparkleButton, }) => { - const [isValidated, setIsValidated] = useState(false); - const [username, setUsername] = useState(currentUsername ?? null); - const [requiredAudioValidations, setRequiredAudioValidations] = useState( - requiredAudioValidationsProp ?? 1 - ); - const [userCreatedLatestEdit, setUserCreatedLatestEdit] = useState(false); + // UI-specific local state only const [showPopover, setShowPopover] = useState(false); const [isPendingValidation, setIsPendingValidation] = useState(false); - const [isValidationInProgress, setIsValidationInProgress] = useState(false); const [isKeyboardFocused, setIsKeyboardFocused] = useState(false); + const buttonRef = useRef(null); const closeTimerRef = useRef(null); const ignoreHoverRef = useRef(false); const wasKeyboardNavigationRef = useRef(false); - const clearCloseTimer = () => { - if (closeTimerRef.current != null) { - clearTimeout(closeTimerRef.current); - closeTimerRef.current = null; - } - }; - const scheduleCloseTimer = (callback: () => void, delay = 100) => { - clearCloseTimer(); - closeTimerRef.current = window.setTimeout(callback, delay); - }; const uniqueId = useRef( `audio-validation-${cellId}-${Math.random().toString(36).substring(2, 11)}` ); - const { iconProps: baseIconProps, validators: baseValidators } = useAudioValidationStatus({ - cell, - currentUsername: username, - requiredAudioValidations: requiredAudioValidationsProp ?? null, - isSourceText, - disabled: Boolean(externallyDisabled) || isSourceText, - displayValidationText: false, - }); - - // Create a deduplicated list of validation users - const uniqueValidationUsers = baseValidators; - const currentValidations = baseValidators.length; - - // Update validation state when attachments or hook-derived validators change - useEffect(() => { - if (!cell.attachments) { - return; - } - - const effectiveSelectedAudioId = cell.metadata?.selectedAudioId ?? ""; - - const cellValueData = getCellValueData({ - ...cell, - metadata: { - ...(cell.metadata || {}), - selectedAudioId: effectiveSelectedAudioId, - }, - } as any); - - setUserCreatedLatestEdit( - cellValueData.author === username && cellValueData.editType === "user-edit" - ); + // Use currentUsername prop directly - no local state duplication + const username = currentUsername ?? null; + + // Provider-derived state via hook - this is the source of truth + const { iconProps: baseIconProps, validators: uniqueValidationUsers } = + useAudioValidationStatus({ + cell, + currentUsername: username, + requiredAudioValidations: requiredAudioValidationsProp ?? null, + isSourceText, + disabled: Boolean(externallyDisabled) || isSourceText, + displayValidationText: false, + }); - setIsValidated(Boolean(baseIconProps.isValidatedByCurrentUser)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cell, username, baseValidators, baseIconProps.isValidatedByCurrentUser]); + // Derive from hook - no local state needed + const currentValidations = uniqueValidationUsers.length; + const isValidatedByCurrentUser = baseIconProps.isValidatedByCurrentUser; - // Get the current username when component mounts and listen for configuration changes - useEffect(() => { - if (currentUsername) { - setUsername(currentUsername); - } - }, [currentUsername]); + // Use prop directly with fallback - no local state needed + const requiredAudioValidations = requiredAudioValidationsProp ?? 1; - useEffect(() => { - if (requiredAudioValidationsProp !== undefined && requiredAudioValidationsProp !== null) { - setRequiredAudioValidations(requiredAudioValidationsProp); + const clearCloseTimer = () => { + if (closeTimerRef.current != null) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; } - }, [requiredAudioValidationsProp]); + }; - const applyValidatedByUpdate = (validatedBy: ValidationEntry[] | undefined) => { - const { isValidated: validated } = computeAudioValidationUpdate(validatedBy, username); - setIsValidated(validated); - setIsPendingValidation(false); - setIsValidationInProgress(false); + const scheduleCloseTimer = (callback: () => void, delay = 100) => { + clearCloseTimer(); + closeTimerRef.current = window.setTimeout(callback, delay); }; + // Listen for provider messages to clear pending state useMessageHandler( `audioValidationButton-${cellId}-${uniqueId.current}`, (event: MessageEvent) => { const message = event.data as any; - if (!currentUsername && message.type === "currentUsername") { - setUsername(message.content.username); - } else if ( - requiredAudioValidationsProp == null && - message.type === "validationCountAudio" - ) { - setRequiredAudioValidations(message.content); - - // The component will re-render with the new requiredAudioValidations value - // which will recalculate isFullyValidated in the render function - } else if (message.type === "providerUpdatesAudioValidationState") { - // Handle audio validation state updates from the backend + + if (message.type === "providerUpdatesAudioValidationState") { + // Provider has confirmed the validation state - clear pending if (message.content.cellId === cellId) { - applyValidatedByUpdate(message.content.validatedBy); - } - } else if (message.type === "configurationChanged") { - // Configuration changes now send validationCountAudio directly, no need to refetch - console.log("Configuration changed - audio validation count will be sent directly"); - } else if (message.command === "updateAudioValidationCount") { - if (requiredAudioValidationsProp == null) { - setRequiredAudioValidations(message.content.requiredAudioValidations || 1); + setIsPendingValidation(false); } - setIsValidated(message.content.isValidated); - setUserCreatedLatestEdit(message.content.userCreatedLatestEdit); } else if (message.type === "audioValidationInProgress") { - // Handle audio validation in progress message - if (message.content.cellId === cellId) { - setIsValidationInProgress(message.content.inProgress); - if (!message.content.inProgress) { - // If validation is complete, clear pending state as well - setIsPendingValidation(false); - } + if (message.content.cellId === cellId && !message.content.inProgress) { + setIsPendingValidation(false); } } else if (message.type === "pendingAudioValidationCleared") { if (message.content.cellIds.includes(cellId)) { setIsPendingValidation(false); } } else if (message.type === "audioHistorySelectionChanged") { - applyValidatedByUpdate(message.content.validatedBy); + // Audio selection changed - clear pending as we'll re-render with new data + if (message.content.cellId === cellId) { + setIsPendingValidation(false); + } } }, - [cellId, username, currentUsername, requiredAudioValidationsProp] + [cellId] ); + // Close popover when another becomes active useEffect(() => { if (showPopover && audioPopoverTracker.getActivePopover() !== uniqueId.current) { setShowPopover(false); } }, [showPopover]); - // Track keyboard navigation globally to detect when focus is achieved via keyboard + // Track keyboard navigation for accessibility useEffect(() => { const handleDocumentKeyDown = (e: KeyboardEvent) => { - // Track Tab, Arrow keys, and Enter as keyboard navigation if (e.key === "Tab" || e.key.startsWith("Arrow") || e.key === "Enter") { wasKeyboardNavigationRef.current = true; } }; const handleDocumentMouseDown = () => { - // Reset keyboard navigation flag when mouse is used wasKeyboardNavigationRef.current = false; }; @@ -197,8 +146,9 @@ const AudioValidationButton: React.FC = ({ const handleValidate = (e: React.MouseEvent) => { e.stopPropagation(); setIsPendingValidation(true); - // Add to audio validation queue for sequential processing - enqueueValidation(cellId, !isValidated, true) + + // Send to provider - it will update the cell, which will update the hook + enqueueValidation(cellId, !isValidatedByCurrentUser, true) .then(() => {}) .catch((error) => { console.error("Audio validation queue error:", error); @@ -214,21 +164,16 @@ const AudioValidationButton: React.FC = ({ e.stopPropagation(); if (isDisabled) return; - // briefly ignore hover so the popover can't re-open immediately ignoreHoverRef.current = true; window.setTimeout(() => { ignoreHoverRef.current = false; }, 200); - // Mark that this was a mouse click, not keyboard navigation wasKeyboardNavigationRef.current = false; setIsKeyboardFocused(false); - // Blur the button after mouse click to remove focus (prevents pulse from continuing) - // Use setTimeout to ensure blur happens after the click event completes window.setTimeout(() => { if (buttonRef.current) { - // Find the actual button element within the VSCodeButton component const buttonElement = buttonRef.current.querySelector( "button" ) as HTMLButtonElement; @@ -238,7 +183,7 @@ const AudioValidationButton: React.FC = ({ } }, 0); - if (!isValidated) { + if (!isValidatedByCurrentUser) { handleValidate(e); handleRequestClose(); } @@ -248,7 +193,6 @@ const AudioValidationButton: React.FC = ({ e.stopPropagation(); if (isDisabled) return; - if (ignoreHoverRef.current) return; clearCloseTimer(); @@ -258,7 +202,6 @@ const AudioValidationButton: React.FC = ({ const handleKeyDown = (e: React.KeyboardEvent) => { e.stopPropagation(); - // Mark that keyboard navigation is being used wasKeyboardNavigationRef.current = true; if (e.key === "Enter") { @@ -274,7 +217,6 @@ const AudioValidationButton: React.FC = ({ }; const handleFocus = () => { - // If focus was achieved via keyboard, add the class for pulse animation if (wasKeyboardNavigationRef.current) { setIsKeyboardFocused(true); } @@ -314,8 +256,11 @@ const AudioValidationButton: React.FC = ({ justifyContent: "center", } as const; + // Show as in-progress when pending (waiting for provider response) + const isValidationInProgress = isPendingValidation; const isDisabled = isSourceText || isValidationInProgress || Boolean(externallyDisabled); + // Don't show validation button for source text or if no username is available if (isSourceText || !username) { return null; } @@ -334,7 +279,7 @@ const AudioValidationButton: React.FC = ({ appearance="icon" style={{ ...buttonStyle, - // Add orange border for pending validations - use a consistent orange color + // Add orange border for pending validations ...(isPendingValidation && { border: "2px solid #f5a623", borderRadius: "50%", @@ -345,18 +290,24 @@ const AudioValidationButton: React.FC = ({ onFocus={handleFocus} onBlur={handleBlur} disabled={isDisabled} - title={isDisabled ? disabledReason || "Audio validation requires audio" : undefined} + title={ + isPendingValidation + ? "Validating audio..." + : isDisabled + ? disabledReason || "Audio validation requires audio" + : undefined + } > - {/* Popover for validation users */} + {/* Popover for validation users - uses hook data directly */} {showPopover && uniqueValidationUsers.length > 0 && ( = ({ cancelCloseTimer={clearCloseTimer} scheduleCloseTimer={scheduleCloseTimer} onRemoveSelf={() => { + setIsPendingValidation(true); enqueueValidation(cellId, false, true) .then(() => {}) - .catch((error) => - console.error("Audio validation queue error:", error) - ); - processValidationQueue(vscode, true).catch((error) => - console.error("Audio validation queue processing error:", error) - ); + .catch((error) => { + console.error("Audio validation queue error:", error); + setIsPendingValidation(false); + }); + processValidationQueue(vscode, true).catch((error) => { + console.error("Audio validation queue processing error:", error); + setIsPendingValidation(false); + }); handleRequestClose(); }} /> )} - {/* Add style for spinner animation */} - {/* Popover for validation users */} - {showPopover && uniqueValidationUsers.length > 0 && ( + {/* Popover for validation users OR health status - uses hook data directly */} + {showPopover && ( = ({ cancelCloseTimer={() => {}} scheduleCloseTimer={scheduleCloseTimer} onRemoveSelf={() => { + setIsPendingValidation(true); enqueueValidation(cellId, false) .then(() => {}) - .catch((error) => console.error("Validation queue error:", error)); - processValidationQueue(vscode).catch((error) => - console.error("Validation queue processing error:", error) - ); + .catch((error) => { + console.error("Validation queue error:", error); + setIsPendingValidation(false); + }); + processValidationQueue(vscode).catch((error) => { + console.error("Validation queue processing error:", error); + setIsPendingValidation(false); + }); closePopover(); }} title="Validators" popoverTracker={textPopoverTracker} + health={currentValidations > 0 ? 1.0 : health} + showHealthWhenNoValidators={showHealthIndicators} + isPendingValidation={isPendingValidation} + currentValidations={currentValidations} /> )} diff --git a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx index 5bd7c604f..9ab83f888 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx @@ -10,7 +10,7 @@ import { import { ProgressDots } from "./ProgressDots"; import { deriveSubsectionPercentages, getProgressDisplay } from "../utils/progressUtils"; import MicrophoneIcon from "../../components/ui/icons/MicrophoneIcon"; -import { Languages, Check, RotateCcw } from "lucide-react"; +import { Languages, Heart, Check, RotateCcw } from "lucide-react"; import type { Subsection, ProgressPercentages } from "../../lib/types"; import type { MilestoneIndex, MilestoneInfo } from "../../../../../types"; import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"; @@ -43,9 +43,12 @@ interface MilestoneAccordionProps { audioValidationLevels?: number[]; requiredTextValidations?: number; requiredAudioValidations?: number; + averageHealth?: number; }; requestSubsectionProgress?: (milestoneIdx: number) => void; vscode: any; + handleEditMilestoneModalOpen: () => void; + showHealthIndicators?: boolean; } export function MilestoneAccordion({ @@ -63,6 +66,8 @@ export function MilestoneAccordion({ calculateSubsectionProgress, requestSubsectionProgress, vscode, + handleEditMilestoneModalOpen, + showHealthIndicators = false, }: MilestoneAccordionProps) { // Layout constants const DROPDOWN_MAX_HEIGHT_VIEWPORT_PERCENT = 60; // 60vh @@ -298,6 +303,7 @@ export function MilestoneAccordion({ audioValidationLevels: backendProgress.audioValidationLevels, requiredTextValidations: backendProgress.requiredTextValidations, requiredAudioValidations: backendProgress.requiredAudioValidations, + averageHealth: backendProgress.averageHealth, }; } @@ -319,6 +325,7 @@ export function MilestoneAccordion({ audioValidationLevels: undefined, requiredTextValidations: undefined, requiredAudioValidations: undefined, + averageHealth: undefined, }; }; @@ -855,29 +862,60 @@ export function MilestoneAccordion({ }`} > {subsection.label} - +
+ {showHealthIndicators && typeof progress.averageHealth === 'number' && ( +
+ = 0.7 + ? "var(--vscode-charts-green, #22c55e)" + : progress.averageHealth >= 0.3 + ? "var(--vscode-charts-yellow, #eab308)" + : "var(--vscode-charts-red, #ef4444)", + }} + /> + = 0.7 + ? "var(--vscode-charts-green, #22c55e)" + : progress.averageHealth >= 0.3 + ? "var(--vscode-charts-yellow, #eab308)" + : "var(--vscode-charts-red, #ef4444)", + }} + > + {Math.round(progress.averageHealth * 100)}% + +
+ )} + +
); })} diff --git a/webviews/codex-webviews/src/CodexCellEditor/components/ValidatorPopover.tsx b/webviews/codex-webviews/src/CodexCellEditor/components/ValidatorPopover.tsx index fb2f2932c..77d5c019f 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/ValidatorPopover.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/ValidatorPopover.tsx @@ -18,6 +18,10 @@ interface ValidatorPopoverProps { getActivePopover: () => string | null; setActivePopover: (id: string | null) => void; }; + health?: number; // Health score (0-1) for showing when no validators + showHealthWhenNoValidators?: boolean; // Whether to show health info when validators.length === 0 + isPendingValidation?: boolean; // Whether validation is pending (waiting for backend) + currentValidations?: number; // Number of current validations } export const ValidatorPopover: React.FC = ({ @@ -33,6 +37,10 @@ export const ValidatorPopover: React.FC = ({ scheduleCloseTimer, title = "Validators", popoverTracker = audioPopoverTracker, + health, + showHealthWhenNoValidators = false, + isPendingValidation = false, + currentValidations = 0, }) => { const popoverRef = useRef(null); @@ -151,7 +159,42 @@ export const ValidatorPopover: React.FC = ({ } }; - if (!show || validators.length === 0) return null; + // Show popover if: + // 1. There are validators, OR + // 2. Validation is pending (show "Validating..."), OR + // 3. We should show health when no validators AND health data exists + const hasValidators = validators.length > 0; + const isPendingWithNoValidators = isPendingValidation && !hasValidators; + const shouldShowHealth = + showHealthWhenNoValidators && + health !== undefined && + health !== null && + !isPendingValidation && + !hasValidators; + const shouldShowPopover = hasValidators || isPendingWithNoValidators || shouldShowHealth; + + if (!show || !shouldShowPopover) return null; + + // Helper to format health information + const getHealthInfo = (h: number): { percentage: number; label: string; color: string } => { + const normalizedHealth = Math.max(0, Math.min(1, h)); + const percentage = Math.round(normalizedHealth * 100); + let label = "Unverified"; + let color = "#ef4444"; // red + + if (normalizedHealth >= 1.0) { + label = "Validated"; + color = "#22c55e"; // green + } else if (normalizedHealth >= 0.7) { + label = "High confidence"; + color = "#22c55e"; // green + } else if (normalizedHealth >= 0.3) { + label = "Medium confidence"; + color = "#eab308"; // yellow + } + + return { percentage, label, color }; + }; return (
= ({ onKeyDown={handleKeyDown} >
-
{title}
+
+ {hasValidators + ? title + : isPendingWithNoValidators + ? "Validating..." + : "Health Status"} +
= ({
- {validators.map((user) => { - const isCurrentUser = user.username === currentUsername; + {isPendingWithNoValidators ? ( + // Show pending state while waiting for validation to complete +
+ + Waiting for validation... +
+ ) : hasValidators ? ( + validators.map((user) => { + const isCurrentUser = user.username === currentUsername; - return ( -
-
-
- - {user.username} - - - {formatTimestamp(user.updatedTimestamp)} - -
+ return ( +
+
+
+ + {user.username} + + + {formatTimestamp(user.updatedTimestamp)} + +
- {isCurrentUser && onRemoveSelf && ( - { - e.stopPropagation(); - onRemoveSelf(); - setShow(false); - if (popoverTracker.getActivePopover() === uniqueId) { - popoverTracker.setActivePopover(null); - } - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); + {isCurrentUser && onRemoveSelf && ( + { + e.stopPropagation(); onRemoveSelf(); setShow(false); - } - }} - title="Remove your audio validation" - className="audio-validation-trash-icon flex items-start justify-center cursor-pointer h-8" + if ( + popoverTracker.getActivePopover() === uniqueId + ) { + popoverTracker.setActivePopover(null); + } + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + onRemoveSelf(); + setShow(false); + } + }} + title="Remove your audio validation" + className="audio-validation-trash-icon flex items-start justify-center cursor-pointer h-8" + style={{ + transition: "background-color 0.2s", + }} + > + + + + + + + + )} +
+
+ ); + }) + ) : shouldShowHealth && health !== undefined ? ( + // Show health information when no validators +
+ {(() => { + const healthInfo = getHealthInfo(health); + return ( + <> +
+ Confidence: + + {healthInfo.percentage}% + +
+
+ Status: + + {healthInfo.label} + +
+
- - - - - - - - )} -
-
- ); - })} + No validators yet +
+ + ); + })()} +
+ ) : null}
+
); }; diff --git a/webviews/codex-webviews/src/CodexCellEditor/hooks/useVSCodeMessageHandler.ts b/webviews/codex-webviews/src/CodexCellEditor/hooks/useVSCodeMessageHandler.ts index d55860f12..97de837d7 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/hooks/useVSCodeMessageHandler.ts +++ b/webviews/codex-webviews/src/CodexCellEditor/hooks/useVSCodeMessageHandler.ts @@ -58,6 +58,7 @@ interface UseVSCodeMessageHandlerProps { singleCellTranslationFailed?: () => void; setChapterNumber?: (chapterNumber: number) => void; setAudioAttachments: Dispatch>; + setShowHealthIndicators?: Dispatch>; // A/B testing handlers showABTestVariants?: (data: { variants: string[]; cellId: string; testId: string; }) => void; @@ -109,6 +110,7 @@ export const useVSCodeMessageHandler = ({ singleCellTranslationFailed, setChapterNumber, setAudioAttachments, + setShowHealthIndicators, showABTestVariants, setContentPaginated, handleCellPage, @@ -313,6 +315,13 @@ export const useVSCodeMessageHandler = ({ } break; + case "updateShowHealthIndicators": + // Update health indicators setting + if (setShowHealthIndicators && typeof message.showHealthIndicators === 'boolean') { + setShowHealthIndicators(message.showHealthIndicators); + } + break; + case "providerSendsInitialContentPaginated": if (typeof (message as any).rev === "number") { const msgRev = (message as any).rev as number; @@ -331,6 +340,10 @@ export const useVSCodeMessageHandler = ({ message.sourceCellMap ); } + // Update health indicators setting if provided + if (setShowHealthIndicators && typeof message.showHealthIndicators === 'boolean') { + setShowHealthIndicators(message.showHealthIndicators); + } // Bootstrap audio availability from initial cells try { const units = (message.cells || []) as QuillCellContent[]; diff --git a/webviews/codex-webviews/src/NavigationView/index.tsx b/webviews/codex-webviews/src/NavigationView/index.tsx index d5a17cff6..c0f1b2e7a 100644 --- a/webviews/codex-webviews/src/NavigationView/index.tsx +++ b/webviews/codex-webviews/src/NavigationView/index.tsx @@ -6,7 +6,7 @@ import bibleData from "../assets/bible-books-lookup.json"; import { Progress } from "../components/ui/progress"; import "../tailwind.css"; import { CodexItem } from "types"; -import { Languages, Mic } from "lucide-react"; +import { Languages, Heart, Mic } from "lucide-react"; import { RenameModal } from "../components/RenameModal"; // Declare the acquireVsCodeApi function @@ -32,6 +32,7 @@ interface State { searchQuery: string; bibleBookMap: Map | undefined; hasReceivedInitialData: boolean; + showHealthIndicators: boolean; renameModal: { isOpen: boolean; item: CodexItem | null; @@ -175,8 +176,8 @@ function NavigationView() { previousExpandedGroups: null, searchQuery: "", bibleBookMap: undefined, - hasReceivedInitialData: false, + showHealthIndicators: false, renameModal: { isOpen: false, item: null, @@ -269,6 +270,7 @@ function NavigationView() { codexItems: processedCodexItems, dictionaryItems: message.dictionaryItems || [], hasReceivedInitialData: true, + showHealthIndicators: message.showHealthIndicators ?? false, }; }); break; @@ -588,6 +590,7 @@ function NavigationView() { audioValidationLevels?: number[]; requiredTextValidations?: number; requiredAudioValidations?: number; + averageHealth?: number; }) => { if (typeof progress !== "object") { return { @@ -599,6 +602,7 @@ function NavigationView() { audioValidationLevels: [] as number[], requiredTextValidations: undefined as number | undefined, requiredAudioValidations: undefined as number | undefined, + averageHealth: undefined as number | undefined, }; } const textValidation = Math.max( @@ -626,6 +630,7 @@ function NavigationView() { audioValidationLevels: progress.audioValidationLevels ?? [audioValidation], requiredTextValidations: progress.requiredTextValidations, requiredAudioValidations: progress.requiredAudioValidations, + averageHealth: progress.averageHealth, }; }; @@ -798,6 +803,35 @@ function NavigationView() { className="pl-7 flex flex-col gap-2" onClick={isGroup ? undefined : (e) => e.stopPropagation()} > + {/* Health indicator */} + {typeof progressValues.averageHealth === "number" && + state.showHealthIndicators && (() => { + const healthPercent = Math.round(progressValues.averageHealth! * 100); + return ( +
+ +
+
+
= 70 + ? "var(--vscode-charts-green, #22c55e)" + : healthPercent >= 30 + ? "var(--vscode-charts-yellow, #eab308)" + : "var(--vscode-charts-red, #ef4444)", + }} + /> +
+ + {healthPercent}% + +
+
+ ); + })()} {/* Text progress */}
diff --git a/webviews/codex-webviews/src/lib/types.ts b/webviews/codex-webviews/src/lib/types.ts index a6958c935..99294573f 100644 --- a/webviews/codex-webviews/src/lib/types.ts +++ b/webviews/codex-webviews/src/lib/types.ts @@ -47,6 +47,7 @@ export interface ProgressPercentages { audioValidationLevels?: number[]; requiredTextValidations?: number; requiredAudioValidations?: number; + averageHealth?: number; } export interface Subsection {